Compare commits

...
Author SHA1 Message Date
Quentin Robert 4cc9e87f29 chore(main): release 3.24.1 2026-09-16 16:42:15 +02:00
MasutaYunikon 9097e369c8 fix(jellyfin): fix test route to take body 2026-09-16 16:41:30 +02:00
github-actions[bot] 543aad4236 chore: update version.txt to v3.24.0 2026-09-16 14:00:50 +00:00
Quentin Robert 3807fd2d63 Merge pull request #149 from Masutayunikon/release-please--branches--main
chore(main): release 3.24.0
2026-09-16 16:00:15 +02:00
Quentin Robert b3c2f7a2d1 chore(main): release 3.24.0 2026-09-16 15:03:52 +02:00
Quentin Robert 0845aa6a81 Merge pull request #150 from Nackophilz/feat/refonte-interface-et-corrections
Feat/refonte interface et corrections
2026-09-16 15:03:17 +02:00
ElPouki a4607c0e2f fix : ajout robustesse pour fallback scraper 2026-09-15 16:02:17 +02:00
ElPouki 586eccbbb3 refacto: réécriture des textes, messages et commentaires 2026-09-15 15:30:20 +02:00
ElPouki 6a3cb21adf fix: correction mineures 2026-09-15 15:30:20 +02:00
ElPouki 09eb1fa3aa feat(ui): refonte de l'interface 2026-09-15 15:30:20 +02:00
Quentin Robert 3ede3e808c Merge pull request #148 from Nackophilz/feat/onboarding
feat(onboarding): assistant de configuration au premier lancement et visite guidée des écrans
2026-09-14 15:08:39 +02:00
ElPouki 4b3c629ff9 feat(onboarding): assistant de configuration au premier lancement et visite guidée des écrans 2026-09-14 13:46:51 +02:00
github-actions[bot] 14cb7b9c07 chore: update version.txt to v3.23.1 2026-09-12 00:38:04 +00:00
Quentin Robert c47001c580 Merge pull request #147 from Masutayunikon/release-please--branches--main
chore(main): release 3.23.1
2026-09-12 02:37:42 +02:00
Quentin Robert 529173a472 chore(main): release 3.23.1 2026-09-12 02:37:19 +02:00
Quentin Robert f64d2e19f7 Merge pull request #146 from Nackophilz/main
fix(scrap): Correction fallback si scraper KO
2026-09-12 02:36:56 +02:00
ElPouki 6348cc40cb fix(scrap): Correction fallback si scraper KO 2026-09-11 23:36:28 +02:00
github-actions[bot] 66a4675321 chore: update version.txt to v3.23.0 2026-09-11 21:29:00 +00:00
Quentin Robert 2898627673 Merge pull request #144 from Masutayunikon/release-please--branches--main
chore(main): release 3.23.0
2026-09-11 23:28:38 +02:00
Quentin Robert f6d4001e3c chore(main): release 3.23.0 2026-09-11 22:49:50 +02:00
Quentin Robert c6b26d88f0 Merge pull request #145 from Nackophilz/main
feat(import): catalogue unifié, renommage de dossier série et nettoya……ge des orphelins corection dédoublonnage
2026-09-11 22:49:24 +02:00
ElPouki 0ff7f21958 feat(import): catalogue unifié, renommage de dossier série et nettoyage des orphelins corection dédoublonnage 2026-09-11 22:42:38 +02:00
Quentin Robert 1ca7eac48c Merge pull request #143 from Nackophilz/main
fix(import): repli sur l'API Fankai quand une série est absente du scraper et retrait de Kodi des NFO
2026-09-11 17:20:01 +02:00
ElPouki 67794d92c8 fix(import): repli sur l'API Fankai quand une série est absente du scraper et retrait de Kodi des NFO 2026-09-11 17:04:19 +02:00
github-actions[bot] ca971f4daf chore: update version.txt to v3.22.0 2026-09-11 13:14:48 +00:00
Quentin Robert a32b1c64fe Merge pull request #141 from Masutayunikon/release-please--branches--main
chore(main): release 3.22.0
2026-09-11 15:14:18 +02:00
Quentin Robert ffc925dbdb chore(main): release 3.22.0 2026-09-11 15:13:33 +02:00
Quentin Robert f4f213508d Merge pull request #142 from Nackophilz/main
feat: actions de masse sur les séries et fiabilisation des imports
2026-09-11 15:13:04 +02:00
ElPouki 7f06dd654a feat: actions de masse sur les séries et fiabilisation des imports et correction protection admin 2026-09-11 15:10:40 +02:00
Quentin Robert 7479ddf8eb feat(torrent): ajout d'une option pour ignorer les erreurs de certificat ssl 2026-08-24 23:14:02 +02:00
destrodxbad 0768964041 feat(torrent): ajout d'une option pour ignorer les erreurs de certificat SSL (#138) 2026-07-25 18:19:41 +02:00
github-actions[bot] 9b66c57a6a chore: update version.txt to v3.21.0 2026-06-28 10:26:18 +00:00
Quentin Robert 3eb64b175d Merge pull request #137 from Masutayunikon/release-please--branches--main
chore(main): release 3.21.0
2026-06-28 12:25:54 +02:00
Quentin Robert 25a8adc91d chore(main): release 3.21.0 2026-06-28 12:11:12 +02:00
MasutaYunikon d533751ffb Merge remote-tracking branch 'origin/main' 2026-06-28 12:10:45 +02:00
MasutaYunikon 6f1e0dc844 feat(settings): ajout de englishDirectory qui permet d'avoir Season XX au lieu de Saison X 2026-06-28 12:10:34 +02:00
github-actions[bot] 894a7ed2b1 chore: update version.txt to v3.20.3 2026-06-26 13:25:13 +00:00
Quentin Robert c87b7a4c7f Merge pull request #136 from Masutayunikon/release-please--branches--main
chore(main): release 3.20.3
2026-06-26 15:24:47 +02:00
Quentin Robert a000e349c9 chore(main): release 3.20.3 2026-06-26 15:24:30 +02:00
MasutaYunikon f23e3c04ec Merge remote-tracking branch 'origin/main' 2026-06-26 15:23:45 +02:00
MasutaYunikon 63146fdc27 fix(serie): ajout du label de saisons avec les episodes 2026-06-26 15:21:55 +02:00
github-actions[bot] 7795598346 chore: update version.txt to v3.20.2 2026-06-26 12:52:07 +00:00
Quentin Robert 04252db0c4 Merge pull request #135 from Masutayunikon/release-please--branches--main
chore(main): release 3.20.2
2026-06-26 14:51:44 +02:00
Quentin Robert aeb2e938ef chore(main): release 3.20.2 2026-06-26 14:50:31 +02:00
MasutaYunikon 3799d33c62 fix(scraper): fallback on empty env for GITHUB_BASE
fix(client): add placeholder from config
fix(client): remove alldebrid
2026-06-26 14:49:53 +02:00
MasutaYunikon 98b317d493 fix(label): add season of episodes 2026-06-26 14:25:41 +02:00
Quentin Robert 4292f96645 Merge pull request #131 from Nackophilz/main
Replace favicon and add PNG logo
2026-06-09 21:44:35 +02:00
ElPouki 182c7cc807 add png logo 2026-06-09 21:42:14 +02:00
ElPouki 53be057425 replace favicon 2026-06-09 21:41:51 +02:00
github-actions[bot] ac70dba927 chore: update version.txt to v3.20.1 2026-06-08 00:23:47 +00:00
Quentin Robert f79cc244ef Merge pull request #130 from Masutayunikon/release-please--branches--main
chore(main): release 3.20.1
2026-06-08 02:23:21 +02:00
Quentin Robert 14137e1b1b chore(main): release 3.20.1 2026-06-08 02:22:48 +02:00
MasutaYunikon 43ee4597a9 Merge remote-tracking branch 'origin/main' 2026-06-08 02:16:17 +02:00
MasutaYunikon f311c8e2f3 fix(organize): import manuel ne traite que les fichiers 100% complets
L'import manuel créait un fakeTorrent sans champ files, ce qui désactivait
le filtre de complétion par fichier dans le worker. Sur Windows, tenter de
déplacer un fichier encore écrit par qBittorrent provoque EBUSY ; les fichiers
échouaient mais étaient quand même marqués importés en mémoire.

Fix :
- routes/organize.ts : appel dispatchGetFiles(hash) avant l'import pour
  récupérer la progression réelle par fichier depuis le client torrent
- organize.ts : organizeTorrent accepte un param optionnel files[] transmis
  au worker via fakeTorrent ; sans fichiers (client injoignable), le comportement
  précédent (pas de filtre) est conservé en fallback
2026-06-08 02:12:47 +02:00
github-actions[bot] 48933b4078 chore: update version.txt to v3.20.0 2026-05-29 01:12:44 +00:00
Quentin Robert 45288a130f Merge pull request #129 from Masutayunikon/release-please--branches--main
chore(main): release 3.20.0
2026-05-29 03:12:20 +02:00
Quentin Robert 73256eb15d chore(main): release 3.20.0 2026-05-29 03:05:15 +02:00
MasutaYunikon 6855c04fab Merge remote-tracking branch 'origin/main'
# Conflicts:
#	CHANGELOG.md
#	version.txt
2026-05-29 03:04:39 +02:00
MasutaYunikon f6f735ad88 fix(organize): fallback par torrent_name quand le hash n'est pas résolu
Quand un client torrent (ex: Synology avec dbid_X) ne fournit pas de vrai
infohash BT, la recherche dans le catalogue série échouait silencieusement.

Ajout de findTorrentByName() qui cherche par torrent_name et title dans les
données série. Le hash effectif (vrai infohash BT) est utilisé tout au long
du pipeline d'import (buildFileMap, organized[], organizeTorrent, message).

Fix: l'import manuel de torrents Synology dont le hash est un dbid_X natif
(ex: "[Shalouf] Mob Psycho 100 Henshū (Fan-Kaï)" → dbid_79) échouait avec
"non trouvé dans les données série" alors que le torrent_name correspondait.
2026-05-29 03:03:47 +02:00
MasutaYunikon a7250a5f80 feat(torrent): système générique de suivi des tâches gérées
- index: ajout de managed_tasks.json (clientUuid → [infohash])
  · trackManagedHash() appelé dans dispatchDownload après add() réussi
  · untrackManagedHash() appelé dans dispatchRemove après remove() réussi
  · clearManagedHashes() appelé dans removeClient
  · dispatchList filtre par les hashes suivis pour les drivers avec
    filterByManagedHashes: true (clients sans catégories natives)
- TorrentClientDefinition: nouveau champ filterByManagedHashes (optionnel)
- synology-ds: filterByManagedHashes: true — la liste ne retourne que
  les torrents ajoutés via FanKarr
- synology-ds add(): snapshot avant/après création pour capturer le dbid_X
  et peupler _hashToSynoId immédiatement (sans attendre le prochain list)
2026-05-29 03:03:06 +02:00
MasutaYunikon d326e08834 fix(synology): filtrage par savePath et retour au GET pour add()
- list(): filtre les tâches par detail.destination === savePath (Synology
  n'ayant pas de labels/catégories, c'est le seul moyen de n'afficher que
  les torrents FanKarr) ; sans savePath configuré, tous les torrents sont
  retournés comme avant
- add(): retour à la requête GET (comme l'ancien driver) — le POST avec
  destination dans le body ne fonctionnait pas correctement sur certains
  DSM, alors que les paramètres en query string fonctionnaient
2026-05-29 03:03:05 +02:00
github-actions[bot] 0e2d513cf7 chore: update version.txt to v3.19.0 2026-05-29 03:03:05 +02:00
Quentin Robert 5cf530ba78 chore(main): release 3.19.0 2026-05-29 03:03:05 +02:00
github-actions[bot] 3bc2e08ca5 chore: update version.txt to v3.19.1 2026-05-28 13:42:18 +00:00
Quentin Robert 38996b4095 Merge pull request #128 from Masutayunikon/release-please--branches--main
chore(main): release 3.19.1
2026-05-28 15:41:40 +02:00
Quentin Robert 4063a1b8ae chore(main): release 3.19.1 2026-05-28 15:40:43 +02:00
MasutaYunikon e9e0b487f4 Merge branch 'main' of github.com:Masutayunikon/FanKarr 2026-05-28 15:40:01 +02:00
MasutaYunikon 099b0889b0 fix(ci): rerun deployment 2026-05-28 15:39:53 +02:00
github-actions[bot] 33be4ce687 chore: update version.txt to v3.19.0 2026-05-28 13:38:50 +00:00
Quentin Robert 5c7b3dc5cd Merge pull request #127 from Masutayunikon/release-please--branches--main
chore(main): release 3.19.0
2026-05-28 15:21:51 +02:00
Quentin Robert f909f554ff chore(main): release 3.19.0 2026-05-28 14:51:23 +02:00
MasutaYunikon fc6d88be5e Merge remote-tracking branch 'origin/main' 2026-05-28 14:50:44 +02:00
MasutaYunikon ed556acd1d fix(torrent): correction suppression torrent après déplacement
- synology-ds: ajout cache _hashToSynoId (hash BT → dbid_X) peuplé dans
  list(), utilisé dans remove() et getFiles() pour retrouver la tâche
  même quand detail.hash et detail.uri sont vides dans la réponse DSM
- index: contrôle du retour de dispatchRemove et log d'erreur explicite
  si la suppression échoue, au lieu de toujours logger "supprimé"
2026-05-28 14:49:30 +02:00
MasutaYunikon c6c52a1ae7 feat(synology): files par fichier + getFiles() comme qbittorrent 2026-05-28 14:34:31 +02:00
MasutaYunikon 98ed3c7199 fix(organize): markOrganized manquant en mode copy pour les packs 2026-05-28 14:33:10 +02:00
MasutaYunikon ee1c7d579c fix(synology): resolution hash via cache URI + magnet + detail.hash 2026-05-28 00:58:53 +02:00
github-actions[bot] ba06dce34d chore: update version.txt to v3.18.3 2026-05-27 21:34:20 +00:00
Quentin Robert 8991524463 Merge pull request #126 from Masutayunikon/release-please--branches--main
chore(main): release 3.18.3
2026-05-27 23:33:54 +02:00
Quentin Robert 3fe1006a1e chore(main): release 3.18.3 2026-05-27 23:33:06 +02:00
MasutaYunikon 63c8f8a841 Merge remote-tracking branch 'origin/main' 2026-05-27 23:32:31 +02:00
MasutaYunikon 25877685ea fix(synology): double prefix dbid_ et fallback hash 2026-05-27 23:31:23 +02:00
github-actions[bot] 5b111272d4 chore: update version.txt to v3.18.2 2026-05-27 20:16:43 +00:00
Quentin Robert 67365336bb Merge pull request #125 from Masutayunikon/release-please--branches--main
chore(main): release 3.18.2
2026-05-27 22:16:09 +02:00
Quentin Robert 3a991c8fcf chore(main): release 3.18.2 2026-05-27 22:08:17 +02:00
MasutaYunikon d85708588c Merge remote-tracking branch 'origin/main' 2026-05-27 22:07:41 +02:00
MasutaYunikon 63f6c1276b fix(client): synology use default savepath when 403 2026-05-27 22:07:31 +02:00
github-actions[bot] 8b4d5e20a9 chore: update version.txt to v3.18.1 2026-05-27 15:00:49 +00:00
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
153 changed files with 13618 additions and 4279 deletions

No files matched your search

+2 -1
View File
@@ -5,4 +5,5 @@ node_modules
config
.env
.env.*
!.env.example
!.env.example
.test-sandbox
+5 -11
View File
@@ -1,22 +1,16 @@
# FanKarr — Configuration (binaire bun)
# Copier ce fichier en .env dans le même répertoire que le binaire fankarr
# FanKarr : configuration du binaire Bun
# Copier ce fichier en .env dans le même dossier que le binaire fankarr
# ── Authentification ──────────────────────────────────────────────────────────
# Clé secrète JWT (générée automatiquement si absente, mais recommandé de la fixer)
# Clé secrète JWT (générée automatiquement si absente ; il est conseillé de la fixer)
# JWT_SECRET=changez-moi-avec-une-chaine-aleatoire-longue
# Durée de validité du token de connexion
# Exemples : "1d", "7d", "30d", "1y", "never" ou "0" pour pas d'expiration
# Durée de validité de la session
# Exemples : "1d", "7d", "30d", "1y" ; "never" ou "0" : pas d'expiration
# AUTH_TOKEN_EXPIRY=30d
# ── 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
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 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 }}
+5
View File
@@ -27,3 +27,8 @@ binaries
*.njsproj
*.sln
*.sw?
# Instance de test locale
.test-sandbox
scripts/seed-test-sandbox.mjs
docker-compose.test.yml
View File
Whitespace-only changes.
+316
View File
@@ -1,5 +1,321 @@
# Changelog
## [3.24.1](https://github.com/Masutayunikon/FanKarr/compare/v3.24.0...v3.24.1) (2026-09-16)
### Bug Fixes
* **jellyfin:** fix test route to take body ([9097e36](https://github.com/Masutayunikon/FanKarr/commit/9097e369c80096daa66ac1df454691b949267ef1))
## [3.24.0](https://github.com/Masutayunikon/FanKarr/compare/v3.23.1...v3.24.0) (2026-09-16)
### Features
* **onboarding:** assistant de configuration au premier lancement et visite guidée des écrans ([3ede3e8](https://github.com/Masutayunikon/FanKarr/commit/3ede3e808c1ad27f8534960a048dac8a3283096a))
* **onboarding:** assistant de configuration au premier lancement et visite guidée des écrans ([4b3c629](https://github.com/Masutayunikon/FanKarr/commit/4b3c629ff918a907d04e6c54a1ef2046930523e0))
* **ui:** refonte de l'interface ([09eb1fa](https://github.com/Masutayunikon/FanKarr/commit/09eb1fa3aa369d8961dd3a49cf74edd89ead3da7))
### Bug Fixes
* correction mineures ([6a3cb21](https://github.com/Masutayunikon/FanKarr/commit/6a3cb21adf004dc63ede7df4701c0fec49ad5d3c))
## [3.23.1](https://github.com/Masutayunikon/FanKarr/compare/v3.23.0...v3.23.1) (2026-09-12)
### Bug Fixes
* **scrap:** Correction fallback si scraper KO ([f64d2e1](https://github.com/Masutayunikon/FanKarr/commit/f64d2e19f7f9ab838cee6d33f5523fabb22b5cf7))
* **scrap:** Correction fallback si scraper KO ([6348cc4](https://github.com/Masutayunikon/FanKarr/commit/6348cc40cb42dd23e3b3e77aa1e96f813b9d2687))
## [3.23.0](https://github.com/Masutayunikon/FanKarr/compare/v3.22.0...v3.23.0) (2026-09-11)
### Features
* **import:** catalogue unifié, renommage de dossier série et nettoya……ge des orphelins corection dédoublonnage ([c6b26d8](https://github.com/Masutayunikon/FanKarr/commit/c6b26d88f0a9c5b1092e162f43ff052efdbffb4d))
* **import:** catalogue unifié, renommage de dossier série et nettoyage des orphelins corection dédoublonnage ([0ff7f21](https://github.com/Masutayunikon/FanKarr/commit/0ff7f21958ad33432123f76a2fa7830655329cbb))
### Bug Fixes
* **import:** repli sur l'API Fankai quand une série est absente du scraper et retrait de Kodi des NFO ([1ca7eac](https://github.com/Masutayunikon/FanKarr/commit/1ca7eac48c9337c39b1d40a43a90c993e90d11ea))
* **import:** repli sur l'API Fankai quand une série est absente du scraper et retrait de Kodi des NFO ([67794d9](https://github.com/Masutayunikon/FanKarr/commit/67794d92c8ab48a402bdcb5a2a855df39c86bba8))
## [3.22.0](https://github.com/Masutayunikon/FanKarr/compare/v3.21.0...v3.22.0) (2026-09-11)
### Features
* actions de masse sur les séries et fiabilisation des imports ([f4f2135](https://github.com/Masutayunikon/FanKarr/commit/f4f213508de8500aa5e32e8782f358a22e47ec36))
* actions de masse sur les séries et fiabilisation des imports et correction protection admin ([7f06dd6](https://github.com/Masutayunikon/FanKarr/commit/7f06dd654a72b4960195202ef5597411a54f8d75))
* **torrent:** ajout d'une option pour ignorer les erreurs de certificat ssl ([7479ddf](https://github.com/Masutayunikon/FanKarr/commit/7479ddf8eb63579727cbb0f497ead98c90145da5))
* **torrent:** ajout d'une option pour ignorer les erreurs de certificat SSL ([#138](https://github.com/Masutayunikon/FanKarr/issues/138)) ([0768964](https://github.com/Masutayunikon/FanKarr/commit/07689640413cf9ea57ea69e1b50273c80dffd640))
## [3.21.0](https://github.com/Masutayunikon/FanKarr/compare/v3.20.3...v3.21.0) (2026-06-28)
### Features
* **settings:** ajout de englishDirectory qui permet d'avoir Season XX au lieu de Saison X ([6f1e0dc](https://github.com/Masutayunikon/FanKarr/commit/6f1e0dc844e8f97b0966b223d558c09ab498ddc9))
## [3.20.3](https://github.com/Masutayunikon/FanKarr/compare/v3.20.2...v3.20.3) (2026-06-26)
### Bug Fixes
* **serie:** ajout du label de saisons avec les episodes ([63146fd](https://github.com/Masutayunikon/FanKarr/commit/63146fdc27d98acb70cf86c7890ea1236388d717))
## [3.20.2](https://github.com/Masutayunikon/FanKarr/compare/v3.20.1...v3.20.2) (2026-06-26)
### Bug Fixes
* **client:** add placeholder from config ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
* **client:** remove alldebrid ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
* **label:** add season of episodes ([98b317d](https://github.com/Masutayunikon/FanKarr/commit/98b317d493b7742c330d49ca816c0fecd983e82a))
* **scraper:** fallback on empty env for GITHUB_BASE ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
## [3.20.1](https://github.com/Masutayunikon/FanKarr/compare/v3.20.0...v3.20.1) (2026-06-08)
### Bug Fixes
* **organize:** import manuel ne traite que les fichiers 100% complets ([f311c8e](https://github.com/Masutayunikon/FanKarr/commit/f311c8e2f3b9f53b6d03a327cf1b5bfd69cc6424))
## [3.20.0](https://github.com/Masutayunikon/FanKarr/compare/v3.19.1...v3.20.0) (2026-05-29)
### Features
* **torrent:** système générique de suivi des tâches gérées ([a7250a5](https://github.com/Masutayunikon/FanKarr/commit/a7250a5f804189a7ddecef44193436cf6b324c31))
### Bug Fixes
* **organize:** fallback par torrent_name quand le hash n'est pas résolu ([f6f735a](https://github.com/Masutayunikon/FanKarr/commit/f6f735ad8838d0755472f739571684d75f9dc1b5))
* **synology:** filtrage par savePath et retour au GET pour add() ([d326e08](https://github.com/Masutayunikon/FanKarr/commit/d326e0883477a7dbf453ab28a3fc619f759a8604))
## [3.19.1](https://github.com/Masutayunikon/FanKarr/compare/v3.19.0...v3.19.1) (2026-05-28)
### Bug Fixes
* **ci:** rerun deployment ([099b088](https://github.com/Masutayunikon/FanKarr/commit/099b0889b069f0226b7cb2ec4fa3b1ca535a82a6))
## [3.19.0](https://github.com/Masutayunikon/FanKarr/compare/v3.18.3...v3.19.0) (2026-05-28)
### Features
* **synology:** files par fichier + getFiles() comme qbittorrent ([c6c52a1](https://github.com/Masutayunikon/FanKarr/commit/c6c52a1ae742dcb02ee86f3de04831ac1f041940))
### Bug Fixes
* **organize:** markOrganized manquant en mode copy pour les packs ([98ed3c7](https://github.com/Masutayunikon/FanKarr/commit/98ed3c7199915d2aafbb7be87b41fb725bb0c1ee))
* **synology:** resolution hash via cache URI + magnet + detail.hash ([ee1c7d5](https://github.com/Masutayunikon/FanKarr/commit/ee1c7d579c3795701158e39159511be3cc1789d7))
* **torrent:** correction suppression torrent après déplacement ([ed556ac](https://github.com/Masutayunikon/FanKarr/commit/ed556acd1d2990a6375d4a60f745048a79854249))
## [3.18.3](https://github.com/Masutayunikon/FanKarr/compare/v3.18.2...v3.18.3) (2026-05-27)
### Bug Fixes
* **synology:** double prefix dbid_ et fallback hash ([2587768](https://github.com/Masutayunikon/FanKarr/commit/25877685eaf02b3b1c02ff06db618a37aebf0e6b))
## [3.18.2](https://github.com/Masutayunikon/FanKarr/compare/v3.18.1...v3.18.2) (2026-05-27)
### Bug Fixes
* **client:** synology use default savepath when 403 ([63f6c12](https://github.com/Masutayunikon/FanKarr/commit/63f6c1276bd48a14e5806ff997b6be63c458d94c))
## [3.18.1](https://github.com/Masutayunikon/FanKarr/compare/v3.18.0...v3.18.1) (2026-05-27)
### Bug Fixes
* **client:** suppresion de alldebrid ([8b4b198](https://github.com/Masutayunikon/FanKarr/commit/8b4b198697ba3296444759c522bb0139be69e364))
## [3.18.0](https://github.com/Masutayunikon/FanKarr/compare/v3.17.1...v3.18.0) (2026-05-25)
### Features
* **client:** ajout deluge ([045213d](https://github.com/Masutayunikon/FanKarr/commit/045213d363a75bf56bdb5a05e3965a040ead2893))
### Bug Fixes
* **client:** alldebrid ([045213d](https://github.com/Masutayunikon/FanKarr/commit/045213d363a75bf56bdb5a05e3965a040ead2893))
## [3.17.1](https://github.com/Masutayunikon/FanKarr/compare/v3.17.0...v3.17.1) (2026-05-24)
### Bug Fixes
* **api:** la recherche ne retourne plus les series sans torrents ([7b18631](https://github.com/Masutayunikon/FanKarr/commit/7b1863180f28cfaaccff69cb949fa6116a2c8cc6))
* **client:** alldebrid utilise maintenant les routes 4.1 ([7b18631](https://github.com/Masutayunikon/FanKarr/commit/7b1863180f28cfaaccff69cb949fa6116a2c8cc6))
* **readme:** update ([edec048](https://github.com/Masutayunikon/FanKarr/commit/edec048e30e1a5a528f1b2888f3db1708eeb15f9))
* **readme:** update ([bf9b18e](https://github.com/Masutayunikon/FanKarr/commit/bf9b18ec0002e84b040738f7229ed4570f5dcd32))
## [3.17.0](https://github.com/Masutayunikon/FanKarr/compare/v3.16.0...v3.17.0) (2026-05-23)
### Features
* ajout du parametre auto download ([0ac4812](https://github.com/Masutayunikon/FanKarr/commit/0ac48126aa3f69ded8ad0b7e83b1324360b25def))
* **auth:** migration multi-utilisateurs, rôles admin/user et token API par compte ([dabd1d4](https://github.com/Masutayunikon/FanKarr/commit/dabd1d429ee78b597d1e403c45645f0c89cb7ccc))
* **client:** ajour de alldebrid client ([6ce59b9](https://github.com/Masutayunikon/FanKarr/commit/6ce59b9e0458ecf5b3bc55d703c132a4ac39db66))
* **client:** ajout de alldebrid dans le tableau de comparatif ([f3d535d](https://github.com/Masutayunikon/FanKarr/commit/f3d535d5774ac7b25da6e948c481e32dcf63a5db))
* **demande:** ajout des routes pour recuperer les saisons et episodes depuis le plugin jellyfin ([1176523](https://github.com/Masutayunikon/FanKarr/commit/1176523d6165465a1f07fcc6251e35c239c896f7))
* **demandes:** ajout d'un bouton supprimé pour les admin et supprime les demandes "disponible" si la serie a été supprimé apres ([858a5b3](https://github.com/Masutayunikon/FanKarr/commit/858a5b34bf36bbeec3b18e87623e9b009e69ebcd))
* **demandes:** ajout des demandes par épisodes ([473d97e](https://github.com/Masutayunikon/FanKarr/commit/473d97e39283d5a0f28dd674ca837d3b61673831))
* **demandes:** telechargement auto lors de l'aprobation ([473d97e](https://github.com/Masutayunikon/FanKarr/commit/473d97e39283d5a0f28dd674ca837d3b61673831))
* **jellyfin:** synchronisation des comptes, SSO token exchange et API publique v1 ([dabd1d4](https://github.com/Masutayunikon/FanKarr/commit/dabd1d429ee78b597d1e403c45645f0c89cb7ccc))
* passer les demandes en disponible une fois l'import fait ([423ba6a](https://github.com/Masutayunikon/FanKarr/commit/423ba6a777322077257839ddc39ea03430846517))
* **requests:** demandes de séries avec fusion des saisons, vue admin et widget dashboard ([dabd1d4](https://github.com/Masutayunikon/FanKarr/commit/dabd1d429ee78b597d1e403c45645f0c89cb7ccc))
* **search:** ajout des requetes deja demandé si existante sur la serie ([4a6510a](https://github.com/Masutayunikon/FanKarr/commit/4a6510adde991bba85d20d7bb420a9f7ad6a2f0c))
* **search:** ajout des requetes deja demandé si existante sur la serie ([b7c7e9d](https://github.com/Masutayunikon/FanKarr/commit/b7c7e9d33dd6190cb97a82a685d414601c353e6e))
* **search:** ajout des requetes deja demandé si existante sur la serie ([ec1d170](https://github.com/Masutayunikon/FanKarr/commit/ec1d1705e76934d149a1a4afce5b36613cf0e22d))
* **search:** ajout description ([16b3f74](https://github.com/Masutayunikon/FanKarr/commit/16b3f741e7dbf10daff2f1bb8b1785ff67594515))
* **search:** retourne maintenant l'année et le rating ([99c8d86](https://github.com/Masutayunikon/FanKarr/commit/99c8d86c6e63d362067e18a3a5135ccfa49aff34))
* **users:** gestion des comptes et système d'invitations avec page d'administration ([dabd1d4](https://github.com/Masutayunikon/FanKarr/commit/dabd1d429ee78b597d1e403c45645f0c89cb7ccc))
### Bug Fixes
* ajout cors type ([0bac6da](https://github.com/Masutayunikon/FanKarr/commit/0bac6da9686edc45d79cde48387d5e3f89772646))
* ajout du parametre auto download ([921f6eb](https://github.com/Masutayunikon/FanKarr/commit/921f6eb8d28bbeb6cc4f5f72a6c1c79a14da03ba))
* argument type ([5d06a23](https://github.com/Masutayunikon/FanKarr/commit/5d06a231985f540bb66014c5ac8726f8740b3fde))
* button contrast and font size on card overlay ([bd5b16e](https://github.com/Masutayunikon/FanKarr/commit/bd5b16e17c3a4f2005c058399b2c30038f350f55))
* copy ([0544a51](https://github.com/Masutayunikon/FanKarr/commit/0544a515b654f9bb9183e2fb9d6a4b99de5af87d))
* cors, alldebrid ([911ca86](https://github.com/Masutayunikon/FanKarr/commit/911ca86a0d1025d41870fd1e9cb39ca038df28cb))
* demandes d'épisode qui demande toutes la serie ([62c5b2e](https://github.com/Masutayunikon/FanKarr/commit/62c5b2e622aa872236cf7d620a811c90606222bd))
* demandes d'épisode qui demande toutes la serie ([73678be](https://github.com/Masutayunikon/FanKarr/commit/73678be2c895a3487081b4e811810c8ac36cf1fb))
* **demandes:** multiples torrent et fix episode qui ne s'ajoute pas ([85a2180](https://github.com/Masutayunikon/FanKarr/commit/85a218042de55ae72efab2ce6f36084e14efa108))
* **demandes:** titres torrent ([e6b0bea](https://github.com/Masutayunikon/FanKarr/commit/e6b0bea455413e99155c11003bf055ac244c259a))
* **import:** supprime les entrees organiser quand on supprime un fichier ([6ce59b9](https://github.com/Masutayunikon/FanKarr/commit/6ce59b9e0458ecf5b3bc55d703c132a4ac39db66))
* **log:** changement pour une notification plus explicit lors du test client ([6ce59b9](https://github.com/Masutayunikon/FanKarr/commit/6ce59b9e0458ecf5b3bc55d703c132a4ac39db66))
* search retourne une image mtn ([5478fa6](https://github.com/Masutayunikon/FanKarr/commit/5478fa6d80314e2b0aaf745ef2d667b42e083d26))
* **serie:** 403 pour les non admin ([f3d535d](https://github.com/Masutayunikon/FanKarr/commit/f3d535d5774ac7b25da6e948c481e32dcf63a5db))
* **settings:** bug ou les parametre se sauvegarder en rentrant sur lla page avancé ([f3d535d](https://github.com/Masutayunikon/FanKarr/commit/f3d535d5774ac7b25da6e948c481e32dcf63a5db))
* **settings:** la clé api n'apparait que dans un menu pour les admin ([f3d535d](https://github.com/Masutayunikon/FanKarr/commit/f3d535d5774ac7b25da6e948c481e32dcf63a5db))
* **start:** url cliquable au demarrage dans le terminal ([6ce59b9](https://github.com/Masutayunikon/FanKarr/commit/6ce59b9e0458ecf5b3bc55d703c132a4ac39db66))
* **supression:** supprime maintenant le dossier de la serie et l'ajoute a organized.json ([161f4f5](https://github.com/Masutayunikon/FanKarr/commit/161f4f5051a327421cede35b1d714286588e4f5b))
* telechargement depuis les demandes de saison et integral ([7f4121d](https://github.com/Masutayunikon/FanKarr/commit/7f4121d9e4aab53a5baf105b46c987d69d4909a8))
* update request state after submit + override theme hover on modal buttons ([41addf9](https://github.com/Masutayunikon/FanKarr/commit/41addf9e4fa437f31c478d7074ce86b05d79c689))
## [3.16.0](https://github.com/Masutayunikon/FanKarr/compare/v3.15.0...v3.16.0) (2026-05-11)
### Features
* **log:** + de logs sur les erreurs d'import (chemin tenté et fichier du torrent chercher) ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
* **rss:** ajout du rss sur les series ([086ca30](https://github.com/Masutayunikon/FanKarr/commit/086ca3053f5e563d8c3c2bb82379ff45d77a5499))
* **UI/UX:** affichage de la serie et des episodes sur la page download du torrent ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
### Bug Fixes
* **client:** fix de plusieurs torrent et refonte du tableau de compatibilité ([086ca30](https://github.com/Masutayunikon/FanKarr/commit/086ca3053f5e563d8c3c2bb82379ff45d77a5499))
* **docker:** ajout du fichier de versions pour la navbar dans docker lors du build ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
* **download:** le telechargement de saison ne propose plus tout les torrents des episodes de la saisons si la serie n'a que des torrent par episodes ([c9040ea](https://github.com/Masutayunikon/FanKarr/commit/c9040ea68c52e65a656824ccfd101a04a2251edd))
* **import:** les episodes avec une association changé n'apparait plus sur la page serie ([8972679](https://github.com/Masutayunikon/FanKarr/commit/8972679e2588b6e5cd3e9fd53b0223e3d1a6e56c))
* **import:** les imports se font maintenant sur les series sans torrents qui n'avais pas de fichier de catalogue depuis le scraper ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
* **import:** on ne peux plus importer plusieurs fichiers sur le meme episodes, celui deja importé ce retire de l'importation dans le modal ([ad829c0](https://github.com/Masutayunikon/FanKarr/commit/ad829c0a99b29ade7eba4673b1218c0ab768a2d7))
* **import:** si on change un fichier son association alors il est bien retirer de l'import ([ad829c0](https://github.com/Masutayunikon/FanKarr/commit/ad829c0a99b29ade7eba4673b1218c0ab768a2d7))
* les torrents n'utilise plus la pause ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
* **rss:** le rss est maintenant sur les ajout apres l'activation de la surveillance ([a5e2aa0](https://github.com/Masutayunikon/FanKarr/commit/a5e2aa0da1b3af445a102d1acd77a3a08017689a))
* **UI/UX:** la bar de progression de saison a été retirer et les bars de progression s'affiche meme si le fichier est à 0% dans le torrent ([f07b608](https://github.com/Masutayunikon/FanKarr/commit/f07b608288afd4b01079367b428123b72182b714))
* **UI/UX:** retour sur le modal d'import et refresh ([a5e2aa0](https://github.com/Masutayunikon/FanKarr/commit/a5e2aa0da1b3af445a102d1acd77a3a08017689a))
* **UI/UX:** retour visuel lors de l'import d'une serie/episode ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
* utilisation de URLSearchParams pour les appels autre que pour le add du fichier .torrent ([42c9795](https://github.com/Masutayunikon/FanKarr/commit/42c979536b51e8ed263b23559b7c4a2d4d2b2902))
## [3.15.0](https://github.com/Masutayunikon/FanKarr/compare/v3.14.6...v3.15.0) (2026-05-10)
### Features
* ajout build docker pour arm32 ([950a2ab](https://github.com/Masutayunikon/FanKarr/commit/950a2abc49fe0b7b5bcc0f47d781a45a01838ec3))
## [3.14.6](https://github.com/Masutayunikon/FanKarr/compare/v3.14.5...v3.14.6) (2026-05-08)
### Bug Fixes
* pnpm build ([fcb176d](https://github.com/Masutayunikon/FanKarr/commit/fcb176d519b1bff49c40b79192e71b6062671998))
## [3.14.5](https://github.com/Masutayunikon/FanKarr/compare/v3.14.4...v3.14.5) (2026-05-08)
### Bug Fixes
* pnpm build ([d04ca79](https://github.com/Masutayunikon/FanKarr/commit/d04ca79e0057e3d6cf92e49822d812266f629dba))
## [3.14.4](https://github.com/Masutayunikon/FanKarr/compare/v3.14.3...v3.14.4) (2026-05-08)
### Bug Fixes
* lock file for build ([ef527c2](https://github.com/Masutayunikon/FanKarr/commit/ef527c2072a93650f9159de12ae875a671fb845c))
## [3.14.3](https://github.com/Masutayunikon/FanKarr/compare/v3.14.2...v3.14.3) (2026-05-08)
### Bug Fixes
* build binaries node version ([6d8ea36](https://github.com/Masutayunikon/FanKarr/commit/6d8ea36260cb76e0592bbee3ce5450d2424b9209))
## [3.14.2](https://github.com/Masutayunikon/FanKarr/compare/v3.14.1...v3.14.2) (2026-05-07)
### Bug Fixes
* build binaries node version ([9ebaca9](https://github.com/Masutayunikon/FanKarr/commit/9ebaca92f18213fd6a79bf55f22cfb8358c4f7e0))
## [3.14.1](https://github.com/Masutayunikon/FanKarr/compare/v3.14.0...v3.14.1) (2026-05-07)
### Bug Fixes
* eslint build ([25ab373](https://github.com/Masutayunikon/FanKarr/commit/25ab373539c20942202e82cf678e5b7577ff0027))
## [3.14.0](https://github.com/Masutayunikon/FanKarr/compare/v3.13.2...v3.14.0) (2026-05-07)
### Features
* **qbittorrent:** support API key authentication (≥5.2.0) ([702847a](https://github.com/Masutayunikon/FanKarr/commit/702847a5c2eb5956f54a8475fefc80a08fca70f1))
* **qbittorrent:** support cookie name (≥5.2.0) ([aa8b500](https://github.com/Masutayunikon/FanKarr/commit/aa8b500f57efb97aae10eb0bd8e254388a8aff42))
### Bug Fixes
* ajout de log pour le cookie ([2b3fd79](https://github.com/Masutayunikon/FanKarr/commit/2b3fd79a92bbc7d7ed2142b7a23e03799efe0a28))
* cookie ([7da5a8d](https://github.com/Masutayunikon/FanKarr/commit/7da5a8d4e853f7e92770f20352478a76ee91642d))
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([105cc05](https://github.com/Masutayunikon/FanKarr/commit/105cc05bdf38127089869ea201165ac1d3479275))
* on ne vois plus le telechargement de fichier qui ont une priorité basse (ne pas télécharger) ([1f50f9a](https://github.com/Masutayunikon/FanKarr/commit/1f50f9ae72ec9958bfb92b2e6936c699e19272f3))
* renaming dans le managements des series ([1e92934](https://github.com/Masutayunikon/FanKarr/commit/1e92934f531c82b9da90c8597ec08cd162b5a9ee))
* renaming dans le managements des series ([bf3f303](https://github.com/Masutayunikon/FanKarr/commit/bf3f30356e086d310b7213b267356bc20976589c))
* rtorrent priority default to 1 ([bfabf9b](https://github.com/Masutayunikon/FanKarr/commit/bfabf9b7937f055579add331cd918a13f05084bf))
## [3.13.2](https://github.com/Masutayunikon/FanKarr/compare/v3.13.1...v3.13.2) (2026-05-06)
### Bug Fixes
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([0d5d44a](https://github.com/Masutayunikon/FanKarr/commit/0d5d44a4840df27d8b23bde9935d12c664d5ad6c))
## [3.13.1](https://github.com/Masutayunikon/FanKarr/compare/v3.13.0...v3.13.1) (2026-05-06)
### Bug Fixes
* ajout du champ wiki dans l'api ([95d2b27](https://github.com/Masutayunikon/FanKarr/commit/95d2b270dcb3052b65f5918ec770799b43316cd6))
## [3.13.0](https://github.com/Masutayunikon/FanKarr/compare/v3.12.1...v3.13.0) (2026-05-06)
+4 -2
View File
@@ -1,7 +1,7 @@
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack enable && corepack prepare pnpm@10 --activate
COPY . /app
WORKDIR /app
@@ -16,6 +16,7 @@ RUN pnpm run build
# ── Production ─────────────────────────────────────────────────
FROM node:22-slim
ARG BUILD_VERSION=dev
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -25,7 +26,8 @@ COPY --from=build /app/dist/client /app/public
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN mkdir -p /config
RUN echo "$BUILD_VERSION" > /app/version.txt
EXPOSE 3001
EXPOSE 9898
ENV NODE_ENV=production
ENTRYPOINT ["/entrypoint.sh"]
+272 -104
View File
@@ -11,7 +11,7 @@
![Bun](https://img.shields.io/badge/bun-ready-f9f1e1?style=flat-square)
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
Inspiré de Radarr et Sonarr, pour les éditions Kai et Yabai
![Banner](.github/images/banner.svg)
@@ -21,62 +21,91 @@ Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
## Aperçu
<!-- [img] : vue catalogue complète — grille des affiches avec les barres d'état en bas de chaque affiche (couleurs : dispo / en cours / importé), barre de recherche visible -->
![Catalogue](.github/images/catalog.png)
![Accueil](.github/images/dashboard.webp)
> Vue catalogue : toutes les séries Fankai. Une barre en bas de chaque affiche indique l'état — disponible, en cours de téléchargement ou déjà importé dans votre médiathèque.
> Accueil : le dernier import, les séries ajoutées récemment et les chiffres de la médiathèque. L'administrateur y retrouve ce qui attend une action : demandes, imports en erreur, dossiers à renommer.
---
<!-- [img] : vue série — détail des saisons et épisodes avec badges VOSTFR/MULTI + drapeaux, boutons de téléchargement avec dropdown multi-sources, barres de progression par épisode -->
![Série](.github/images/serie.png)
![Médiathèque](.github/images/catalog.webp)
> Vue série : épisodes avec badge de langue (VOSTFR / MULTI), progression par fichier, et dropdown de sélection quand plusieurs sources sont disponibles.
> Médiathèque : tout le catalogue Fankai. Chaque affiche indique l'état de la série (importée, partielle, en téléchargement, dossier à renommer, nouveauté), avec des filtres, un tri et plusieurs tailles d'affiches.
---
<!-- [img] : vue téléchargements — liste des torrents actifs avec barres de progression, état d'organisation, badges erreur au hover -->
![Téléchargements](.github/images/downloads.png)
![Série](.github/images/serie.webp)
> Vue téléchargements : suivi en temps réel, état d'organisation par torrent et détail des erreurs au survol.
> Fiche d'une série : l'import saison par saison et épisode par épisode, avec le téléchargement d'un épisode, d'une saison ou de toute la série, l'import manuel et la surveillance des nouveaux épisodes.
---
![Activité](.github/images/downloads.webp)
> Activité : un torrent par ligne, du téléchargement à l'import, avec le détail des erreurs et un bouton pour relancer l'import.
---
<p align="center">
<img src=".github/images/mobile-dashboard.webp" width="260" alt="Accueil sur mobile">
<img src=".github/images/mobile-serie.webp" width="260" alt="Fiche d'une série sur mobile">
<img src=".github/images/mobile-activity.webp" width="260" alt="Activité sur mobile">
</p>
> Sur mobile : l'interface s'adapte au téléphone, avec une barre d'onglets en bas de l'écran.
---
## Fonctionnalités
### 📥 Téléchargement
- **Catalogue complet** des séries Fankai avec affiches et état de disponibilité
- **Téléchargement en un clic** vers votre client torrent — par épisode, par saison ou en intégrale
- **Dropdown intelligent** sur chaque bouton quand plusieurs sources sont disponibles (qualités différentes, packs différents)
- **"Tout télécharger"** unifié — sélectionne automatiquement les épisodes non couverts par l'intégrale choisie et complète avec les packs individuels
- **Progression par fichier** — barre de téléchargement au niveau de l'épisode, même pour les fichiers dans un pack intégrale
### Téléchargement
- Tout le catalogue Fankai, avec les affiches et l'état de chaque série
- Envoi au client torrent par épisode, par saison ou en intégrale
- Choix de la source quand plusieurs torrents existent (qualité, pack)
- Bouton « Tout télécharger » : l'intégrale choisie, complétée par les packs des épisodes qu'elle ne contient pas
- Progression par épisode, même pour un fichier contenu dans un pack (si le client torrent l'indique)
- Surveillance : les nouveaux épisodes des séries surveillées sont envoyés au client (vérification toutes les 6 heures)
### 🗂️ Organisation
- **Import automatique** des fichiers terminés vers votre médiathèque, déclenché dès la fin d'un téléchargement ou toutes les 5 minutes en arrière-plan
- **Scan de médiathèque** — détecte les fichiers déjà présents sur le disque et les référence sans les déplacer
- **Mode hardlink** (recommandé), **déplacement** ou **copie** — le hardlink préserve le seeding du client torrent
- **Désimport automatique** optionnel quand un fichier est supprimé du disque
### Organisation
- Import automatique des téléchargements terminés dans la médiathèque (vérification toutes les 5 minutes)
- Trois modes d'import : Hardlink (recommandé), Copier ou Déplacer. Avec un hardlink, le client torrent continue de partager le fichier.
- Analyse de la médiathèque : les épisodes déjà présents sur le disque sont reconnus sans être déplacés
- En option, les épisodes dont le fichier a disparu sont retirés de la médiathèque
- Quand vous retirez une série de la médiathèque en supprimant ses fichiers, son dossier est effacé et ses torrents sont retirés du client
### 🏷️ Métadonnées & Affichage
- **Badges de langue** VOSTFR et MULTI avec drapeaux sur chaque épisode (détectés automatiquement)
- **Support NFO** — nommage compatible avec les plugins Jellyfin/Plex utilisant les fichiers `.nfo`
- **Intégration Plex** — connexion à votre serveur Plex pour déclencher le scan médiathèque après import
### Demandes
- Les invités demandent une série, une saison ou des épisodes, depuis FanKarr ou depuis le plugin Jellyfin
- Les administrateurs approuvent, refusent (avec un message) ou marquent une demande comme disponible
- Approbation automatique, pour tous les invités ou certains seulement : la demande est approuvée et le torrent part aussitôt au client
- Bouton « Tout supprimer » pour vider la liste
- Une demande passe à « disponible » quand des épisodes de la série sont importés, et les demandes disponibles sont supprimées quand la série est retirée de la médiathèque
### ⚙️ Système
- **Logs centralisés** — filtre par niveau et source, rotation automatique, clear depuis l'UI
- **Authentification** par mot de passe avec session JWT
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
- **Compatible Docker / Runtipi / Binaire autonome**
### Métadonnées et affichage
- Badges de langue VOSTFR et MULTI sur chaque épisode, détectés automatiquement
- Fichiers NFO et images en option, pour Infuse ou les lecteurs sans agent Fankai
- Plex : connexion au compte, installation de l'agent de métadonnées Fankai et création de la bibliothèque (Plex Media Server 1.43 ou plus récent pour l'agent)
- Jellyfin, Emby et Kodi : métadonnées Fankai avec le [plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) ou l'add-on [Fankai pour Kodi](https://github.com/Nackophilz/fankai_kodi) (Kodi 20 ou plus récent)
### Utilisateurs et Jellyfin
- Plusieurs comptes, avec deux rôles (administrateur et invité) et des liens d'invitation
- Synchronisation Jellyfin : un compte FanKarr est créé pour chaque utilisateur Jellyfin, toutes les heures ou à la demande
- Connexion depuis le plugin Jellyfin avec le compte Jellyfin
- Un jeton d'API personnel par utilisateur pour l'API publique
### Système
- Journaux filtrables par niveau et par source, avec rotation automatique, que vous pouvez vider depuis l'interface
- Authentification par mot de passe, session JWT
- Clients torrent pris en charge : qBittorrent, Transmission, Deluge, rTorrent, uTorrent, Synology Download Station et Real-Debrid. Vous pouvez en configurer plusieurs.
- Installation avec Docker, Runtipi ou un binaire autonome
---
## Données torrent — Scraper
## Données des torrents (scraper)
Les données du catalogue sont récupérées depuis le repo [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
Les données du catalogue viennent du dépôt [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
Ce repo contient un pipeline Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, les croise avec l'API metadata de Fankai pour identifier chaque épisode, et publie le résultat automatiquement **toutes les 6 heures** via GitHub Actions. Les données sont organisées par série dans un dossier `/serie` et des fichiers d'index à la racine du repo.
Ce dépôt récupère les torrents Fankai sur les trackers publics, les associe aux épisodes grâce à l'API Fankai et publie le résultat toutes les 6 heures avec GitHub Actions : un fichier par série dans le dossier `series/`, et des fichiers d'index à la racine (`available.json`, `infohash_map.json`).
FanKarr récupère ces données au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
FanKarr lit ces données au démarrage et les garde une heure en cache. Pour les recharger tout de suite, utilisez **Synchroniser maintenant** dans **Paramètres › Catalogue Fankai**. Les séries qui ne sont pas encore dans le scraper sont complétées par l'API Fankai, sans torrents. La collecte des torrents ne se fait pas sur votre serveur.
---
@@ -84,7 +113,7 @@ FanKarr récupère ces données au démarrage et via le bouton **Mettre à jour*
### Docker Compose (recommandé)
**Prérequis** : Docker + Docker Compose, un client torrent accessible en réseau.
**Prérequis** : Docker et Docker Compose, et un client torrent joignable depuis FanKarr.
```yaml
services:
@@ -94,10 +123,10 @@ services:
environment:
- PUID=1000 # UID de votre utilisateur (id -u)
- PGID=1000 # GID de votre utilisateur (id -g)
- TZ=Europe/Paris # Votre timezone
- TZ=Europe/Paris # Fuseau horaire
volumes:
- ./config:/config # Config, logs, base de données
- /votre/chemin:/media # Racine de votre médiathèque (voir note hardlinks)
- ./config:/config # Configuration, journaux et données
- /votre/chemin:/media # Dossier parent de la médiathèque et des téléchargements (nécessaire aux hardlinks)
ports:
- 9898:9898
restart: unless-stopped
@@ -107,79 +136,96 @@ services:
docker compose up -d
```
> **⚠️ Note sur les hardlinks** : Pour que le mode `hardlink` fonctionne, vos dossiers de téléchargement complets et votre médiathèque doivent être sur le **même filesystem**. La solution recommandée est de monter un seul volume racine (ex: `/votre/chemin:/media`) et de configurer vos chemins à l'intérieur — comme Radarr et Sonarr le font.
> **Hardlinks** : le dossier des téléchargements terminés et la médiathèque doivent être sur le même système de fichiers. Montez un seul volume parent qui contient les deux (ex. `/votre/chemin:/media`), puis choisissez vos dossiers à l'intérieur, comme avec Radarr et Sonarr.
>
> Plusieurs volumes sont possibles mais les hardlinks ne fonctionneront pas entre deux volumes différents — FanKarr basculera automatiquement en copie.
> Vous pouvez monter plusieurs volumes, mais les hardlinks ne fonctionnent pas d'un volume à l'autre ; FanKarr copie alors les fichiers.
>
> Si le client torrent tourne dans un autre conteneur ou sur une autre machine, renseignez dans ses réglages le chemin distant (le dossier tel que le client le voit) et le chemin local (le même dossier tel que FanKarr le voit).
---
### Binaire autonome (Windows / Linux / macOS)
### Binaire autonome (Windows, Linux, macOS)
Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
Téléchargez l'archive de votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
| Système | Archive |
| -------------------------------- | -------------------------------- |
| Windows x64 | `fankarr-windows-x64.zip` |
| Windows Legacy (anciens CPU) | `fankarr-windows-legacy.zip` |
| Linux x64 | `fankarr-linux-x64.zip` |
| macOS (Apple Silicon) | `fankarr-macos.zip` |
| Système | Archive |
| ---------------------------------------- | ---------------------------------- |
| Windows x64 | `fankarr-windows-x64.zip` |
| Windows x64, anciens processeurs | `fankarr-windows-x64-legacy.zip` |
| Linux x64 | `fankarr-linux-x64.zip` |
| Linux ARM64 | `fankarr-linux-arm64.zip` |
| macOS (Apple Silicon) | `fankarr-macos.zip` |
Extrayez l'archive — tous les fichiers doivent rester ensemble dans le même répertoire :
Extrayez l'archive dans un dossier. Tous les fichiers doivent rester ensemble :
```
fankarr-windows-x64/
├── fankarr.exe ← lancer ce fichier
├── organize-worker.js ← worker d'organisation (ne pas déplacer)
├── .env ← configuration optionnelle (voir Variables d'environnement)
└── public/ ← assets frontend (ne pas déplacer)
fankarr/
├── fankarr.exe # à lancer (fankarr sous Linux et macOS)
├── organize-worker.js # tâche d'import, à garder à côté du binaire
├── public/ # fichiers de l'interface, à garder à côté du binaire
├── version.txt
└── .env # facultatif, à créer (voir Variables d'environnement)
```
> Le fichier `.env` permet de configurer le port, la durée des sessions, etc. sans passer par des variables d'environnement système. Il est chargé automatiquement au démarrage si présent à côté du binaire.
> Le fichier `.env` sert à régler le port, la durée des sessions, etc. sans passer par les variables d'environnement du système. S'il est présent à côté du binaire, il est chargé au démarrage. Le fichier [`.env.example`](.env.example) du dépôt sert de modèle.
**Linux / macOS** — rendre le binaire exécutable avant le premier lancement :
**Linux et macOS** : dans l'archive, le binaire est rangé dans un sous-dossier `binaries/`. Placez-le à côté de `public/` et rendez-le exécutable avant le premier lancement :
```bash
wget https://github.com/masutayunikon/fankarr/releases/latest/download/fankarr-linux-x64.zip
unzip fankarr-linux-x64.zip -d fankarr
cd fankarr
# Le binaire est extrait dans binaries/ : le placer à côté de public/ et de organize-worker.js
mv binaries/fankarr .
chmod +x fankarr
./fankarr
```
FanKarr sera accessible sur `http://localhost:9898`. La configuration, les logs et la base de données sont sauvegardés dans un dossier `config/` créé automatiquement à côté du binaire.
Pour Linux ARM64 ou macOS, remplacez le nom de l'archive.
FanKarr est alors accessible sur `http://localhost:9898`. La configuration, les journaux et les données sont enregistrés dans un dossier `config/`, créé automatiquement à côté du binaire.
---
### Variables d'environnement
| Variable | Défaut | Description |
| ------------------ | ------------- | ------------------------------------------------------------------------------- |
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `PORT` | `9898` | Port d'écoute du serveur |
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent |
| `AUTH_TOKEN_EXPIRY`| `30d` | Durée de validité du token de connexion (ex: `7d`, `1y`, `never`) |
| `DATA_DIR` | `./config` | Répertoire de stockage des données (binaire uniquement) |
| `GITHUB_RAW_URL` | repo scraper | URL de base du repo scraper si vous hébergez votre propre instance |
| Variable | Défaut | Description |
| ------------------- | ------------- | ------------------------------------------------------------------------------------ |
| `PUID` | `1000` | UID propriétaire des fichiers de `/config` (Docker) |
| `PGID` | `1000` | GID propriétaire des fichiers de `/config` (Docker) |
| `TZ` | non défini | Fuseau horaire (ex. `Europe/Paris`) |
| `PORT` | `9898` | Port d'écoute du serveur |
| `JWT_SECRET` | généré | Secret des sessions, généré dans `secret.key` (dossier de configuration) si absent |
| `AUTH_TOKEN_EXPIRY` | `30d` | Durée de validité de la session (ex. `7d`, `1y` ; `never` ou `0` : pas d'expiration) |
| `GITHUB_BASE` | dépôt scraper | Adresse de base des données du scraper, si vous hébergez votre propre copie |
Par défaut, `GITHUB_BASE` vaut `https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main`.
---
### Premier démarrage
### Premier lancement
1. Ouvrir `http://localhost:9898`
2. Créer un mot de passe à la première connexion
3. Aller dans **Paramètres** → configurer votre client torrent
4. Renseigner le chemin vers le dossier des **torrents terminés** et celui de votre **médiathèque**
5. Choisir le mode d'organisation : `hardlink` (recommandé), `move` ou `copy`
6. Optionnel : lancer le **scan** pour référencer les fichiers déjà présents sur le disque
7. Retourner au catalogue et télécharger
1. Ouvrez `http://localhost:9898` (ou l'adresse de votre serveur).
2. Créez le compte administrateur.
3. Suivez l'assistant de configuration :
- dossiers : téléchargements terminés, médiathèque et mode d'import (Hardlink, recommandé, Copier ou Déplacer) ;
- client torrent ;
- options d'import : import automatique, nommage des dossiers, fichiers NFO ;
- serveur multimédia, Jellyfin ou Plex (facultatif) ;
- catalogue : synchronisation des séries Fankai, puis analyse de la médiathèque si elle contient déjà des épisodes.
4. Terminez avec la visite guidée, qui présente les écrans principaux, ou passez-la.
<!-- [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)
![Assistant de configuration](.github/images/setup.webp)
Tous ces réglages restent modifiables dans les paramètres, et l'assistant peut être relancé.
![Paramètres](.github/images/settings.webp)
---
## Organisation des fichiers
FanKarr organise les fichiers terminés vers la structure attendue par Jellyfin / Plex :
FanKarr range les fichiers terminés selon la structure attendue par Jellyfin et Plex :
```
Black Lagoon Henshū/
@@ -196,51 +242,173 @@ Black Lagoon Henshū/
└── Black Lagoon Henshū.S03E08.MULTI.1080p.x264-FANKAI.mkv
```
Le mode **hardlink** est recommandé si vos dossiers de téléchargement et de médiathèque sont sur le même filesystem — les fichiers ne sont pas déplacés et le client torrent continue de seeder sans interruption.
Une option permet de nommer les dossiers de saison « Season 01 » plutôt que « Saison 1 ».
L'organisation se déclenche :
- **Automatiquement** dès la fin d'un téléchargement, et toutes les 5 minutes en arrière-plan pour les torrents déjà en seeding
- **Manuellement** depuis le bouton **Importer** dans la vue téléchargements
Le mode Hardlink est recommandé quand le dossier des téléchargements et la médiathèque sont sur le même système de fichiers : le fichier n'est pas copié et le client torrent continue de le partager.
Le **scan médiathèque** permet de référencer des fichiers déjà présents sur le disque sans les déplacer — utile si vous avez déjà une bibliothèque existante.
L'import se lance :
- automatiquement, si l'import automatique est activé : FanKarr cherche les téléchargements terminés toutes les 5 minutes ;
- à la main, avec le bouton **Importer** de la page Activité.
L'analyse de la médiathèque (**Paramètres › Gestion des médias › Analyser la médiathèque**) reconnaît les fichiers déjà présents sur le disque sans les déplacer, utile pour une médiathèque existante.
---
## Logs
## Journaux
<!-- [img] : page logs — liste des événements avec filtres niveau/source, bouton clear, affichage compact -->
![Logs](.github/images/logs.png)
![Journaux](.github/images/logs.webp)
> Tous les événements sont horodatés et filtrables par source (`organize`, `api`, `torrent`, …) et niveau (`info`, `warn`, `error`, `debug`).
> Les journaux sont dans **Paramètres › Journaux**. Chaque événement est horodaté et filtrable par niveau (`info`, `warn`, `error`, `debug`) et par source : `api`, `organize`, `rss-sync`, `requests`, `jellyfin`, `plex`, `auth`, `torrent-clients`, une source par client torrent (`qbittorrent`, `transmission`…), etc.
Le fichier garde environ les 2 000 dernières lignes. Les messages `debug` ne sont pas enregistrés quand `NODE_ENV=production`, ce qui est le cas dans l'image Docker.
---
## Plugin Jellyfin FanKarr Search
Un plugin Jellyfin intègre la recherche FanKarr à l'interface de votre serveur : vos utilisateurs parcourent le catalogue et demandent des séries sans quitter Jellyfin.
**[jellyfin-plugin-fankarr-search](https://github.com/Masutayunikon/jellyfin-plugin-fankarr-search)**
Le plugin nécessite **[Jellyfin JavaScript Injector](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector)**. Les instructions d'installation sont dans le README du plugin.
### Synchronisation des utilisateurs
FanKarr crée un compte invité pour chaque utilisateur Jellyfin actif qui n'en a pas encore (même nom d'utilisateur). La synchronisation se fait :
- automatiquement toutes les heures, si Jellyfin est configuré ;
- à la demande, depuis **Paramètres › Jellyfin et API › Synchroniser les utilisateurs**.
Les comptes ainsi créés reçoivent un mot de passe aléatoire. Ces utilisateurs se connectent par le plugin, qui échange leur session Jellyfin contre leur jeton FanKarr.
---
## API publique
FanKarr expose une API publique, sous `/api/v1`, utilisée par le plugin Jellyfin et ouverte aux intégrations tierces.
**Authentification** : `Authorization: Bearer <jeton>` (jeton visible dans **Paramètres › Mon profil › Jeton d'API**)
| Méthode | Route | Description |
|---------|-------|-------------|
| `POST` | `/api/v1/auth/jellyfin` | Échange une session Jellyfin contre le jeton FanKarr de l'utilisateur |
| `GET` | `/api/v1/auth/me` | Compte associé au jeton |
| `GET` | `/api/v1/series/search?q=` | Recherche par titre ; renvoie note, année, description et demande en cours |
| `GET` | `/api/v1/series/:id` | Détail d'une série : saisons et épisodes |
| `POST` | `/api/v1/requests` | Crée une demande ou complète la demande en cours |
| `GET` | `/api/v1/requests` | Demandes de l'utilisateur du jeton |
### Connexion avec Jellyfin
```http
POST /api/v1/auth/jellyfin
Content-Type: application/json
{ "jellyfinUserId": "...", "jellyfinToken": "..." }
```
Renvoie `{ token, username, role }`. Le `token` s'utilise ensuite en `Bearer` pour tous les autres appels. Si aucun compte FanKarr ne porte le nom de l'utilisateur Jellyfin, la réponse est une erreur 404 : lancez la synchronisation Jellyfin.
### Recherche
```http
GET /api/v1/series/search?q=dragon+ball
Authorization: Bearer <jeton>
```
```json
[
{
"id": 42,
"title": "Dragon Ball Z Kai",
"original_title": "DRAGON BALL Z KAI",
"image": "https://...",
"year": 2009,
"rating": 8.5,
"description": "Suite de Dragon Ball...",
"request": {
"id": "uuid",
"status": "pending",
"seasons": [1, 2],
"episodes": []
}
}
]
```
La recherche porte sur le titre et le titre original, sans tenir compte de la casse, parmi les séries présentes dans les données du scraper. Elle renvoie 50 résultats au maximum ; sans `q`, elle renvoie les 50 premières séries.
Le champ `request` vaut `null` si l'utilisateur n'a pas de demande en cours pour cette série.
### Détail d'une série (saisons et épisodes)
```http
GET /api/v1/series/42
Authorization: Bearer <jeton>
```
```json
{
"id": 42,
"title": "Dragon Ball Z Kai",
"original_title": "DRAGON BALL Z KAI",
"image": "https://...",
"seasons": [
{
"season_number": 1,
"episodes": [
{ "id": 101, "episode_number": 1, "title": "...", "image": "https://..." }
]
}
]
}
```
### Créer une demande
```http
POST /api/v1/requests
Authorization: Bearer <jeton>
Content-Type: application/json
{ "serieId": 42, "serieName": "Dragon Ball Z Kai", "seasons": [1, 2] }
```
- `serieId` et `serieName` sont obligatoires.
- `seasons` : numéros de saison (`season_number`). Sans saison ni épisode, la demande porte sur toute la série.
- `episodes` : identifiants d'épisodes (`episode.id`). Prioritaires sur `seasons` s'ils sont renseignés.
- Si une demande est déjà en cours pour cette série, les saisons et épisodes sont **fusionnés**.
- Si l'approbation automatique est activée pour l'utilisateur, la demande est approuvée et le torrent envoyé au client.
---
## Runtipi
FanKarr est disponible dans l'appstore personnalisé. Pour l'installer :
FanKarr est disponible dans le dépôt d'applications Runtipi de Masutayunikon. Pour l'installer :
1. Dans Runtipi, aller dans **Paramètres → App Stores**
2. Ajouter l'URL : `https://github.com/Masutayunikon/runtipi-appstore`
3. FanKarr apparaît dans le catalogue → **Installer**
1. Dans Runtipi, ouvrez **Paramètres › App Stores**.
2. Ajoutez l'adresse `https://github.com/Masutayunikon/runtipi-appstore`.
3. FanKarr apparaît dans la liste des applications : cliquez sur **Installer**.
---
## Stack technique
## Technologies
| Couche | Technologie |
| ----------------- | ---------------------------------------- |
| Frontend | Vue 3 + Vite + Tailwind CSS v4 |
| Backend | Express 5 + TypeScript |
| Auth | JWT + bcrypt |
| Worker | Node Worker Threads (organisation async) |
| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) |
| Packaging binaire | Bun (self-contained, pas de Node requis) |
| Partie | Technologie |
| ----------------- | ------------------------------------------------- |
| Interface | Vue 3, Vite, Tailwind CSS v4 |
| Serveur | Express 5, TypeScript |
| Authentification | JWT, bcrypt |
| Import | Worker threads Node (import en arrière-plan) |
| Image Docker | `node:22-slim`, pnpm, gosu (PUID/PGID) |
| Binaire | Bun (exécutable autonome, sans Node) |
---
## Liens
- [fankai.fr](https://fankai.fr) — Le projet Fankai
- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) — Reconnaissance des métadonnées dans Jellyfin
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
- [fankai.fr](https://fankai.fr) : le projet Fankai
- [Plugin Jellyfin FanKarr Search](https://github.com/Masutayunikon/jellyfin-plugin-fankarr-search) : recherche et demandes FanKarr dans Jellyfin
- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) : reconnaissance des métadonnées Fankai dans Jellyfin et Emby
- [Fankai pour Kodi](https://github.com/Nackophilz/fankai_kodi) : fournisseur de métadonnées Fankai pour Kodi
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) : collecte des torrents
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) : dépôt d'applications Runtipi
+14 -14
View File
@@ -1,14 +1,14 @@
fankarr:
image: masutayunikon/fankarr:latest
container_name: fankarr
environment:
- PUID=1000 # UID de votre utilisateur (id -u)
- PGID=1000 # GID de votre utilisateur (id -g)
- TZ=Europe/Paris # Votre timezone
volumes:
- ./config:/config # Config, logs, base de données
- /votre/chemin:/media # Racine de votre mediatheque (hardlinks compatibles)
ports:
- 9898:9898
restart: unless-stopped
services:
fankarr:
image: masutayunikon/fankarr:latest
container_name: fankarr
environment:
- PUID=1000 # UID de votre utilisateur (id -u)
- PGID=1000 # GID de votre utilisateur (id -g)
- TZ=Europe/Paris # Fuseau horaire
volumes:
- ./config:/config # Configuration, journaux et données
- /votre/chemin:/media # Dossier parent de la médiathèque et des téléchargements (nécessaire aux hardlinks)
ports:
- 9898:9898
restart: unless-stopped
+2 -2
View File
@@ -4,9 +4,9 @@ set -e
PUID=${PUID:-1000}
PGID=${PGID:-1000}
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
echo "[fankarr] Exécution en tant que UID=${PUID} GID=${PGID}"
# Corriger les permissions du dossier data
# Attribuer /config à PUID:PGID
chown -R "$PUID:$PGID" /config
exec gosu "$PUID:$PGID" node dist/server/index.js
+6 -3
View File
@@ -1,10 +1,13 @@
<!doctype html>
<html lang="en">
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Serif+Display&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;600;700;800&family=Zen+Kaku+Gothic+New:wght@400;500;700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fankarr</title>
<meta name="theme-color" content="#13100c" />
<title>FanKarr</title>
</head>
<body>
<div id="app"></div>
+7 -2
View File
@@ -7,7 +7,9 @@
"dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
"dev:server": "tsx watch server/index.ts",
"dev:client": "vite",
"build": "vite build && tsc -p tsconfig.server.json",
"build": "vue-tsc -p tsconfig.app.json --noEmit && vite build && tsc -p tsconfig.server.json",
"typecheck": "vue-tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.server.json --noEmit",
"test": "vitest run",
"build:bun:linux": "bun build server/index.ts --compile --target bun-linux-x64 --outfile binaries/fankarr-linux-x64 && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
"build:bun:windows": "bun build server/index.ts --compile --target bun-windows-x64 --outfile binaries/fankarr-windows-x64.exe && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
"build:bun:macos": "bun build server/index.ts --compile --target bun-darwin-arm64 --outfile binaries/fankarr-macos && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
@@ -20,6 +22,7 @@
"@vueuse/core": "^14.2.1",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"lucide-vue-next": "^1.0.0",
@@ -33,6 +36,7 @@
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^24.10.1",
@@ -42,6 +46,7 @@
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vitest": "^5.0.0",
"vue-tsc": "^3.1.5"
}
}
}
+256
View File
@@ -20,6 +20,9 @@ importers:
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
cors:
specifier: ^2.8.6
version: 2.8.6
express:
specifier: ^5.2.1
version: 5.2.1
@@ -54,6 +57,9 @@ importers:
'@types/cookie-parser':
specifier: ^1.4.8
version: 1.4.10(@types/express@5.0.6)
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
'@types/express':
specifier: ^5.0.6
version: 5.0.6
@@ -81,6 +87,9 @@ importers:
vite:
specifier: ^7.3.1
version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
vitest:
specifier: ^5.0.0
version: 5.0.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
vue-tsc:
specifier: ^3.1.5
version: 3.2.5(typescript@5.9.3)
@@ -277,6 +286,9 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/sourcemap-codec@1.6.0':
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
@@ -317,66 +329,79 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -446,24 +471,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
@@ -504,6 +533,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@@ -512,6 +544,12 @@ packages:
peerDependencies:
'@types/express': '*'
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -555,6 +593,20 @@ packages:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
vue: ^3.2.25
'@vitest/mocker@5.0.0':
resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/spy@5.0.0':
resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==}
'@volar/language-core@2.4.28':
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
@@ -667,6 +719,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-kit@2.2.0:
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
engines: {node: '>=20.19.0'}
@@ -701,6 +757,10 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -758,6 +818,10 @@ packages:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -815,6 +879,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@2.3.2:
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -834,10 +901,17 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
@@ -1004,24 +1078,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -1076,6 +1154,9 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magic-string@1.3.1:
resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1126,10 +1207,18 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
obug@2.2.1:
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
engines: {node: '>=12.20.0'}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -1163,6 +1252,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
@@ -1272,6 +1365,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1280,10 +1376,16 @@ packages:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -1311,10 +1413,22 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tinybench@6.1.4:
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
engines: {node: '>=20.0.0'}
tinyexec@1.3.0:
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
engines: {node: '>=18'}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@@ -1406,6 +1520,47 @@ packages:
yaml:
optional: true
vitest@5.0.0:
resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.0
'@vitest/browser-preview': 5.0.0
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.0
'@vitest/coverage-v8': 5.0.0
'@vitest/ui': 5.0.0
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
@@ -1445,6 +1600,11 @@ packages:
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -1584,6 +1744,8 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/sourcemap-codec@1.6.0': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
@@ -1743,6 +1905,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 24.12.0
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/connect@3.4.38':
dependencies:
'@types/node': 24.12.0
@@ -1751,6 +1918,12 @@ snapshots:
dependencies:
'@types/express': 5.0.6
'@types/cors@2.8.19':
dependencies:
'@types/node': 24.12.0
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
'@types/express-serve-static-core@5.1.1':
@@ -1800,6 +1973,17 @@ snapshots:
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
vue: 3.5.29(typescript@5.9.3)
'@vitest/mocker@5.0.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.0
estree-walker: 3.0.3
magic-string: 1.3.1
optionalDependencies:
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/spy@5.0.0': {}
'@volar/language-core@2.4.28':
dependencies:
'@volar/source-map': 2.4.28
@@ -1950,6 +2134,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
assertion-error@2.0.1: {}
ast-kit@2.2.0:
dependencies:
'@babel/parser': 7.29.0
@@ -1992,6 +2178,8 @@ snapshots:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -2045,6 +2233,11 @@ snapshots:
dependencies:
is-what: 5.5.0
cors@2.8.6:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
csstype@3.2.3: {}
data-uri-to-buffer@4.0.1: {}
@@ -2084,6 +2277,8 @@ snapshots:
es-errors@1.3.0: {}
es-module-lexer@2.3.2: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -2123,8 +2318,14 @@ snapshots:
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
etag@1.8.1: {}
expect-type@1.4.0: {}
express@5.2.1:
dependencies:
accepts: 2.0.0
@@ -2164,6 +2365,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
fetch-blob@3.2.0:
dependencies:
node-domexception: 1.0.0
@@ -2364,6 +2569,10 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magic-string@1.3.1:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
math-intrinsics@1.1.0: {}
media-typer@1.1.0: {}
@@ -2401,8 +2610,12 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
obug@2.2.1: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -2427,6 +2640,8 @@ snapshots:
picomatch@4.0.3: {}
picomatch@4.0.7: {}
pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.9
@@ -2590,12 +2805,18 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
stackback@0.0.2: {}
statuses@2.0.2: {}
std-env@4.2.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -2622,11 +2843,20 @@ snapshots:
tapable@2.3.0: {}
tinybench@6.1.4: {}
tinyexec@1.3.0: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
toidentifier@1.0.1: {}
tree-kill@1.2.2: {}
@@ -2685,6 +2915,27 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
vitest@5.0.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
magic-string: 1.3.1
obug: 2.2.1
picomatch: 4.0.7
std-env: 4.2.0
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.12.0
transitivePeerDependencies:
- msw
vscode-uri@3.1.0: {}
vue-router@5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)):
@@ -2731,6 +2982,11 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 231 KiB

+145 -80
View File
@@ -1,112 +1,112 @@
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, markTourSeen, markLogin,
type User,
} from './users.js'
import { readSettings } from './settings.js'
import { startOnboarding } from './onboarding.js'
// AUTH_TOKEN_EXPIRY : durée de validité du token JWT
// Exemples : "1d", "7d", "30d", "1y", "never" / "0" = pas d'expiration
const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
// ── Typage Express étendu ─────────────────────────────────────
declare global {
namespace Express {
interface Request { user?: User }
}
}
// ── Durée de session (JWT) ────────────────────────────────────
const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY
function signToken(username: string): string {
function signToken(user: User): string {
const payload = { id: user.id, username: user.username, role: user.role }
return NO_EXPIRY
? jwt.sign({ username }, JWT_SECRET)
: jwt.sign({ username }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
? jwt.sign(payload, JWT_SECRET)
: jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
}
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10
interface AuthData {
username : string
passwordHash: string
}
// ── Helpers fichier ───────────────────────────────────────────
function readAuth(): AuthData | null {
try {
if (!fs.existsSync(DATA_PATH)) return null
return JSON.parse(fs.readFileSync(DATA_PATH, 'utf-8'))
} catch { return null }
}
function writeAuth(data: AuthData): void {
fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true })
fs.writeFileSync(DATA_PATH, JSON.stringify(data, null, 2))
function setCookieAndRespond(res: Response, user: User): void {
user.lastLoginAt = markLogin(user.id) ?? user.lastLoginAt
const token = signToken(user)
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true, user: safeUser(user) })
}
// ── Routes ────────────────────────────────────────────────────
// GET /api/auth/status
export function authStatus(req: Request, res: Response): void {
const existing = readAuth()
const 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,
onboardingDone: !!readSettings().onboardingCompletedAt,
tourSeen : !!user.tourSeenAt,
})
} 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()) {
logger.warn('auth', 'Tentative de setup alors qu\'un compte existe déjà')
res.status(400).json({ error: 'Un compte existe déjà' })
return
if (hasUsers()) {
logger.warn('auth', 'Tentative de création du premier compte alors qu\'un compte existe')
res.status(400).json({ error: 'Un compte existe déjà' }); return
}
const { username, password } = req.body
if (!username || !password) {
res.status(400).json({ error: 'Username et password requis' })
return
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
if (password.length < 6) {
res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères' }); return
}
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
writeAuth({ username, passwordHash })
logger.info('auth', `Compte créé pour "${username}"`)
const token = signToken(username)
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true })
try {
const user = createUser(username, password, 'admin')
startOnboarding()
logger.info('auth', `Premier compte admin créé pour « ${username} »`)
setCookieAndRespond(res, user)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
}
// POST /api/auth/login
export function authLogin(req: Request, res: Response): void {
const auth = readAuth()
if (!auth) {
res.status(400).json({ error: 'Aucun compte configuré' })
return
if (!hasUsers()) {
res.status(400).json({ error: 'Aucun compte configuré' }); return
}
const { username, password } = req.body
if (!username || !password) {
res.status(400).json({ error: 'Username et password requis' })
return
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) {
logger.warn('auth', `Échec de connexion pour "${username}"`)
res.status(401).json({ error: 'Identifiants incorrects' })
return
const user = findByUsername(username)
if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
logger.warn('auth', `Échec de la connexion de « ${username} »`)
res.status(401).json({ error: 'Identifiants incorrects' }); return
}
const token = signToken(username)
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
logger.info('auth', `Connexion réussie pour "${username}" (expiry: ${NO_EXPIRY ? 'aucune' : TOKEN_EXPIRY})`)
res.json({ success: true })
logger.info('auth', `Connexion de « ${username} » (rôle : ${user.role}, session : ${NO_EXPIRY ? 'sans expiration' : TOKEN_EXPIRY})`)
setCookieAndRespond(res, user)
}
// POST /api/auth/logout
@@ -114,27 +114,92 @@ 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 mot de passe requis' }); return
}
if (newPassword.length < 6) {
res.status(400).json({ error: 'Le nouveau mot de passe doit contenir au moins 6 caractères' }); return
}
try {
changePassword(req.user!.id, currentPassword, newPassword)
res.json({ success: true })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
}
// POST /api/auth/regenerate-token
export function authRegenerateToken(req: Request, res: Response): void {
try {
const token = regenerateApiToken(req.user!.id)
res.json({ apiToken: token })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
}
// POST /api/auth/tour-seen
export function authTourSeen(req: Request, res: Response): void {
try {
res.json({ tourSeenAt: markTourSeen(req.user!.id) })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
}
// ── Contrôle d'accès ─────────────────────────────────────────
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const token = req.cookies?.fankarr_token
if (!token) {
logger.debug('auth', `Accès refusé — non authentifié (${req.method} ${req.path})`)
res.status(401).json({ error: 'Non authentifié' })
return
logger.debug('auth', `Accès refusé : non authentifié (${req.method} ${req.path})`)
res.status(401).json({ error: 'Session expirée, reconnectez-vous' }); return
}
try {
jwt.verify(token, JWT_SECRET)
const payload = jwt.verify(token, JWT_SECRET) as any
const user = findById(payload.id)
if (!user) {
res.status(401).json({ error: 'Utilisateur introuvable' }); return
}
req.user = user
next()
} catch {
logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`)
res.status(401).json({ error: 'Token invalide' })
logger.warn('auth', `Jeton de session invalide (${req.method} ${req.path})`)
res.status(401).json({ error: 'Session expirée, reconnectez-vous' })
}
}
}
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
requireAuth(req, res, () => {
if (req.user?.role !== 'admin') {
logger.warn('auth', `Accès admin refusé pour « ${req.user?.username} » (${req.method} ${req.path})`)
res.status(403).json({ error: 'Accès réservé aux administrateurs' }); return
}
next()
})
}
export function requireAuthOrApiToken(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization
if (authHeader?.startsWith('Bearer ')) {
const user = findByApiToken(authHeader.slice(7))
if (user) { req.user = user; next(); return }
}
requireAuth(req, res, next)
}
+3 -10
View File
@@ -4,9 +4,7 @@ import path from 'path'
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
&& path.dirname((process as any).execPath) !== process.cwd()
// ── Chargement anticipé du fichier .env pour les binaires bun ─────────────
// Doit s'exécuter avant tout autre module qui lit process.env
// (secret.ts, auth.ts…) car config.ts est la dépendance la plus profonde.
// Charge le .env du binaire Bun avant tout module qui lit process.env
;(() => {
if (!_isBunBinary) return
const binDir = path.dirname((process as any).execPath)
@@ -21,17 +19,15 @@ export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
if (eq === -1) continue
const key = line.slice(0, eq).trim()
let val = line.slice(eq + 1).trim()
// Supprimer les guillemets optionnels
if ((val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1)
}
// Ne pas écraser les variables déjà définies (CLI/système prioritaires)
if (key && !(key in process.env)) process.env[key] = val
}
console.log(`[config] .env chargé depuis ${envPath}`)
} catch (err) {
console.warn(`[config] Impossible de charger .env : ${err}`)
console.warn(`[config] Impossible de charger le fichier .env : ${err}`)
}
})()
@@ -40,19 +36,16 @@ export const BASE_DIR = _isBunBinary
: process.cwd()
const isInContainer = (): boolean => {
// Docker
if (fs.existsSync('/.dockerenv')) return true;
// Podman (crée un fichier /.containerenv)
// Podman
if (fs.existsSync('/.containerenv')) return true;
// Vérification via cgroup (Docker, Podman, LXC, etc.)
try {
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
if (/docker|podman|containerd|lxc/i.test(cgroup)) return true;
} catch {}
// Vérification via /proc/1/environ (fallback général)
try {
const environ = fs.readFileSync('/proc/1/environ', 'utf8');
if (environ.includes('container=')) return true;
+1 -6
View File
@@ -1,9 +1,4 @@
/**
* gitlab-map.ts
* =============
* Map des titres API → titres GitLab pour le téléchargement des NFO.
* Partagé entre organize-worker.ts et index.ts.
*/
/** Titres de l'API Fankai vers les noms de dossiers du dépôt GitLab des NFO. */
export const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
+155 -43
View File
@@ -3,7 +3,9 @@ import cookieParser from 'cookie-parser'
import http from 'http'
import fs from 'fs'
import path from 'path'
import { authStatus, authSetup, authLogin, authLogout } from './auth.js'
import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, authTourSeen, requireAuth, requireAdmin } from './auth.js'
import { migrateIfNeeded } from './users.js'
import { migrateOnboarding } from './onboarding.js'
import { readSettings } from './settings.js'
import { registerDriver, dispatchList, dispatchRemove } from './torrent-clients/index.js'
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
@@ -12,13 +14,25 @@ import synologyDsDriver from './torrent-clients/synology-ds.js'
import utorrentDriver from './torrent-clients/utorrent.js'
import rtorrentDriver from './torrent-clients/rtorrent.js'
import realDebridDriver from './torrent-clients/real-debrid.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js'
import delugeDriver from './torrent-clients/deluge.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from './organize.js'
import { logger } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js'
import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js'
import { readAvailable, readInfohashMap } from './lib/github-cache.js'
import { loadCatalog } from './lib/serie-helpers.js'
import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js'
import { pushNotif } from './lib/notifs.js'
import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule, planNextAutoImport } from './lib/schedule.js'
import { readRequests, completeRequest } from './requests.js'
import { checkNfoUpdates } from './lib/nfo.js'
import { runJellyfinSync } from './routes/jellyfin.js'
import cors from 'cors';
import usersRouter from './routes/users.js'
import invitesRouter from './routes/invites.js'
import requestsRouter from './routes/requests.js'
import jellyfinRouter from './routes/jellyfin.js'
import publicApiRouter from './routes/public-api.js'
import settingsRouter from './routes/settings.js'
import torrentClientsRouter from './routes/torrent-clients.js'
import seriesRouter from './routes/series.js'
@@ -28,6 +42,8 @@ import importRouter from './routes/import.js'
import systemRouter from './routes/system.js'
import nfoUpdatesRouter from './routes/nfo-updates.js'
import plexRouter from './routes/plex.js'
import rssSyncRouter from './routes/rss-sync.js'
import { runRssSync } from './lib/rss-sync.js'
registerDriver(qbittorrentDriver)
registerDriver(transmissionDriver)
@@ -35,6 +51,7 @@ registerDriver(synologyDsDriver)
registerDriver(utorrentDriver)
registerDriver(rtorrentDriver)
registerDriver(realDebridDriver)
registerDriver(delugeDriver)
const app = express()
const PORT = Number(process.env.PORT) || 9898
@@ -42,29 +59,69 @@ const PORT = Number(process.env.PORT) || 9898
app.use(express.json())
app.use(cookieParser())
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
const PUBLIC_PATH = path.join(BASE_DIR, 'public')
if (fs.existsSync(PUBLIC_PATH)) {
app.use(express.static(PUBLIC_PATH))
}
// ── Auth ───────────────────────────────────────────────────────
app.get('/api/auth/status', authStatus)
app.post('/api/auth/setup', authSetup)
app.post('/api/auth/login', authLogin)
app.post('/api/auth/logout', authLogout)
// ── Migration vers le multi-utilisateur ───────────────────────
migrateIfNeeded()
migrateOnboarding()
// ── Routes ─────────────────────────────────────────────────────
// ── Authentification ───────────────────────────────────────────
app.get ('/api/auth/status', authStatus)
app.post('/api/auth/setup', authSetup)
app.post('/api/auth/login', authLogin)
app.post('/api/auth/logout', authLogout)
app.get ('/api/auth/me', requireAuth, authMe)
app.post('/api/auth/change-password', requireAuth, authChangePassword)
app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken)
app.post('/api/auth/tour-seen', requireAuth, authTourSeen)
// ── API publique v1 (authentification par jeton) ──────────────
app.use('/api', publicApiRouter)
// ── Garde admin par préfixe ───────────────────────────────────
// requireAdmin sur app.use('/api') bloquerait tous les utilisateurs
const ADMIN_PREFIXES = [
'/users', '/jellyfin', '/settings', '/torrent-clients',
'/downloads', '/download', '/organize', '/import',
'/system', '/nfo-updates', '/plex', '/rss-sync',
'/torrent', '/manual-import', '/organized', '/organized-summary', '/organized-folders',
'/rename-episode', '/rename-all', '/purge-nfo',
'/logs', '/browse', '/browse-files', '/update', '/scan', '/debug',
]
app.use('/api', (req, res, next) => {
if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) {
return requireAdmin(req, res, next)
}
next()
})
// ── Routes (admins et utilisateurs connectés) ──────────────────
app.use('/api', usersRouter)
app.use('/api', jellyfinRouter)
app.use('/api', settingsRouter)
app.use('/api', torrentClientsRouter)
app.use('/api', seriesRouter)
app.use('/api', downloadsRouter)
app.use('/api', organizeRouter)
app.use('/api', importRouter)
app.use('/api', systemRouter)
app.use('/api', nfoUpdatesRouter)
app.use('/api', plexRouter)
app.use('/api', rssSyncRouter)
app.use('/api', invitesRouter)
app.use('/api', requireAuth, seriesRouter)
app.use('/api', requireAuth, requestsRouter)
// ── Catch-all SPA ──────────────────────────────────────────────
// ── Application web (toutes les autres routes) ─────────────────
if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
}
@@ -73,43 +130,66 @@ if (fs.existsSync(PUBLIC_PATH)) {
const server = http.createServer({ maxHeaderSize: 32768 }, app)
server.listen(PORT, async () => {
logger.info('api', `Serveur démarré sur le port ${PORT}`)
const url = `http://localhost:${PORT}`
console.log(`\n FanKarr \x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\n`)
logger.info('api', `Serveur démarré sur ${url}`)
try {
const available = await readAvailable()
logger.info('api', `Cache GitHub initialisé — ${available.length} séries disponibles`)
logger.info('api', `Catalogue chargé : ${available.length} séries`)
} catch (err) {
logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`)
logger.warn('api', `Impossible de charger le catalogue (available.json) au démarrage : ${err instanceof Error ? err.message : err}`)
}
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
const { mediaPath } = readSettings()
// Migration organized.json : si ancien format (valeurs string) → reset + rescan
// Ancien format d'organized.json (valeurs texte) : réinitialisation, l'analyse le reconstruit
try {
if (fs.existsSync(ORGANIZED_PATH)) {
const raw = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
const isOldFormat = Object.values(raw).some((entries: any) =>
Object.values(entries).some(v => typeof v === 'string')
)
if (isOldFormat) {
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
fs.writeFileSync(ORGANIZED_PATH, '{}', 'utf-8')
const raw = readOrganized()
const isOldFormat = Object.values(raw).some((entries: any) =>
Object.values(entries).some(v => typeof v === 'string')
)
if (isOldFormat) {
logger.info('api', 'Migration organized.json : ancien format détecté, fichier réinitialisé')
writeOrganized({})
}
} catch (err) {
logger.error('api', `Migration organized.json annulée : ${err instanceof Error ? err.message : err}`)
}
try {
const raw = readOrganized()
let patched = 0
for (const episodes of Object.values(raw)) {
for (const entry of Object.values(episodes)) {
if (!entry.dest_dir && entry.dest_path) {
entry.dest_dir = path.dirname(entry.dest_path)
patched++
}
}
}
} catch {}
if (patched > 0) {
writeOrganized(raw)
logger.info('api', `Migration organized.json : ${patched} entrée(s) complétée(s) (dest_dir)`)
}
} catch (err) {
logger.warn('api', `Échec de la migration dest_dir : ${err instanceof Error ? err.message : err}`)
}
loadEnrichedSeriesData()
loadCatalog()
.then(async seriesData => {
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
dedupeOrganizedEpisodes(ORGANIZED_PATH)
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
})
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
.catch(err => logger.error('api', `Échec de l'analyse initiale de la médiathèque : ${err instanceof Error ? err.message : err}`))
const autoOrganize = async () => {
autoImportSchedule.lastRunAt = new Date().toISOString()
planNextAutoImport()
try {
const { category } = readSettings()
const infohashMap = await readInfohashMap()
const seriesData = await loadEnrichedSeriesData()
const seriesData = await loadCatalog()
await autoOrganizeAll(
() => dispatchList(category ?? 'fankai', infohashMap),
seriesData,
@@ -117,48 +197,80 @@ server.listen(PORT, async () => {
if (result.done > 0 || result.errors > 0) {
pushNotif({ ...result, at: new Date().toISOString() })
}
// Demandes actives passées en « disponible » dès qu'un fichier est importé
if (result.done > 0 && result.serieId != null) {
try {
const toComplete = readRequests().filter(r =>
r.serieId === result.serieId &&
(r.status === 'pending' || r.status === 'approved')
)
for (const r of toComplete) {
completeRequest(r.id)
logger.info('requests', `Demande « ${r.serieName} » passée en disponible (import automatique)`)
}
} catch (err) {
logger.warn('api', `Impossible de passer en « disponible » les demandes de la série ${result.serieId} : ${err instanceof Error ? err.message : err}`)
}
}
const { organizeMode, deleteTorrentOnMove } = readSettings()
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
try {
await dispatchRemove(result.hash, false)
logger.info('api', `Torrent "${result.name}" supprimé après move`)
} catch (err) {
logger.warn('api', `Impossible de supprimer le torrent "${result.name}" : ${err instanceof Error ? err.message : err}`)
const removeResult = await dispatchRemove(result.hash, false)
if (removeResult.ok) {
logger.info('api', `Torrent « ${result.name} » retiré du client après déplacement`)
} else {
logger.warn('api', `Échec du retrait de « ${result.name} » après déplacement : ${removeResult.error ?? 'aucun client n\'a pu le supprimer'}`)
}
}
}
)
} catch (err) {
logger.error('api', `Auto-organise échoué : ${err instanceof Error ? err.message : err}`)
logger.error('api', `Échec de l'import automatique : ${err instanceof Error ? err.message : err}`)
}
}
planNextAutoImport(10_000)
setTimeout(() => {
autoOrganize()
setInterval(autoOrganize, 5 * 60_000)
setInterval(autoOrganize, AUTO_IMPORT_INTERVAL_MS)
}, 10_000)
setTimeout(() => {
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif initiale échouée : ${err instanceof Error ? err.message : err}`))
checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
setInterval(() => {
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif horaire échouée : ${err instanceof Error ? err.message : err}`))
checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
}, 60 * 60_000)
}, 30_000)
// Sync automatique des noms de fichiers toutes les heures.
// Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches.
setInterval(async () => {
try {
const seriesData = await loadEnrichedSeriesData()
const seriesData = await loadCatalog()
const organizedPath = path.join(DATA_DIR, 'organized.json')
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
if (updated > 0)
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`)
logger.info('api', `Migration automatique des IDs : ${updated} ID(s) mis à jour`)
const { removed } = dedupeOrganizedEpisodes(organizedPath)
if (removed > 0)
logger.info('api', `Dédoublonnage automatique : ${removed} entrée(s) redondante(s) supprimée(s)`)
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0)
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
logger.info('api', `Renommage automatique : ${renamed} fichier(s) renommé(s)`)
} catch (err) {
logger.error('api', `Sync noms auto échoué : ${err instanceof Error ? err.message : err}`)
logger.error('api', `Échec de la mise à jour horaire des imports : ${err instanceof Error ? err.message : err}`)
}
}, 60 * 60_000)
setTimeout(() => {
runRssSync().catch(err => logger.error('rss-sync', `Échec de la surveillance des nouveaux épisodes : ${err instanceof Error ? err.message : err}`))
setInterval(() => {
runRssSync().catch(err => logger.error('rss-sync', `Échec de la surveillance des nouveaux épisodes : ${err instanceof Error ? err.message : err}`))
}, 6 * 60 * 60_000)
}, 60_000)
// Sans effet si Jellyfin n'est pas configuré
setTimeout(() => {
runJellyfinSync().catch(err => logger.error('jellyfin', `Échec de la synchronisation Jellyfin : ${err instanceof Error ? err.message : err}`))
setInterval(() => {
runJellyfinSync().catch(err => logger.error('jellyfin', `Échec de la synchronisation Jellyfin : ${err instanceof Error ? err.message : err}`))
}, 60 * 60_000)
}, 2 * 60_000)
})
+86
View File
@@ -0,0 +1,86 @@
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import { DATA_DIR } from './config.js'
import { logger } from './logger.js'
export interface Invite {
code : string
createdBy: string // userId de l'admin
createdAt: string
expiresAt: string | null // null = pas d'expiration
maxUses : number | null // null = utilisations illimitées
uses : number
note? : string
}
const INVITES_PATH = path.join(DATA_DIR, 'invites.json')
// ── Fichier des invitations ───────────────────────────────────
export function readInvites(): Invite[] {
try {
if (!fs.existsSync(INVITES_PATH)) return []
return JSON.parse(fs.readFileSync(INVITES_PATH, 'utf-8')) as Invite[]
} catch { return [] }
}
function writeInvites(invites: Invite[]): void {
fs.mkdirSync(path.dirname(INVITES_PATH), { recursive: true })
fs.writeFileSync(INVITES_PATH, JSON.stringify(invites, null, 2))
}
// ── Gestion des invitations ───────────────────────────────────
export function createInvite(opts: {
createdBy: string
expiresInHours?: number | null
maxUses?: number | null
note?: string
}): Invite {
const invite: Invite = {
code : crypto.randomBytes(16).toString('hex'),
createdBy: opts.createdBy,
createdAt: new Date().toISOString(),
expiresAt: opts.expiresInHours
? new Date(Date.now() + opts.expiresInHours * 3_600_000).toISOString()
: null,
maxUses : opts.maxUses === undefined ? 1 : opts.maxUses,
uses : 0,
note : opts.note,
}
writeInvites([...readInvites(), invite])
logger.info('invites', `Invitation créée : ${invite.code.slice(0, 8)}… (utilisations max : ${invite.maxUses ?? '∞'}, expiration : ${invite.expiresAt ?? 'jamais'})`)
return invite
}
export function deleteInvite(code: string): void {
const invites = readInvites()
if (!invites.find(i => i.code === code)) throw new Error('Invitation introuvable')
writeInvites(invites.filter(i => i.code !== code))
}
export function findInvite(code: string): Invite | undefined {
return readInvites().find(i => i.code === code)
}
export function validateInvite(code: string): { valid: true; invite: Invite } | { valid: false; reason: string } {
const invite = findInvite(code)
if (!invite) return { valid: false, reason: 'Ce lien d\'invitation n\'est pas valide' }
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
return { valid: false, reason: 'Ce lien d\'invitation a expiré' }
}
if (invite.maxUses !== null && invite.uses >= invite.maxUses) {
return { valid: false, reason: 'Ce lien d\'invitation a atteint son nombre maximum d\'utilisations' }
}
return { valid: true, invite }
}
export function consumeInvite(code: string): void {
const invites = readInvites()
const invite = invites.find(i => i.code === code)
if (!invite) return
invite.uses++
writeInvites(invites)
}
+29 -18
View File
@@ -2,12 +2,13 @@ import { DATA_DIR } from '../config.js'
import path from 'path'
import fs from 'fs'
const GITHUB_BASE = process.env.GITHUB_BASE
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
const GITHUB_BASE =
process.env.GITHUB_BASE?.trim() ||
'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
interface CacheEntry<T> { data: T; expiresAt: number }
interface CacheEntry<T> { data: T; expiresAt: number; fetchedAt: number }
const _cache = new Map<string, CacheEntry<any>>()
export function cacheGet<T>(key: string): T | null {
@@ -18,41 +19,51 @@ export function cacheGet<T>(key: string): T | null {
}
export function cacheSet<T>(key: string, data: T): void {
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS, fetchedAt: Date.now() })
}
export function cacheFetchedAt(key: string): string | null {
const entry = _cache.get(key)
return entry && Date.now() <= entry.expiresAt ? new Date(entry.fetchedAt).toISOString() : null
}
export function cacheClear(): void { _cache.clear() }
export function cacheSize(): number { return _cache.size }
export async function githubGet(urlPath: string): Promise<any> {
const cached = cacheGet(urlPath)
if (cached) return cached
const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
/** force : ignore le cache mémoire et celui du CDN de raw.githubusercontent (environ 5 min). */
export async function githubGet(urlPath: string, force = false): Promise<any> {
if (!force) {
const cached = cacheGet(urlPath)
if (cached) return cached
}
const url = force ? `${GITHUB_BASE}/${urlPath}?_=${Date.now()}` : `${GITHUB_BASE}/${urlPath}`
const init = force ? { cache: 'no-store' as const, headers: { 'Cache-Control': 'no-cache' } } : undefined
const res = await fetch(url, init)
if (!res.ok) throw new Error(`GitHub : HTTP ${res.status} sur ${urlPath}`)
const data = await res.json()
cacheSet(urlPath, data)
return data
}
export async function readAvailable(): Promise<number[]> {
try { return await githubGet('available.json') as number[] }
export async function readAvailable(force = false): Promise<number[]> {
try { return await githubGet('available.json', force) as number[] }
catch { return [] }
}
export async function readInfohashMap(): Promise<Record<string, string>> {
try { return await githubGet('infohash_map.json') as Record<string, string> }
export async function readInfohashMap(force = false): Promise<Record<string, string>> {
try { return await githubGet('infohash_map.json', force) as Record<string, string> }
catch { return {} }
}
export async function readSerieData(serieId: number): Promise<any | null> {
try { return await githubGet(`series/${serieId}.json`) }
export async function readSerieData(serieId: number, force = false): Promise<any | null> {
try { return await githubGet(`series/${serieId}.json`, force) }
catch { return null }
}
export async function loadEnrichedSeriesData(): Promise<any[]> {
const ids = await readAvailable()
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
export async function loadEnrichedSeriesData(force = false): Promise<any[]> {
const ids = await readAvailable(force)
const allSd = await Promise.all(ids.map(id => readSerieData(id, force)))
return allSd.filter(Boolean)
}
+87
View File
@@ -0,0 +1,87 @@
import { readSettings } from '../settings.js'
import { logger } from '../logger.js'
const CLIENT_HEADER = 'MediaBrowser Client="FanKarr", Device="Server", DeviceId="fankarr-server", Version="1.0"'
function adminHeaders(token: string): Record<string, string> {
return {
'X-Emby-Token': token,
'Authorization': `${CLIENT_HEADER}, Token="${token}"`,
'Content-Type': 'application/json',
}
}
function userHeaders(token: string): Record<string, string> {
return {
'Authorization': `${CLIENT_HEADER}, Token="${token}"`,
'Content-Type': 'application/json',
}
}
// ── Types Jellyfin ────────────────────────────────────────────
export interface JellyfinUser {
Id : string
Name : string
HasPassword : boolean
Policy : { IsAdministrator: boolean; IsDisabled: boolean }
}
// ── Test de connexion ─────────────────────────────────────────
export async function testJellyfinConnection(url: string, token: string): Promise<{ ok: boolean; version?: string; error?: string }> {
try {
const res = await fetch(`${url.replace(/\/$/, '')}/System/Info`, {
headers: adminHeaders(token),
})
if (!res.ok) return { ok: false, error: `Jellyfin a répondu HTTP ${res.status} : vérifiez l'URL et la clé API` }
const data = await res.json()
return { ok: true, version: data.Version }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Erreur réseau' }
}
}
// ── Récupération des utilisateurs Jellyfin ────────────────────
export async function fetchJellyfinUsers(): Promise<JellyfinUser[]> {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) throw new Error('Jellyfin non configuré (URL ou clé API manquante)')
const base = jellyfinUrl.replace(/\/$/, '')
const res = await fetch(`${base}/Users`, { headers: adminHeaders(jellyfinAdminToken) })
if (!res.ok) throw new Error(`Impossible de récupérer les utilisateurs Jellyfin (HTTP ${res.status})`)
return res.json()
}
// ── Validation d'un jeton utilisateur Jellyfin ────────────────
export interface JellyfinTokenInfo {
valid : boolean
userId? : string
username?: string
error? : string
}
export async function validateJellyfinToken(jellyfinUserId: string, jellyfinToken: string): Promise<JellyfinTokenInfo> {
const { jellyfinUrl } = readSettings()
if (!jellyfinUrl) return { valid: false, error: 'Jellyfin non configuré sur ce serveur FanKarr' }
try {
const base = jellyfinUrl.replace(/\/$/, '')
const res = await fetch(`${base}/Users/${jellyfinUserId}`, {
headers: userHeaders(jellyfinToken),
})
if (res.status === 401 || res.status === 403) return { valid: false, error: 'Jeton Jellyfin invalide ou expiré' }
if (!res.ok) return { valid: false, error: `Jellyfin a répondu HTTP ${res.status}` }
const user: JellyfinUser = await res.json()
if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' }
logger.debug('jellyfin', `Jeton valide pour « ${user.Name} » (${user.Id.slice(0, 8)}…)`)
return { valid: true, userId: user.Id, username: user.Name }
} catch (err) {
return { valid: false, error: err instanceof Error ? err.message : 'Erreur réseau' }
}
}
+4 -4
View File
@@ -57,7 +57,7 @@ export async function downloadNfoFolder(gitlabTitle: string, destRoot: string):
downloaded++
} catch { skipped++ }
}
logger.info('nfo-update', `"${gitlabTitle}" — ${downloaded} fichiers mis à jour, ${skipped} ignorés`)
logger.info('nfo-update', `NFO de « ${gitlabTitle} » : ${downloaded} fichier(s) mis à jour, ${skipped} échec(s)`)
}
export async function checkNfoUpdates() {
@@ -82,15 +82,15 @@ export async function checkNfoUpdates() {
const gitlabTitle = getGitlabTitle(serieTitle)
const latest = await getLatestGitlabCommit(gitlabTitle)
if (!latest || commits[gitlabTitle] === latest.sha) continue
logger.info('nfo-update', `MAJ NFO détectée pour "${gitlabTitle}" (${latest.sha.slice(0, 8)})`)
logger.info('nfo-update', `Mise à jour des NFO disponible pour « ${gitlabTitle} » (${latest.sha.slice(0, 8)})`)
try {
await downloadNfoFolder(gitlabTitle, path.join(mediaPath, serieTitle))
commits[gitlabTitle] = latest.sha
hasChanges = true
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'updated' })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('nfo-update', `Échec MAJ NFO "${gitlabTitle}" : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('nfo-update', `Échec de la mise à jour des NFO de « ${gitlabTitle} » : ${msg}`)
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'error', error: msg })
}
if (recentNfoUpdates.length > MAX_NFO_NOTIFS) recentNfoUpdates.pop()
+46 -4
View File
@@ -1,12 +1,54 @@
import fs from 'fs'
import path from 'path'
import { DATA_DIR } from '../config.js'
export interface OrganizeNotif {
hash: string; name: string; done: number; skipped: number; errors: number
hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number
errorFiles: { file: string; error: string }[]; at: string
}
export const recentOrganized: OrganizeNotif[] = []
const MAX_NOTIFS = 20
export interface RecentSerieImport { serieId: number; episodes: number; at: string }
const HISTORY_PATH = path.join(DATA_DIR, 'import-history.json')
const MAX_HISTORY = 200
const RECENT_WINDOW_MS = 7 * 24 * 60 * 60_000
function loadHistory(): OrganizeNotif[] {
try {
if (!fs.existsSync(HISTORY_PATH)) return []
const data = JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf-8'))
return Array.isArray(data) ? data.slice(0, MAX_HISTORY) : []
} catch { return [] }
}
function saveHistory(): void {
try {
fs.mkdirSync(path.dirname(HISTORY_PATH), { recursive: true })
fs.writeFileSync(HISTORY_PATH, JSON.stringify(recentOrganized, null, 2))
} catch {}
}
// Du plus récent au plus ancien
export const recentOrganized: OrganizeNotif[] = loadHistory()
export function pushNotif(n: OrganizeNotif) {
recentOrganized.unshift(n)
if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
if (recentOrganized.length > MAX_HISTORY) recentOrganized.length = MAX_HISTORY
saveHistory()
}
export function clearNotifs() {
recentOrganized.length = 0
saveHistory()
}
export function recentImportsBySerie(): RecentSerieImport[] {
const bySerie = new Map<number, RecentSerieImport>()
for (const n of recentOrganized) {
if (n.serieId == null || n.done <= 0) continue
const current = bySerie.get(n.serieId)
if (!current) { bySerie.set(n.serieId, { serieId: n.serieId, episodes: n.done, at: n.at }); continue }
if (Date.parse(current.at) - Date.parse(n.at) <= RECENT_WINDOW_MS) current.episodes += n.done
}
return [...bySerie.values()]
}
+59
View File
@@ -0,0 +1,59 @@
import fs from 'fs'
import path from 'path'
import { threadId } from 'worker_threads'
import { DATA_DIR } from '../config.js'
export type Organized = Record<string, Record<string, any>>
export const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// Un fichier existant mais illisible lève une erreur : ne jamais repartir de {} avant d'écrire
export function readOrganized(file = ORGANIZED_PATH): Organized {
if (!fs.existsSync(file)) return {}
const raw = fs.readFileSync(file, 'utf-8')
let data: unknown
try { data = JSON.parse(raw) }
catch (err) { throw new Error(`Fichier de suivi des imports (organized.json) corrompu : ${err instanceof Error ? err.message : err}`) }
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Fichier de suivi des imports (organized.json) corrompu : contenu inattendu')
return data as Organized
}
export function writeOrganized(data: Organized, file = ORGANIZED_PATH): void {
for (const hash of Object.keys(data))
if (!data[hash] || Object.keys(data[hash]).length === 0) delete data[hash]
const tmp = `${file}.${process.pid}-${threadId}.tmp`
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8')
renameWithRetry(tmp, file)
}
// Répercute des déplacements de fichiers sur toutes les entrées qui les suivent, tous hashes confondus
export function retargetEntries(data: Organized, moves: Map<string, string>): number {
let updated = 0
for (const eps of Object.values(data)) {
for (const entry of Object.values(eps)) {
const to = entry?.dest_path ? moves.get(entry.dest_path) : undefined
if (!to) continue
entry.dest_path = to
entry.dest_filename = path.basename(to)
entry.dest_dir = path.dirname(to)
updated++
}
}
return updated
}
// Lecture-modification-écriture synchrone ; fn renvoie false pour ne rien écrire
export function updateOrganized(fn: (data: Organized) => boolean | void, file = ORGANIZED_PATH): void {
const data = readOrganized(file)
if (fn(data) !== false) writeOrganized(data, file)
}
function renameWithRetry(from: string, to: string, attempts = 5): void {
for (let i = 1; ; i++) {
try { fs.renameSync(from, to); return }
catch (err: any) {
if (i >= attempts || !['EPERM', 'EACCES', 'EBUSY'].includes(err?.code)) throw err
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * i)
}
}
}
+77
View File
@@ -0,0 +1,77 @@
export type RequestTorrent = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
/** Torrents à envoyer : épisodes ciblés, sinon saisons ciblées, sinon toute la série. */
export function resolveRequestTorrents(serieData: any, seasons: number[], episodes: number[]): RequestTorrent[] {
const toDownload: RequestTorrent[] = []
const seenHashes = new Set<string>()
const allPackTorrents: any[] = [
...(serieData.torrents ?? []),
...(serieData.seasons ?? []).flatMap((s: any) => s.torrents ?? []),
]
function addTorrent(t: any, file_index?: number | null, file_path?: string | null) {
const key = `${t.infohash ?? t.magnet ?? t.torrent_url}:${file_index ?? ''}`
if (seenHashes.has(key)) return
seenHashes.add(key)
toDownload.push({ url: t.torrent_url, magnet: t.magnet ?? null, infohash: t.infohash ?? null, file_index: file_index ?? null, file_path: file_path ?? null })
}
function addEpisodesByIndex(epList: any[]) {
for (const ep of epList) {
if (ep.torrents?.length > 0) {
addTorrent(ep.torrents[0])
} else if (ep.paths?.length > 0) {
const p = ep.paths[0]
const pack = allPackTorrents.find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase())
if (pack) addTorrent(pack, p.file_index ?? null, p.path ?? null)
}
}
}
if (episodes.length > 0) {
// ── Épisodes ciblés ───────────────────────────────────────
for (const season of serieData.seasons ?? []) {
const targets = (season.episodes ?? []).filter((ep: any) => episodes.includes(ep.id))
addEpisodesByIndex(targets)
}
} else if (seasons.length > 0) {
// ── Saisons ciblées ───────────────────────────────────────
for (const season of serieData.seasons ?? []) {
if (!seasons.includes(season.season_number)) continue
if ((season.torrents ?? []).length > 0) {
addTorrent(season.torrents[0])
} else {
addEpisodesByIndex(season.episodes ?? [])
}
}
} else {
// ── Toutes les saisons : intégrale en priorité ────────────
if ((serieData.torrents ?? []).length > 0) {
addTorrent(serieData.torrents[0])
} else if ((serieData.seasons ?? []).some((s: any) => s.torrents?.length > 0)) {
for (const season of serieData.seasons ?? []) {
if (season.torrents?.length > 0) addTorrent(season.torrents[0])
}
} else {
for (const season of serieData.seasons ?? []) {
addEpisodesByIndex(season.episodes ?? [])
}
}
}
return toDownload
}
export function countRequestedEpisodes(serieData: any, seasons: number[], episodes: number[]): number {
const keys = new Set<string>()
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
const inScope = episodes.length > 0
? episodes.includes(ep.id)
: seasons.length === 0 || seasons.includes(season.season_number)
if (inScope) keys.add(`${season.season_number}:${ep.episode_number}`)
}
}
return keys.size
}
+270
View File
@@ -0,0 +1,270 @@
/** Surveillance : envoie au client les épisodes ajoutés au catalogue après activation (toutes les 6 h). */
import path from 'path'
import fs from 'fs'
import { DATA_DIR } from '../config.js'
import { logger } from '../logger.js'
import { readInfohashMap } from './github-cache.js'
import { loadCatalogStatus } from './serie-helpers.js'
import { dispatchDownload, dispatchList } from '../torrent-clients/index.js'
import { readSettings } from '../settings.js'
// ── Types ─────────────────────────────────────────────────────
export interface SyncedSerie {
serieId : number
serieName: string
addedAt : string // date ISO d'activation de la surveillance
}
type SyncedMap = Record<number, SyncedSerie>
// ── Persistance ───────────────────────────────────────────────
const SYNC_FILE = path.join(DATA_DIR, 'rss-synced.json')
export function loadSynced(): SyncedMap {
try {
if (fs.existsSync(SYNC_FILE))
return JSON.parse(fs.readFileSync(SYNC_FILE, 'utf-8')) as SyncedMap
} catch {}
return {}
}
function saveSynced(map: SyncedMap): void {
fs.mkdirSync(DATA_DIR, { recursive: true })
fs.writeFileSync(SYNC_FILE, JSON.stringify(map, null, 2), 'utf-8')
}
export function setSync(serieId: number, serieName: string, enabled: boolean): SyncedMap {
const map = loadSynced()
if (enabled) {
map[serieId] = { serieId, serieName, addedAt: new Date().toISOString() }
} else {
delete map[serieId]
}
saveSynced(map)
return map
}
// Une série déjà surveillée garde sa date d'activation
export function setSyncBulk(series: { id: number; name: string }[], enabled: boolean): { changed: number; map: SyncedMap } {
const map = loadSynced()
let changed = 0
for (const { id, name } of series) {
if (enabled && !map[id]) { map[id] = { serieId: id, serieName: name, addedAt: new Date().toISOString() }; changed++ }
if (!enabled && map[id]) { delete map[id]; changed++ }
}
if (changed > 0) saveSynced(map)
return { changed, map }
}
export function isSynced(serieId: number): boolean {
return !!loadSynced()[serieId]
}
const normalizeName = (name: string) =>
name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
// Série recréée sous un nouvel ID et même nom : la surveillance suit ; renvoie celles sans série
export function reconcileSynced(catalog: any[]): SyncedSerie[] {
const map = loadSynced()
const ids = new Set(catalog.map(sd => sd.id))
const orphans: SyncedSerie[] = []
let changed = false
for (const entry of Object.values(map)) {
if (ids.has(entry.serieId)) continue
const matches = catalog.filter(sd => normalizeName(sd.title ?? '') === normalizeName(entry.serieName))
if (matches.length === 1 && !map[matches[0].id]) {
delete map[entry.serieId]
map[matches[0].id] = { ...entry, serieId: matches[0].id, serieName: matches[0].title }
logger.info('rss-sync', `Surveillance de « ${entry.serieName} » déplacée de la série ${entry.serieId} vers ${matches[0].id}`)
changed = true
} else {
orphans.push(entry)
}
}
if (changed) saveSynced(map)
return orphans
}
// ── Épisodes déjà importés ────────────────────────────────────
function readOrganized(): Record<string, Record<string, any>> {
try {
const p = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {}
return {}
}
function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string, any>>): Set<number> {
const ids = new Set<number>()
// Sous n'importe quel hash : évite de retélécharger un épisode importé via un torrent disparu du scraper
const tracked = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
for (const season of sd.seasons ?? [])
for (const ep of season.episodes ?? [])
if (tracked.has(String(ep.id))) ids.add(ep.id)
for (const t of sd.torrents ?? []) {
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const season of sd.seasons ?? [])
for (const ep of season.episodes ?? [])
if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id)
}
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const ep of season.episodes ?? [])
if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id)
}
for (const ep of season.episodes ?? []) {
for (const t of ep.torrents ?? []) {
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id)
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
if (pathEntry) {
const fname = pathEntry.path?.split('/').pop()
if (fname && orgFiles[fname] !== undefined) ids.add(ep.id)
}
}
}
}
return ids
}
// ── Vérification des séries surveillées ───────────────────────
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
if (Object.keys(loadSynced()).length === 0) return { sent: 0, skipped: 0, errors: 0 }
const { series: seriesData, complete } = await loadCatalogStatus()
if (complete) reconcileSynced(seriesData)
const synced = loadSynced()
const ids = Object.keys(synced).map(Number)
logger.info('rss-sync', `Surveillance : vérification de ${ids.length} série(s)`)
const organized = readOrganized()
const activeHashes = new Set<string>()
try {
const { category } = readSettings()
const infohashMap = await readInfohashMap()
const active = await dispatchList(category ?? 'fankai', infohashMap)
for (const t of active) if (t.hash) activeHashes.add(t.hash.toLowerCase())
} catch (err) {
logger.warn('rss-sync', `Impossible de lire les torrents actifs : ${err instanceof Error ? err.message : err}`)
}
let sent = 0
let skipped = 0
let errors = 0
for (const serieId of ids) {
const syncedEntry = synced[serieId]
const sd = seriesData.find((s: any) => s.id === serieId)
if (!sd) {
logger.warn('rss-sync', `Série ${serieId} (« ${syncedEntry.serieName} ») absente du catalogue : retirez la surveillance dans Paramètres › Catalogue`)
continue
}
const title = sd.title ?? sd.show_title ?? String(serieId)
const activatedAt = new Date(syncedEntry.addedAt)
const organizedIds = getOrganizedEpisodeIds(sd, organized)
logger.info('rss-sync', `Vérification de « ${title} » (surveillée depuis le ${activatedAt.toLocaleDateString('fr-FR')})`)
const candidateIds = new Set<number>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (!ep.date_added) continue
const epDate = new Date(ep.date_added)
if (epDate <= activatedAt) continue
if (organizedIds.has(ep.id)) continue
const hasTorrent = (ep.torrents ?? []).some((t: any) => t.fankai && (t.torrent_url || t.magnet))
if (!hasTorrent) continue
candidateIds.add(ep.id)
}
}
if (candidateIds.size === 0) {
logger.info('rss-sync', `« ${title} » : aucun nouvel épisode depuis l'activation`)
continue
}
logger.info('rss-sync', `« ${title} » : ${candidateIds.size} épisode(s) à télécharger`)
const toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = []
const coveredIds = new Set<number>()
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
if (!t.fankai) continue
const url = t.torrent_url ?? t.magnet
if (!url) continue
const hash = t.infohash?.toLowerCase()
if (hash && activeHashes.has(hash)) { skipped++; continue }
const coversSomeCandidate = (season.episodes ?? []).some((ep: any) =>
candidateIds.has(ep.id) && !coveredIds.has(ep.id)
)
if (!coversSomeCandidate) { skipped++; continue }
toDownload.push({ url, infohash: hash ?? null, label: `S${season.season_number} ${title}` })
for (const ep of season.episodes ?? []) coveredIds.add(ep.id)
}
}
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (!candidateIds.has(ep.id) || coveredIds.has(ep.id)) continue
for (const t of ep.torrents ?? []) {
if (!t.fankai) continue
const url = t.torrent_url ?? t.magnet
if (!url) continue
const hash = t.infohash?.toLowerCase()
if (hash && activeHashes.has(hash)) { skipped++; continue }
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === hash)
toDownload.push({
url,
infohash : hash ?? null,
file_index: pathEntry?.file_index ?? null,
file_path : pathEntry?.path ?? null,
label : `S${season.season_number}E${ep.episode_number} ${title}`,
})
coveredIds.add(ep.id)
break
}
}
}
for (const dl of toDownload) {
try {
const results = await dispatchDownload(dl.url, {
file_index: dl.file_index,
file_path : dl.file_path,
infohash : dl.infohash,
})
if (results.some((r: any) => r.ok)) {
logger.info('rss-sync', `Envoyé au client : ${dl.label}`)
sent++
} else {
logger.warn('rss-sync', `Échec de l'envoi : ${dl.label}`)
errors++
}
} catch (err) {
logger.error('rss-sync', `Erreur à l'envoi de ${dl.label} : ${err instanceof Error ? err.message : err}`)
errors++
}
}
}
logger.info('rss-sync', `Surveillance terminée : ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`)
return { sent, skipped, errors }
}
+10
View File
@@ -0,0 +1,10 @@
export const AUTO_IMPORT_INTERVAL_MS = 5 * 60_000
export const autoImportSchedule = {
lastRunAt: null as string | null,
nextRunAt: null as string | null,
}
export function planNextAutoImport(delayMs = AUTO_IMPORT_INTERVAL_MS) {
autoImportSchedule.nextRunAt = new Date(Date.now() + delayMs).toISOString()
}
+137 -33
View File
@@ -1,11 +1,113 @@
import path from 'path'
import { logger } from '../logger.js'
import { githubGet, loadEnrichedSeriesData, readAvailable } from './github-cache.js'
const FANKAI_API = 'https://metadata.fankai.fr'
export async function fankaiGet(endpoint: string): Promise<any> {
const res = await fetch(`${FANKAI_API}${endpoint}`)
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
if (!res.ok) throw new Error(`API Fankai : HTTP ${res.status} sur ${endpoint}`)
return res.json()
}
export async function fetchSerieFromApi(serieId: number): Promise<any> {
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${serieId}`), fankaiGet(`/series/${serieId}/seasons`)])
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
return {
...serie,
seasons: await Promise.all(seasons.map(async (season: any) => {
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
})),
}
}
export function serieHasEpisodes(sd: any): boolean {
return (sd?.seasons ?? []).some((s: any) => (s.episodes ?? []).length > 0)
}
export function serieFolderName(title: string): string {
return title.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
}
// Même règle que seasonFolder() du worker
export function seasonFolderName(seasonNumber: number, englishDirectory: boolean): string {
return seasonNumber === 0 ? 'Specials' : (englishDirectory ? `Season ${String(seasonNumber).padStart(2, '0')}` : `Saison ${seasonNumber}`)
}
const errorMessage = (err: unknown) => err instanceof Error ? err.message : String(err)
// Le scraper écarte les épisodes qu'il n'associe à aucun fichier de torrent : on les reprend de l'API, sans torrent
function addApiEpisodes(sd: any, api: any): number {
const known = new Set<number>()
for (const season of sd.seasons ?? [])
for (const ep of season.episodes ?? []) {
known.add(ep.id)
for (const v of Object.values<any>(ep.codec_variants ?? {})) known.add(v.id)
}
let added = 0
for (const apiSeason of api.seasons ?? []) {
const eps = (apiSeason.episodes ?? []).filter((ep: any) => !known.has(ep.id))
if (eps.length === 0) continue
let season = sd.seasons.find((s: any) => s.id === apiSeason.id)
if (!season) sd.seasons.push(season = { ...apiSeason, torrents: [], episodes: [] })
season.episodes.push(...eps.map((ep: any) => ({ ...ep, torrents: [], paths: [] })))
season.episodes.sort((a: any, b: any) => a.episode_number - b.episode_number)
added += eps.length
}
sd.seasons.sort((a: any, b: any) => a.season_number - b.season_number)
return added
}
// Données scraper complétées par l'API (titre, épisodes manquants), ou repli complet sur l'API quand le scraper n'a pas (encore) la série
export async function resolveSerieData(serieId: number, action?: string): Promise<any | null> {
const [scraper, api] = await Promise.allSettled([githubGet(`series/${serieId}.json`), fetchSerieFromApi(serieId)])
if (scraper.status === 'fulfilled' && serieHasEpisodes(scraper.value)) {
if (api.status !== 'fulfilled') return scraper.value
const sd = { ...structuredClone(scraper.value), title: api.value.title ?? scraper.value.title }
const added = addApiEpisodes(sd, api.value)
if (added > 0 && action) logger.warn('api', `${action} : ${added} épisode(s) de la série ${serieId} absent(s) du scraper, repris de l'API Fankai`)
return sd
}
const scraperError = scraper.status === 'rejected' ? errorMessage(scraper.reason) : 'aucun épisode'
if (api.status === 'rejected') {
logger.error('api', `${action ?? 'Lecture de la série'} : série ${serieId} introuvable (scraper : ${scraperError}, API Fankai : ${errorMessage(api.reason)})`)
return null
}
if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises de l'API Fankai`)
return api.value
}
let lastCatalogWarn = 0
// Scraper complété par l'API Fankai ; complete = false si une source a échoué (ne rien supprimer alors)
export async function loadCatalogStatus(force = false): Promise<{ series: any[]; complete: boolean }> {
const [seriesData, availableIds] = await Promise.all([loadEnrichedSeriesData(force), readAvailable(force)])
const scraperComplete = availableIds.length > 0 && seriesData.length === availableIds.length
let apiSeries: any[]
try {
const data = await fankaiGet('/series')
apiSeries = Array.isArray(data) ? data : (data.series ?? [])
} catch (err) {
if (Date.now() - lastCatalogWarn > 60 * 60_000) {
lastCatalogWarn = Date.now()
logger.warn('api', `Catalogue : API Fankai injoignable, titres du scraper conservés (${errorMessage(err)})`)
}
return { series: seriesData, complete: false }
}
const titles = new Map<number, string>(apiSeries.filter(s => s.title).map(s => [s.id, s.title]))
const catalog = seriesData.map(sd => titles.has(sd.id) ? { ...sd, title: titles.get(sd.id) } : sd)
// Scraper vide = injoignable : ne pas interroger l'API série par série
if (seriesData.length === 0) return { series: catalog, complete: false }
const known = new Set(seriesData.map(sd => sd.id))
const missing = await Promise.all(apiSeries.filter(s => !known.has(s.id)).map(s => fetchSerieFromApi(s.id).catch(() => null)))
return { series: [...catalog, ...missing.filter(Boolean)], complete: scraperComplete && missing.every(Boolean) }
}
export async function loadCatalog(force = false): Promise<any[]> {
return (await loadCatalogStatus(force)).series
}
export function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
if (!url) return null
const stripped = url.replace(/^https?:\/\//, '')
@@ -43,14 +145,6 @@ export function extractTorrentsFromSerieData(sd: any): any[] {
return result
}
/**
* Résout les noms d'un épisode en préférant les valeurs du path entry
* correspondant au hash donné (spécifique au torrent/encoding),
* avec fallback sur les champs de niveau épisode.
*
* Utilisation :
* const { formatted_name, nfo_filename, original_filename } = resolveEpNaming(ep, hash)
*/
export function resolveEpNaming(ep: any, hash?: string | null): {
formatted_name : string | null
nfo_filename : string | null
@@ -67,11 +161,22 @@ export function resolveEpNaming(ep: any, hash?: string | null): {
}
}
/**
* 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 computeExpectedName(
ep : any,
orgEntry: any,
hash : string | null,
nfoSupport: boolean,
): { currentName: string; expectedName: string; needsRename: boolean } {
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, hash)
const expectedName = nfoSupport
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
return { currentName, expectedName, needsRename: expectedName !== currentName }
}
/** Dédoublonne par episode_number : la première variante (x264, x265…) garde les torrents des autres. */
export function deduplicateEpisodes(episodes: any[]): any[] {
const map = new Map<number, any>()
for (const ep of episodes) {
@@ -115,14 +220,21 @@ export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: numb
return resolved
}
export function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, any>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
export function computeSerieDownloadState(
serieData : any | null,
organized : Record<string, Record<string, any>>,
activeTorrents: Set<string>,
counts = serieData ? countOrganizedEpisodes(serieData, organized) : { total: 0, organized: 0 },
): 'none' | 'downloading' | 'partial' | 'complete' {
if (!serieData) return 'none'
const allTorrents = extractTorrentsFromSerieData(serieData)
if (extractTorrentsFromSerieData(serieData).some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
if (counts.total === 0 || counts.organized === 0) return 'none'
if (counts.organized >= counts.total) return 'complete'
return 'partial'
}
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
// Dédoublonnage : épisodes uniques par (season_number, episode_number)
// pour éviter de compter x264 + x265 comme deux épisodes distincts
export function countOrganizedEpisodes(serieData: any, organized: Record<string, Record<string, any>>): { total: number; organized: number } {
// Épisodes uniques par (saison, numéro) : x264 et x265 comptent pour un
const uniqueEpKeys = new Set<string>()
const epIdToKey = new Map<number, string>()
for (const season of serieData.seasons ?? []) {
@@ -135,7 +247,7 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
const organizedEpKeys = new Set<string>()
for (const t of allTorrents) {
for (const t of extractTorrentsFromSerieData(serieData)) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
const orgFiles = organized[hash] ?? {}
@@ -149,17 +261,9 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
}
}
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)
}
}
}
const trackedIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
for (const [id, key] of epIdToKey)
if (trackedIds.has(String(id))) organizedEpKeys.add(key)
if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none'
if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete'
return 'partial'
return { total: uniqueEpKeys.size, organized: organizedEpKeys.size }
}
+20 -25
View File
@@ -1,10 +1,3 @@
/**
* logger.ts
* =========
* Logger centralisé — écrit dans data/logs.jsonl (une ligne JSON par event)
* Niveaux : debug | info | warn | error
* En prod (NODE_ENV=production) les lignes debug ne sont pas écrites sur disque.
*/
import fs from 'fs'
import path from 'path'
@@ -13,27 +6,35 @@ import { DATA_DIR } from './config.js'
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
export interface LogEntry {
at : string // ISO timestamp
at : string // date ISO
level : LogLevel
source : string // 'organize' | 'api' | 'scraper' | ...
source : string // 'api', 'organize', 'torrent-clients', 'rss-sync'…
msg : string
meta? : Record<string, any>
}
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
const MAX_LINES = 2000 // rotation auto au-delà
const MAX_LINES = 2000 // rotation automatique au-delà
const ROTATE_SLACK = 200 // marge avant rotation, pour ne pas réécrire le fichier à chaque ligne
const IS_PROD = process.env.NODE_ENV === 'production'
// ── Écriture ──────────────────────────────────────────────────
let lineCount: number | null = null
function countLines(): number {
if (!fs.existsSync(LOGS_PATH)) return 0
return fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).length
}
function writeLine(entry: LogEntry) {
try {
lineCount ??= countLines()
fs.appendFileSync(LOGS_PATH, JSON.stringify(entry) + '\n', 'utf-8')
// Rotation si trop grand
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
const lines = content.split('\n').filter(Boolean)
if (lines.length > MAX_LINES) {
fs.writeFileSync(LOGS_PATH, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8')
if (++lineCount > MAX_LINES + ROTATE_SLACK) {
const lines = fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).slice(-MAX_LINES)
fs.writeFileSync(LOGS_PATH, lines.join('\n') + '\n', 'utf-8')
lineCount = lines.length
}
} catch {}
}
@@ -43,14 +44,12 @@ function writeLine(entry: LogEntry) {
export function log(level: LogLevel, source: string, msg: string, meta?: Record<string, any>) {
const entry: LogEntry = { at: new Date().toISOString(), level, source, msg, meta }
// Console
const prefix = `[${source}]`
if (level === 'error') console.error(prefix, msg, meta ?? '')
else if (level === 'warn') console.warn(prefix, msg, meta ?? '')
else if (level === 'debug' && !IS_PROD) console.log(prefix, msg, meta ?? '')
else if (level === 'info') console.log(prefix, msg, meta ?? '')
// Disque — pas les debug en prod
if (level === 'debug' && IS_PROD) return
writeLine(entry)
}
@@ -65,9 +64,9 @@ export const logger = {
// ── Lecture pour l'API ────────────────────────────────────────
export interface LogsReadOptions {
limit? : number // nb de lignes (défaut 100)
level? : LogLevel | 'all' // filtre niveau
source? : string // filtre source
limit? : number // nombre de lignes (100 par défaut)
level? : LogLevel | 'all'
source? : string
}
export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
@@ -76,29 +75,25 @@ export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
let lines = content.split('\n').filter(Boolean)
// Parse
let entries: LogEntry[] = []
for (const line of lines) {
try { entries.push(JSON.parse(line)) } catch {}
}
// Filtres
if (opts.level && opts.level !== 'all')
entries = entries.filter(e => e.level === opts.level)
if (opts.source)
entries = entries.filter(e => e.source === opts.source)
// Plus récents en premier
entries.reverse()
// Limite
const limit = opts.limit ?? 100
return entries.slice(0, limit)
} catch { return [] }
}
export function clearLogs(): void {
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8') } catch {}
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8'); lineCount = 0 } catch {}
}
export function logsFileSize(): number {
+55
View File
@@ -0,0 +1,55 @@
import { readSettings, writeSettings } from './settings.js'
import { markAllToursSeen } from './users.js'
import { listClients } from './torrent-clients/index.js'
import { logger } from './logger.js'
export const ONBOARDING_STEPS = ['welcome', 'paths', 'client', 'import', 'media-server', 'catalog', 'recap'] as const
export type OnboardingStep = typeof ONBOARDING_STEPS[number]
export interface OnboardingState {
step : OnboardingStep | null
completedAt: string | null
}
export function isOnboardingStep(value: unknown): value is OnboardingStep {
return typeof value === 'string' && (ONBOARDING_STEPS as readonly string[]).includes(value)
}
export function readOnboarding(): OnboardingState {
const { onboardingStep, onboardingCompletedAt } = readSettings()
return { step: isOnboardingStep(onboardingStep) ? onboardingStep : null, completedAt: onboardingCompletedAt }
}
// Installation configurée avant l'assistant : on ne l'impose pas
export function migrateOnboarding(): boolean {
const settings = readSettings()
if (settings.onboardingCompletedAt || settings.onboardingStep) return false
if (!settings.mediaPath && listClients().length === 0) return false
const now = new Date().toISOString()
writeSettings({ onboardingCompletedAt: now })
markAllToursSeen(now)
logger.info('onboarding', 'Installation existante détectée : assistant marqué comme terminé')
return true
}
export function startOnboarding(): void {
if (readSettings().onboardingCompletedAt) return
writeSettings({ onboardingStep: 'welcome' })
}
export function advanceOnboarding(step?: OnboardingStep, complete = false): OnboardingState {
const current = readOnboarding()
const patch: { onboardingStep?: string; onboardingCompletedAt?: string } = {}
if (step && (!current.step || ONBOARDING_STEPS.indexOf(step) > ONBOARDING_STEPS.indexOf(current.step))) {
patch.onboardingStep = step
}
if (complete) {
patch.onboardingStep = 'recap'
patch.onboardingCompletedAt = new Date().toISOString()
logger.info('onboarding', 'Assistant de configuration terminé')
}
if (Object.keys(patch).length > 0) writeSettings(patch)
return readOnboarding()
}
+124 -86
View File
@@ -1,8 +1,3 @@
/**
* organize-worker.ts
* ==================
* Worker thread dédié à l'organisation des fichiers.
*/
import fs from 'fs'
import fsp from 'fs/promises'
@@ -10,10 +5,9 @@ import path from 'path'
import { parentPort } from 'worker_threads'
import { DATA_DIR } from './config.js'
import { getGitlabTitle } from './gitlab-map.js'
import { readOrganized } from './lib/organized-store.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// ─── Utils log ────────────────────────────────────────────────
// ─── Journal ──────────────────────────────────────────────────
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
@@ -28,40 +22,32 @@ export interface OrgEntry {
src_filename : string
dest_filename: string
dest_path : string
dest_dir : string
}
// organized.json : Record<hash, Record<episode_id_string, OrgEntry>>
type Organized = Record<string, Record<string, OrgEntry>>
// ─── Settings ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
// ─── Réglages ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean, englishDirectory: boolean } {
try {
const p = path.join(DATA_DIR, 'settings.json')
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, englishDirectory: false }
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
}
// ─── Organized log ────────────────────────────────────────────
function loadOrganized(): Organized {
try {
if (!fs.existsSync(ORGANIZED_PATH)) return {}
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch { return {} }
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false , englishDirectory: false } }
}
// ─── Suivi des imports ────────────────────────────────────────
function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
const data = loadOrganized()
if (!data[hash]) data[hash] = {}
data[hash][String(episodeId)] = entry
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
parentPort?.postMessage({ type: 'mark', hash, episodeId, entry })
}
function isOrganized(hash: string, episodeId: number): boolean {
return !!loadOrganized()[hash]?.[String(episodeId)]
return !!readOrganized()[hash]?.[String(episodeId)]
}
// ─── Lookup torrent dans les seriesData ───────────────────────
// ─── Recherche du torrent dans le catalogue ───────────────────
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
const h = hash.toLowerCase()
for (const sd of seriesData) {
@@ -100,6 +86,47 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
return null
}
/** Hash non résolu (ex. dbid_X Synology) : recherche par torrent_name ou title, renvoie le vrai infohash. */
function findTorrentByName(
name : string,
seriesData: any[],
): { torrent: any; serieData: any; infohash: string } | null {
const n = name.toLowerCase().trim()
if (!n) return null
for (const sd of seriesData) {
// Pack intégral (torrents au niveau série)
for (const t of sd.torrents ?? []) {
if (!t.infohash) continue
if (
t.torrent_name?.toLowerCase().trim() === n ||
t.title?.toLowerCase().trim() === n
) return { torrent: t, serieData: sd, infohash: t.infohash.toLowerCase() }
}
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
if (!t.infohash) continue
if (
t.torrent_name?.toLowerCase().trim() === n ||
t.title?.toLowerCase().trim() === n
) return { torrent: { ...t, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
}
for (const ep of season.episodes ?? []) {
for (const t of ep.torrents ?? []) {
if (!t.infohash) continue
if (
t.torrent_name?.toLowerCase().trim() === n ||
t.title?.toLowerCase().trim() === n
) return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
}
}
}
}
return null
}
function buildFileMap(
serieData : any,
hash : string,
@@ -129,7 +156,6 @@ function buildFileMap(
if (!matchedPath) continue
const filename = matchedPath.split('/').pop() ?? matchedPath
// 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
@@ -138,7 +164,7 @@ function buildFileMap(
: null
if (!nfoSupport && !fmtName) {
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`)
warn(`formatted_name absent pour l'épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) : nom NFO ou nom d'origine utilisé`)
}
const srcExt = path.extname(filename)
@@ -177,7 +203,7 @@ function isInExcludedFolder(filePath: string[]): boolean {
return false
}
// ─── GitLab NFO downloader ────────────────────────────────────
// ─── Téléchargement des NFO ───────────────────────────────────
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
@@ -198,7 +224,7 @@ async function fetchBinary(url: string): Promise<Buffer> {
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
const gitlabTitle = getGitlabTitle(serieTitle)
const folderPath = `pack/${gitlabTitle}`
log(`Téléchargement NFO/images depuis GitLab pour "${gitlabTitle}"`)
log(`Téléchargement des NFO et images depuis GitLab pour « ${gitlabTitle} »`)
let files: any[] = []
try {
@@ -213,14 +239,14 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
page++
}
} catch (err) {
warn(`Dossier GitLab introuvable pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
warn(`Dossier GitLab introuvable pour « ${gitlabTitle} » : ${err instanceof Error ? err.message : err}`)
return
}
if (files.length === 0) { warn(`Aucun fichier NFO trouvé pour "${gitlabTitle}"`); return }
if (files.length === 0) { warn(`Aucun fichier NFO trouvé pour « ${gitlabTitle} »`); return }
const fileEntries = files.filter((f: any) => f.type === 'blob')
log(`${fileEntries.length} fichiers NFO/images à télécharger pour "${gitlabTitle}"`)
log(`${fileEntries.length} fichier(s) NFO et images à télécharger pour « ${gitlabTitle} »`)
let downloaded = 0, skipped = 0, failed = 0
for (const entry of fileEntries) {
@@ -234,14 +260,14 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
fs.writeFileSync(destPath, data)
downloaded++
} catch (err) {
warn(`Échec téléchargement NFO "${relativePath}" : ${err instanceof Error ? err.message : err}`)
warn(`Échec du téléchargement de « ${relativePath} » : ${err instanceof Error ? err.message : err}`)
failed++
}
}
log(`NFO "${gitlabTitle}" — ${downloaded} téléchargés, ${skipped} déjà présents${failed > 0 ? `, ${failed} échecs` : ''}`)
log(`NFO de « ${gitlabTitle} » : ${downloaded} téléchargé(s), ${skipped} déjà présent(s)${failed > 0 ? `, ${failed} échec(s)` : ''}`)
}
// ─── Filesystem ops ───────────────────────────────────────────
// ─── Fichiers ─────────────────────────────────────────────────
function swapExtension(filename: string, ext: string): string {
const currentExt = path.extname(filename)
@@ -250,13 +276,11 @@ function swapExtension(filename: string, ext: string): string {
}
function seasonFolder(n: number): string {
return n === 0 ? 'Specials' : `Saison ${n}`
const { englishDirectory } = readSettings()
return n === 0 ? 'Specials' : (englishDirectory ? `Season ${String(n).padStart(2, '0')}` : `Saison ${n}`)
}
/**
* Supprime un fichier 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.
*/
/** Supprime un fichier en réessayant si EBUSY (fichier ouvert sous Windows). */
async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
@@ -264,7 +288,7 @@ async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void>
return
} catch (err: any) {
if (err?.code === 'EBUSY' && attempt < maxAttempts - 1) {
warn(`EBUSY sur "${path.basename(filePath)}" — retry ${attempt + 1}/${maxAttempts - 1}`)
warn(`Fichier « ${path.basename(filePath)} » occupé, nouvelle tentative (${attempt + 1}/${maxAttempts - 1})`)
await new Promise(r => setTimeout(r, 500))
} else {
throw err
@@ -276,7 +300,7 @@ async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void>
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
catch (err) {
debug(`Hardlink échoué (${err instanceof Error ? err.message : err}), fallback copie`)
debug(`Hardlink impossible (${err instanceof Error ? err.message : err}), copie du fichier`)
return false
}
}
@@ -289,7 +313,7 @@ function sanitizeDirName(name: string): string {
.trim()
}
// ─── Organise un torrent ──────────────────────────────────────
// ─── Import d'un torrent ──────────────────────────────────────
async function organizeTorrent(
hash : string,
name : string,
@@ -302,7 +326,7 @@ async function organizeTorrent(
const found = findTorrentByHash(hash, seriesData)
if (!found) {
error(`Hash ${hash} introuvable dans les données série`)
error(`Hash ${hash} introuvable dans le catalogue`)
throw new Error(`Torrent introuvable : ${hash}`)
}
@@ -310,13 +334,13 @@ async function organizeTorrent(
const rawTitle = serieData.title ?? serieData.show_title ?? name
const serieTitle = sanitizeDirName(rawTitle)
debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
debug(`Série identifiée : « ${serieTitle} » (mode : ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
if (nfoSupport) {
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
}
// ── Cas torrent épisode unique ────────────────────────────
// ── Torrent d'un seul épisode ─────────────────────────────
if (torrent._episode) {
const ep = torrent._episode
const season = torrent._season
@@ -332,7 +356,6 @@ async function organizeTorrent(
}
}
const filename = filePath?.split('/').pop() ?? name
// 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
@@ -351,9 +374,8 @@ async function organizeTorrent(
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())) {
debug(`Fichier incomplet (skip) : ${filename}`)
warn(`Ignoré (téléchargement incomplet ou fichier non sélectionné) : ${filename}`)
return { ...result, total: 1, skipped: 1 }
}
@@ -371,8 +393,8 @@ async function organizeTorrent(
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
error(`Fichier source introuvable : ${filename}`)
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] }
error(`Fichier source introuvable : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
return { ...result, total: 1, errors: [{ file: filename, error: `Fichier source introuvable (chemins essayés : ${candidates.join(', ')})` }] }
}
if (!fs.existsSync(dest)) {
@@ -386,10 +408,10 @@ async function organizeTorrent(
markOrganized(hash, ep.id, {
at: new Date().toISOString(), season: season.season_number ?? 1,
episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest,
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
})
await unlinkWithRetry(src)
log(`${destName} → Saison ${season.season_number}`)
log(`${destName} importé dans ${path.basename(destDir)}`)
return { ...result, total: 1, done: 1 }
}
}
@@ -397,18 +419,18 @@ async function organizeTorrent(
markOrganized(hash, ep.id, {
at: new Date().toISOString(), season: season.season_number ?? 1,
episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest,
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
})
log(`${destName} → Saison ${season.season_number}`)
log(`${destName} importé dans ${path.basename(destDir)}`)
return { ...result, total: 1, done: 1 }
}
// ── Pack saison / intégrale ───────────────────────────────
// ── Pack saison ou intégrale ──────────────────────────────
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter)
if (fileMap.size === 0) {
warn(`Aucun fichier mappé pour "${name}"`)
warn(`Aucun fichier de « ${name} » ne correspond au catalogue`)
return result
}
@@ -417,10 +439,8 @@ async function organizeTorrent(
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: 1% téléchargé avant priorité appliquée).
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
debug(`Fichier incomplet (skip) : ${filename}`)
warn(`Ignoré (téléchargement incomplet ou fichier non sélectionné) : ${filename}`)
result.skipped++
continue
}
@@ -435,9 +455,8 @@ async function organizeTorrent(
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
// Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0).
// On skip silencieusement — pas une erreur.
debug(`Fichier non disponible (skip) : ${filename}`)
// Pas encore téléchargé ou hors sélection (priorité 0)
warn(`Ignoré (fichier absent du disque) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
result.skipped++
continue
}
@@ -449,7 +468,7 @@ async function organizeTorrent(
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
})
result.skipped++
continue
@@ -458,28 +477,33 @@ async function organizeTorrent(
try {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'copy') {
await fsp.copyFile(src, dest)
await fsp.copyFile(src, dest)
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
})
} else if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
})
} else {
await fsp.copyFile(src, dest)
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
})
await unlinkWithRetry(src)
}
result.done++
debug(`${nfo_filename} → Saison ${season_number}`)
debug(`${nfo_filename} importé dans ${path.basename(destDir)}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
error(`Échec import "${filename}" : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
error(`Échec de l'import de « ${filename} » : ${msg}`)
result.errors.push({ file: filename, error: msg })
}
}
@@ -493,46 +517,60 @@ parentPort?.on('message', async (msg: any) => {
const torrents: any[] = msg.torrents
const seriesData: any[] = msg.seriesData ?? []
const organized = loadOrganized()
const { nfoSupport } = readSettings()
let organized: Organized
try { organized = readOrganized() as Organized }
catch (err) {
error(`Import annulé : ${err instanceof Error ? err.message : err}`)
parentPort?.postMessage({ type: 'done' })
return
}
for (const t of torrents) {
if (t.state !== 'seeding') continue
const found = findTorrentByHash(t.hash, seriesData)
let found = findTorrentByHash(t.hash, seriesData)
let effectiveHash = t.hash
// Hash non résolu (ex. dbid_X Synology) : recherche par nom de dossier
if (!found && t.name) {
const byName = findTorrentByName(t.name, seriesData)
if (byName) {
found = byName
effectiveHash = byName.infohash
debug(`Hash résolu par nom de torrent : « ${t.name} » (${effectiveHash.slice(0, 8)}…)`)
}
}
if (!found) {
debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
debug(`Torrent « ${t.name} » (${t.hash.slice(0, 8)}…) absent du catalogue (pas encore à jour ?)`)
continue
}
const { torrent, serieData } = found
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
const orgHash = organized[t.hash] ?? {}
const fileMap = buildFileMap(serieData, effectiveHash, nfoSupport, seasonFilter)
const orgHash = organized[effectiveHash] ?? {}
const allDone = fileMap.size > 0
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
: Object.keys(orgHash).length > 0
if (allDone) {
debug(`"${t.name}" déjà entièrement importé — skip`)
debug(`« ${t.name} » déjà entièrement importé`)
continue
}
try {
// 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.
// Fichiers terminés (nom seul) ; null si le client ne donne pas les noms
let completedFileNames: Set<string> | null = null
if (Array.isArray(t.files) && t.files.length > 0) {
// 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()
})
@@ -541,10 +579,10 @@ parentPort?.on('message', async (msg: any) => {
}
}
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
const result = await organizeTorrent(effectiveHash, t.name, t.save_path, seriesData, completedFileNames)
parentPort?.postMessage({ type: 'result', hash: effectiveHash, name: t.name, serieId: serieData.id ?? null, ...result })
} catch (err) {
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
error(`Échec de l'import de « ${t.name} » :${err instanceof Error ? err.message : err}`)
}
}
+234 -157
View File
@@ -1,17 +1,10 @@
/**
* organize.ts
* ===========
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
*/
import fs from 'fs'
import path from 'path'
import { Worker } from 'worker_threads'
import { logger } from './logger.js'
import { DATA_DIR } from './config.js'
import { readSettings } from './settings.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from './lib/organized-store.js'
export let workerRunning = false
const _prevStates = new Map<string, string>()
@@ -24,25 +17,63 @@ function resolveWorkerPath(): string {
}
export interface OrganizeResult {
serieId?: number | null
total : number
skipped : number
done : number
errors : { file: string; error: string }[]
}
interface OrgEntry {
at : string
season : number
episode : number
episode_id : number
src_filename : string
dest_filename: string
dest_path : string
type WorkerResult = { hash: string; name: string; serieId?: number | null; total: number; done: number; skipped: number; errors: { file: string; error: string }[] }
// ── File d'attente des workers ────────────────────────────────
let _queue: Promise<unknown> = Promise.resolve()
let _queued = 0
const _pendingManual = new Map<string, Promise<OrganizeResult>>()
function enqueue<T>(job: () => Promise<T>): Promise<T> {
_queued++
const run = _queue.then(job).finally(() => { _queued-- })
_queue = run.catch(() => {})
return run
}
type Organized = Record<string, Record<string, OrgEntry>>
function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResult) => void): Promise<void> {
return new Promise((resolve, reject) => {
workerRunning = true
const worker = new Worker(resolveWorkerPath())
let settled = false
const finish = (err?: Error) => {
if (settled) return
settled = true
workerRunning = false
worker.terminate()
err ? reject(err) : resolve()
}
// ── Scan initial ──────────────────────────────────────────────
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'mark') {
try {
updateOrganized(data => { (data[msg.hash] ??= {})[String(msg.episodeId)] = msg.entry })
} catch (err) {
logger.error('organize', `Échec de l'enregistrement de l'import (épisode ${msg.episodeId}) :${err instanceof Error ? err.message : err}`)
}
} else if (msg.type === 'result') {
onResult(msg)
} else if (msg.type === 'done') {
finish()
}
})
worker.on('error', err => finish(err))
worker.on('exit', code => finish(code > 1 ? new Error(`Arrêt inattendu du worker (code ${code})`) : undefined))
worker.postMessage({ type: 'run', torrents, seriesData })
})
}
// ── Analyse de la médiathèque ─────────────────────────────────
function swapExt(filename: string, ext: string): string {
const cur = path.extname(filename)
@@ -50,6 +81,8 @@ function swapExt(filename: string, ext: string): string {
return filename.slice(0, -cur.length) + ext
}
export let lastScan: { at: string; found: number; added: number } | null = null
export async function scanMediaPath(
mediaPath : string,
organizedPath: string,
@@ -58,16 +91,14 @@ export async function scanMediaPath(
const result = { found: 0, added: 0 }
if (!fs.existsSync(mediaPath)) {
logger.warn('organize', `Dossier médiathèque introuvable : ${mediaPath}`)
logger.warn('organize', `Dossier de la médiathèque introuvable : ${mediaPath}`)
return result
}
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
logger.info('organize', `Analyse de la médiathèque : ${mediaPath}`)
// Map : nom_sans_extension → { hash, srcFilename, episodeId, season, episode }
// On indexe par nom sans extension pour matcher quelle que soit l'extension sur le disque
const filenameIndex = new Map<string, {
hash : string
hashes : string[]
srcFilename : string
episodeId : number
season : number
@@ -79,12 +110,16 @@ export async function scanMediaPath(
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
// Helper : indexer par nom sans extension
const idx = (filename: string, hash: string, destFilename?: string) => {
const base = filename.replace(/\.[^.]+$/, '')
if (!base) return
const existing = filenameIndex.get(base)
if (existing && existing.episodeId === ep.id) {
if (!existing.hashes.includes(hash)) existing.hashes.push(hash)
return
}
filenameIndex.set(base, {
hash,
hashes : [hash],
srcFilename : filename,
episodeId : ep.id,
season : season.season_number,
@@ -93,14 +128,12 @@ export async function scanMediaPath(
})
}
// 1. Noms depuis les paths (avec vrai infohash)
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash || !p.path) continue
const hash = p.infohash.toLowerCase()
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
if (!srcFilename) continue
idx(srcFilename, hash)
// 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
@@ -110,7 +143,6 @@ export async function scanMediaPath(
}
}
// 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)
@@ -122,16 +154,26 @@ export async function scanMediaPath(
}
}
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch {}
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
logger.error('organize', `Analyse annulée : ${err instanceof Error ? err.message : err}`)
return result
}
// Track les paires hash:episodeId présentes
const presentFiles = new Set<string>()
let noMatch = 0
const trackedByPath = new Map<string, string[]>()
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes)) {
if (!entry?.dest_path) continue
const keys = trackedByPath.get(entry.dest_path) ?? []
keys.push(`${hash}:${episodeId}`)
trackedByPath.set(entry.dest_path, keys)
}
}
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
@@ -142,11 +184,18 @@ export async function scanMediaPath(
walk(full)
} else if (entry.isFile() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) {
result.found++
// Fichier déjà suivi (sous n'importe quel hash) : ne pas créer d'entrée en double
const tracked = trackedByPath.get(full)
if (tracked) {
for (const key of tracked) presentFiles.add(key)
continue
}
const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '')
let match = filenameIndex.get(nameWithoutExt)
// Fallback : chercher si un candidat de l'index est un préfixe du nom du fichier
// ex: index a "Wind Breaker.S01E01.MULTI.1080p" et le fichier est "Wind Breaker.S01E01.MULTI.1080p.x264-FANKAI"
// À défaut, un nom de l'index qui préfixe le fichier (ex. "X.S01E01.MULTI.1080p" pour "X.S01E01.MULTI.1080p.x264-FANKAI")
if (!match) {
const lower = nameWithoutExt.toLowerCase()
for (const [key, val] of filenameIndex.entries()) {
@@ -161,12 +210,13 @@ export async function scanMediaPath(
noMatch++
const prefix = nameWithoutExt.slice(0, 20).toLowerCase()
const close = [...filenameIndex.keys()].filter(k => k.toLowerCase().startsWith(prefix)).slice(0, 3)
const closeStr = close.length > 0 ? ` | candidats : ${close.map(c => `"${c}"`).join(', ')}` : ' | aucun candidat proche'
logger.warn('organize', `Scan no match : "${nameWithoutExt}"${closeStr}`)
const closeStr = close.length > 0 ? ` (candidats : ${close.map(c => `« ${c} »`).join(', ')})` : ' (aucun candidat proche)'
logger.warn('organize', `Analyse : aucune correspondance pour « ${nameWithoutExt} »${closeStr}`)
continue
}
const { hash, srcFilename, episodeId, season, episode } = match
const { hashes, srcFilename, episodeId, season, episode } = match
const hash = hashes.find(h => organized[h]?.[String(episodeId)]) ?? hashes[0]
presentFiles.add(`${hash}:${episodeId}`)
if (organized[hash]?.[String(episodeId)]) continue
@@ -178,22 +228,28 @@ export async function scanMediaPath(
episode,
episode_id : episodeId,
src_filename : srcFilename,
dest_filename: entry.name, // vrai nom sur disque
dest_filename: entry.name,
dest_path : full,
}
result.added++
logger.debug('organize', `Scan match : "${entry.name}" → ep ${episodeId} (S${season}E${episode})`)
logger.debug('organize', `Analyse : « ${entry.name} » associé à l'épisode ${episodeId} (S${season}E${episode})`)
}
}
}
walk(mediaPath)
const mediaEmpty = result.found === 0 && Object.keys(organized).length > 0
if (mediaEmpty) logger.warn('organize', `Analyse : aucun fichier trouvé dans ${mediaPath}, entrées existantes conservées`)
// Supprimer uniquement les entrées connues dans l'index et absentes du disque
const allEpisodeIds = new Set([...filenameIndex.values()].map(v => `${v.hash}:${v.episodeId}`))
const allEpisodeIds = new Set(
[...filenameIndex.values()].flatMap(v => v.hashes.map(h => `${h}:${v.episodeId}`))
)
let removed = 0
for (const [hash, episodes] of Object.entries(organized)) {
if (mediaEmpty) break
for (const episodeId of Object.keys(episodes)) {
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
delete organized[hash][episodeId]
@@ -203,17 +259,16 @@ export async function scanMediaPath(
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
// Désimport automatique des fichiers manquants sur le disque
const { autoUnimportMissing } = readSettings() as any
const { autoUnimportMissing } = readSettings()
let autoRemoved = 0
if (autoUnimportMissing) {
if (autoUnimportMissing && !mediaEmpty) {
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
const destPath = entry?.dest_path
if (destPath && !fs.existsSync(destPath)) {
delete (organized[hash] as any)[episodeId]
autoRemoved++
logger.info('organize', `Désimport auto : fichier introuvable "${destPath}"`)
logger.info('organize', `Retrait automatique : fichier introuvable « ${destPath} »`)
}
}
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
@@ -221,29 +276,34 @@ export async function scanMediaPath(
}
if (result.added > 0 || removed > 0 || autoRemoved > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
writeOrganized(organized, organizedPath)
}
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
lastScan = { at: new Date().toISOString(), ...result }
logger.info('organize', `Analyse terminée : ${result.found} fichier(s), ${result.added} ajouté(s), ${removed} entrée(s) obsolète(s) retirée(s) du suivi${autoRemoved > 0 ? `, ${autoRemoved} retirée(s) (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} sans correspondance` : ''}`)
return result
}
// ── Auto-import ───────────────────────────────────────────────
// ── Import automatique ────────────────────────────────────────
export async function autoOrganizeAll(
listFn : () => Promise<any[]>,
seriesData : any[],
onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
onResult ?: (r: { hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
): Promise<void> {
const { autoImport } = readSettings()
const { autoImport, mediaPath } = readSettings()
if (!autoImport) {
logger.debug('organize', 'Import automatique désactivé — skip')
logger.debug('organize', 'Import automatique désactivé')
return
}
if (!mediaPath?.trim()) {
logger.debug('organize', 'Médiathèque non configurée')
return
}
if (workerRunning) {
logger.debug('organize', 'Worker déjà en cours — skip')
if (_queued > 0) {
logger.debug('organize', 'Import déjà en cours')
return
}
@@ -267,11 +327,12 @@ export async function autoOrganizeAll(
for (const t of torrents) _prevStates.set(t.hash, t.state)
let organized: Organized = {}
try {
if (fs.existsSync(ORGANIZED_PATH))
organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch {}
let organized: Organized
try { organized = readOrganized() }
catch (err) {
logger.error('organize', `Import automatique annulé : ${err instanceof Error ? err.message : err}`)
return
}
const hasUnorganized = torrents.some(t =>
t.state === 'seeding' && !organized[t.hash?.toLowerCase()]
@@ -281,72 +342,51 @@ export async function autoOrganizeAll(
if (seedingCount === 0) return
if (newlySeeding.length > 0 && !isFirstRun) {
logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) → lancement de l'import automatique`)
logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) : lancement de l'import automatique`)
for (const t of newlySeeding) {
logger.debug('organize', `Nouveau seeding : "${t.name}"`)
logger.debug('organize', `Torrent terminé : « ${t.name} »`)
}
} else {
logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
logger.debug('organize', `Import automatique : vérification de ${seedingCount} torrent(s) terminé(s)`)
}
workerRunning = true
const worker = new Worker(resolveWorkerPath())
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'result') {
try {
await enqueue(() => runWorker(torrents, seriesData, msg => {
if (msg.errors.length > 0) {
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
logger.warn('organize', `« ${msg.name} » : ${msg.done} importé(s), ${msg.skipped} ignoré(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) {
logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
logger.error('organize', `Erreur sur « ${e.file} » : ${e.error}`)
}
} else if (msg.done > 0) {
logger.info('organize', `"${msg.name}" — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
logger.info('organize', `« ${msg.name} » : ${msg.done} fichier(s) importé(s), ${msg.skipped} ignoré(s)`)
} else {
logger.debug('organize', `"${msg.name}" — rien à importer (${msg.skipped} skippé(s))`)
logger.debug('organize', `« ${msg.name} » : rien à importer (${msg.skipped} ignoré(s))`)
}
onResult?.({
hash : msg.hash,
name : msg.name,
serieId : msg.serieId ?? null,
done : msg.done,
skipped : msg.skipped,
errors : msg.errors.length,
errorFiles: msg.errors,
})
} else if (msg.type === 'done') {
logger.debug('organize', 'Worker terminé')
workerRunning = false
worker.terminate()
}
})
worker.on('error', (err) => {
logger.error('organize', `Worker erreur : ${err.message}`)
workerRunning = false
})
worker.on('exit', (code) => {
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
else logger.debug('organize', `Worker terminé (code ${code})`)
workerRunning = false
})
worker.postMessage({ type: 'run', torrents, seriesData })
}))
logger.debug('organize', 'Worker d\'import terminé')
} catch (err) {
logger.error('organize', `Erreur du worker : ${err instanceof Error ? err.message : err}`)
}
}
// ── 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.
// ── Migration des IDs d'épisodes ──────────────────────────────
// Les IDs du scraper changent : on les retrouve via (infohash, saison, épisode)
export async function migrateOrganizedEpisodeIds(
organizedPath: string,
seriesData : any[]
): Promise<{ updated: number; orphaned: number }> {
// 1. Construire les tables de lookup depuis le catalogue actuel
// hashEpMap : infohash → Map<"S:E", new_episode_id>
// infohash : Map<"saison:épisode", ID actuel>
const hashEpMap = new Map<string, Map<string, number>>()
const validIds = new Set<number>()
@@ -365,12 +405,12 @@ export async function migrateOrganizedEpisodeIds(
}
}
// 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 organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
logger.error('organize', `Migration des IDs annulée :${err instanceof Error ? err.message : err}`)
return { updated: 0, orphaned: 0 }
}
let updated = 0
let orphaned = 0
@@ -385,62 +425,100 @@ export async function migrateOrganizedEpisodeIds(
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})`)
logger.info('organize', `Migration des IDs : entrée de l'épisode ${oldId} supprimée, ${newId} déjà présent (${hash.slice(0, 8)}…, S${entry.season}E${entry.episode})`)
} else {
newEpisodes[String(newId)] = { ...entry, episode_id: newId }
updated++
changed = true
logger.info('organize', `Migration ID : ep ${oldId} → ${newId} (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
logger.info('organize', `Migration des IDs : épisode ${oldId} remplacé par ${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})`)
logger.warn('organize', `Migration des IDs : épisode ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}…, S${entry.season}E${entry.episode})`)
}
}
organized[hash] = newEpisodes
// 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` : ''}`)
writeOrganized(organized, organizedPath)
logger.info('organize', `Migration des IDs terminée : ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolu(s)` : ''}`)
} else {
logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`)
logger.debug('organize', `Migration des IDs : aucun changement${orphaned > 0 ? ` (${orphaned} ID(s) inconnu(s) conservé(s))` : ''}`)
}
return { updated, orphaned }
}
// ── Sync des noms de fichiers après mise à jour du scraper ────
// ── Dédoublonnage ─────────────────────────────────────────────
export function dedupeOrganizedEpisodes(
organizedPath: string,
): { removed: number } {
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
logger.error('organize', `Dédoublonnage annulé : ${err instanceof Error ? err.message : err}`)
return { removed: 0 }
}
// episode_id : [{ hash, entry }]
const byEpisode = new Map<string, { hash: string; entry: any }[]>()
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
if (!byEpisode.has(episodeId)) byEpisode.set(episodeId, [])
byEpisode.get(episodeId)!.push({ hash, entry })
}
}
let removed = 0
for (const [episodeId, candidates] of byEpisode) {
if (candidates.length < 2) continue
const present = candidates.filter(c => c.entry?.dest_path && fs.existsSync(c.entry.dest_path))
if (present.length === 0) continue
for (const c of candidates) {
if (present.includes(c)) continue
delete (organized[c.hash] as any)[episodeId]
removed++
logger.info('organize', `Dédoublonnage : épisode ${episodeId} retiré de ${c.hash.slice(0, 8)}… (fichier absent, conservé sous ${present[0].hash.slice(0, 8)}…)`)
}
}
if (removed > 0) {
writeOrganized(organized, organizedPath)
logger.info('organize', `Dédoublonnage terminé : ${removed} entrée(s) redondante(s) supprimée(s)`)
}
return { removed }
}
// ── Renommage selon le catalogue ──────────────────────────────
export async function syncFilenameChanges(
seriesData : any[],
organizedPath: string,
): Promise<{ renamed: number; errors: number }> {
const { nfoSupport } = readSettings()
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch { return { renamed: 0, errors: 0 } }
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
logger.error('organize', `Renommage selon le catalogue annulé :${err instanceof Error ? err.message : err}`)
return { renamed: 0, errors: 0 }
}
let renamed = 0
let errors = 0
@@ -464,7 +542,6 @@ export async function syncFilenameChanges(
? path.basename(orgEntry.dest_path)
: orgEntry.dest_filename
const srcExt = path.extname(currentName)
// Priorité aux valeurs du path entry correspondant au hash organisé
const matchedPathEntry = (ep.paths ?? []).find((p: any) =>
typeof p === 'object' && p.infohash?.toLowerCase() === orgHash
)
@@ -482,7 +559,7 @@ export async function syncFilenameChanges(
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (fs.existsSync(newPath)) {
logger.warn('organize', `Sync rename : "${expectedName}" existe déjà — skip`)
logger.warn('organize', `Renommage ignoré : « ${expectedName} » existe déjà`)
continue
}
fs.renameSync(oldPath, newPath)
@@ -492,22 +569,23 @@ export async function syncFilenameChanges(
dest_path : newPath,
at : new Date().toISOString(),
}
retargetEntries(organized, new Map([[oldPath, newPath]]))
renamed++
changed = true
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
logger.info('organize', `Renommage : « ${currentName} » en « ${expectedName} »`)
} catch (err) {
errors++
logger.error('organize', `Sync rename échoué pour ep ${ep.id} : ${err instanceof Error ? err.message : err}`)
logger.error('organize', `Échec du renommage de l'épisode ${ep.id} :${err instanceof Error ? err.message : err}`)
}
}
}
}
if (changed)
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
writeOrganized(organized, organizedPath)
if (renamed > 0 || errors > 0)
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
logger.info('organize', `Noms de fichiers mis à jour : ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
return { renamed, errors }
}
@@ -518,46 +596,45 @@ export async function organizeTorrent(
hash : string,
name : string,
savePath : string,
seriesData: any[]
seriesData: any[],
files? : { name?: string; progress: number; priority?: number }[],
): Promise<OrganizeResult> {
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
const key = hash.toLowerCase()
const pending = _pendingManual.get(key)
if (pending) {
logger.info('organize', `Import manuel de « ${name} » déjà en file d'attente`)
return pending
}
logger.info('organize', `Import manuel ${_queued > 0 ? 'mis en file d\'attente' : 'lancé'} pour « ${name} » (${hash})`)
return new Promise((resolve, reject) => {
const worker = new Worker(resolveWorkerPath())
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
// Progression par fichier : le worker ignore les fichiers incomplets (évite EBUSY sous Windows)
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] }
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'result') {
const job = enqueue(async () => {
_pendingManual.delete(key)
try {
await runWorker([fakeTorrent], seriesData, msg => {
result.serieId = msg.serieId ?? null
result.total = msg.total
result.skipped = msg.skipped
result.done = msg.done
result.errors = msg.errors
if (msg.errors.length > 0) {
logger.warn('organize', `Import "${name}" — ${msg.done} OK, ${msg.errors.length} erreur(s)`)
logger.warn('organize', `Import de « ${name} » : ${msg.done} importé(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) {
logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
logger.error('organize', `Erreur sur « ${e.file} » : ${e.error}`)
}
} else {
logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
logger.info('organize', `Import de « ${name} » terminé : ${msg.done} fichier(s) importé(s), ${msg.skipped} ignoré(s)`)
}
} else if (msg.type === 'done') {
worker.terminate()
resolve(result)
}
})
worker.on('error', (err) => {
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err.message}`)
worker.terminate()
reject(err)
})
worker.on('exit', (code) => {
if (code > 1) reject(new Error(`Worker exit inattendu: code ${code}`))
})
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
})
} catch (err) {
logger.error('organize', `Échec de l'import de « ${name} » :${err instanceof Error ? err.message : err}`)
throw err
}
return result
})
_pendingManual.set(key, job)
return job
}
+186
View File
@@ -0,0 +1,186 @@
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import { DATA_DIR } from './config.js'
import { logger } from './logger.js'
import { findById } from './users.js'
export type RequestStatus = 'pending' | 'approved' | 'rejected' | 'completed'
export interface Requester {
userId : string
username : string
seasons : number[] // numéros de saison ; [] = toutes les saisons disponibles
episodes : number[] // IDs d'épisodes ciblés ; [] = aucun
requestedAt: string
}
export interface SerieRequest {
id : string
serieId : number
serieName : string
requesters : Requester[]
status : RequestStatus
rejectionMessage?: string
createdAt : string
updatedAt : string
}
const REQUESTS_PATH = path.join(DATA_DIR, 'requests.json')
// ── Fichier des demandes ──────────────────────────────────────
export function readRequests(): SerieRequest[] {
try {
if (!fs.existsSync(REQUESTS_PATH)) return []
return JSON.parse(fs.readFileSync(REQUESTS_PATH, 'utf-8')) as SerieRequest[]
} catch { return [] }
}
function writeRequests(requests: SerieRequest[]): void {
fs.mkdirSync(path.dirname(REQUESTS_PATH), { recursive: true })
fs.writeFileSync(REQUESTS_PATH, JSON.stringify(requests, null, 2))
}
/** Saisons demandées par l'ensemble des demandeurs ([] = toutes). */
export function mergedSeasons(req: SerieRequest): number[] {
if (req.requesters.some(r => r.seasons.length === 0 && r.episodes.length === 0)) return []
const all = new Set<number>()
req.requesters.forEach(r => r.seasons.forEach(s => all.add(s)))
return [...all].sort((a, b) => a - b)
}
export function mergedEpisodes(req: SerieRequest): number[] {
const all = new Set<number>()
req.requesters.forEach(r => (r.episodes ?? []).forEach(e => all.add(e)))
return [...all].sort((a, b) => a - b)
}
// ── Création et suivi ─────────────────────────────────────────
export function upsertRequest(
userId : string,
serieId : number,
serieName: string,
seasons : number[], // [] = toutes les saisons
episodes : number[] = [],
): SerieRequest {
const user = findById(userId)
if (!user) throw new Error('Utilisateur introuvable')
const requests = readRequests()
const existing = requests.find(r =>
r.serieId === serieId &&
r.status !== 'rejected' &&
r.status !== 'completed'
)
const now = new Date().toISOString()
if (existing) {
const idx = existing.requesters.findIndex(r => r.userId === userId)
if (idx >= 0) {
const r = existing.requesters[idx]
const newIsAll = seasons.length === 0 && episodes.length === 0
const curIsAll = r.seasons.length === 0 && (r.episodes ?? []).length === 0
if (newIsAll || curIsAll) {
// Si l'une couvre toute la série : plus de filtre saison ni épisode
r.seasons = []
r.episodes = []
} else {
r.seasons = [...new Set([...r.seasons, ...seasons ])].sort((a, b) => a - b)
r.episodes = [...new Set([...(r.episodes ?? []), ...episodes ])].sort((a, b) => a - b)
}
r.requestedAt = now
} else {
existing.requesters.push({ userId, username: user.username, seasons, episodes, requestedAt: now })
}
existing.updatedAt = now
writeRequests(requests)
logger.info('requests', `Demande de « ${serieName} » complétée par « ${user.username} »`)
return existing
}
const request: SerieRequest = {
id : crypto.randomUUID(),
serieId,
serieName,
requesters: [{ userId, username: user.username, seasons, episodes, requestedAt: now }],
status : 'pending',
createdAt : now,
updatedAt : now,
}
writeRequests([...requests, request])
logger.info('requests', `Nouvelle demande de « ${serieName} » par « ${user.username} »`)
return request
}
export function approveRequest(id: string): SerieRequest {
const requests = readRequests()
const req = requests.find(r => r.id === id)
if (!req) throw new Error('Demande introuvable')
req.status = 'approved'
req.rejectionMessage = undefined
req.updatedAt = new Date().toISOString()
writeRequests(requests)
logger.info('requests', `Demande « ${req.serieName} » approuvée`)
return req
}
export function rejectRequest(id: string, message?: string): SerieRequest {
const requests = readRequests()
const req = requests.find(r => r.id === id)
if (!req) throw new Error('Demande introuvable')
req.status = 'rejected'
req.rejectionMessage = message
req.updatedAt = new Date().toISOString()
writeRequests(requests)
logger.info('requests', `Demande « ${req.serieName} » refusée`)
return req
}
export function completeRequest(id: string): SerieRequest {
const requests = readRequests()
const req = requests.find(r => r.id === id)
if (!req) throw new Error('Demande introuvable')
req.status = 'completed'
req.updatedAt = new Date().toISOString()
writeRequests(requests)
return req
}
export function deleteRequest(id: string): void {
const requests = readRequests()
if (!requests.find(r => r.id === id)) throw new Error('Demande introuvable')
writeRequests(requests.filter(r => r.id !== id))
}
/** Retire l'utilisateur d'une demande en attente ; supprime la demande s'il était seul. */
export function withdrawRequest(id: string, userId: string): { deleted: boolean; request: SerieRequest | null } {
const requests = readRequests()
const req = requests.find(r => r.id === id)
if (!req || !req.requesters.some(r => r.userId === userId)) throw new Error('Demande introuvable')
if (req.status !== 'pending') throw new Error('Seule une demande en attente peut être annulée')
const username = req.requesters.find(r => r.userId === userId)!.username
req.requesters = req.requesters.filter(r => r.userId !== userId)
if (req.requesters.length === 0) {
writeRequests(requests.filter(r => r.id !== id))
logger.info('requests', `Demande « ${req.serieName} » annulée par « ${username} »`)
return { deleted: true, request: null }
}
req.updatedAt = new Date().toISOString()
writeRequests(requests)
logger.info('requests', `« ${username} » retiré de la demande « ${req.serieName} »`)
return { deleted: false, request: req }
}
export function getRequestsForUser(userId: string): SerieRequest[] {
return readRequests().filter(r => r.requesters.some(req => req.userId === userId))
}
export function getPendingCount(): number {
return readRequests().filter(r => r.status === 'pending').length
}
+20 -9
View File
@@ -17,7 +17,7 @@ router.post('/download', requireAuth, async (req, res) => {
const torrent_url: string | null = req.body.torrent_url ?? null
const magnet : string | null = req.body.magnet ?? null
const url = torrent_url ?? magnet
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
if (!url) { res.status(400).json({ error: 'Lien du torrent ou magnet requis' }); return }
const file_index: number | null = req.body.file_index != null ? Number(req.body.file_index) : null
const file_path : string | null = req.body.file_path ?? null
const infohash : string | null = req.body.infohash ?? null
@@ -29,13 +29,13 @@ router.post('/download', requireAuth, async (req, res) => {
router.delete('/torrent/:hash', requireAuth, async (req, res) => {
const hash = String(req.params.hash)
const deleteFiles = req.query.deleteFiles === 'true'
if (!hash) { res.status(400).json({ error: 'hash requis' }); return }
if (!hash) { res.status(400).json({ error: 'Hash du torrent requis' }); return }
try {
const result = await dispatchRemove(hash, deleteFiles)
res.json(result)
} catch (err) {
logger.error('api', `Suppression torrent échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec de la suppression du torrent : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
@@ -49,24 +49,35 @@ router.get('/downloads', requireAuth, async (_req, res) => {
const enriched = await Promise.all(torrents.map(async (t: any) => {
const orgFiles = organized[t.hash] ?? {}
let totalFiles = 1
let serieName: string | undefined
let serieId: number | undefined
let episodes: any[] = []
try {
const availableIds = await readAvailable()
for (const id of availableIds) {
const sd = await readSerieData(id)
if (!sd) continue
const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
if (match) { totalFiles = buildResolvedEpisodes(sd, match.infohash, match.season_number).length || 1; break }
if (match) {
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
totalFiles = resolved.length || 1
serieName = sd.title ?? sd.show_title
serieId = sd.id
episodes = resolved
break
}
}
} catch {}
const doneFiles = Object.keys(orgFiles).length
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
const notif = recentOrganized.find(n => n.hash === t.hash)
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [] }
const notif = recentOrganized.find(n => n.hash === t.hash)
const imported = recentOrganized.find(n => n.hash === t.hash && n.done > 0)
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [], importedAt: imported?.at ?? null, serieId, serieName, episodes }
}))
res.json(enriched)
} catch (err) {
logger.error('api', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec du chargement des téléchargements : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
+429 -147
View File
@@ -1,41 +1,120 @@
import { Router } from 'express'
import path from 'path'
import fs from 'fs'
import { requireAuth } from '../auth.js'
import { requireAuth, requireAdmin } from '../auth.js'
import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { getGitlabTitle } from '../gitlab-map.js'
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js'
import { resolveEpNaming } from '../lib/serie-helpers.js'
import {
resolveEpNaming, computeExpectedName, resolveSerieData, loadCatalog, loadCatalogStatus,
serieFolderName, seasonFolderName, fetchSerieFromApi,
} from '../lib/serie-helpers.js'
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
import { dispatchRemove } from '../torrent-clients/index.js'
import { readRequests, deleteRequest } from '../requests.js'
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from '../lib/organized-store.js'
const router = Router()
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
const serieNotFound = (id: unknown) => `Série ${id} introuvable dans le catalogue Fankai. Synchronisez le catalogue ou consultez les journaux.`
const ORGANIZED_UNREADABLE = 'Fichier de suivi des imports (organized.json) illisible'
const UNEXPECTED_ERROR = 'Erreur inattendue, consultez les journaux'
function loadOrganizedJson(): Record<string, Record<string, any>> {
try { if (fs.existsSync(ORGANIZED_PATH)) return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
return {}
// Entrée d'un épisode sous n'importe quel hash (torrent absent des paths du scraper)
function findEntry(organized: Organized, episodeId: number): { hash: string; entry: any } | null {
for (const [hash, eps] of Object.entries(organized))
if (eps[String(episodeId)]) return { hash, entry: eps[String(episodeId)] }
return null
}
function saveOrganizedJson(data: Record<string, Record<string, any>>) {
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
// Entrées dont l'épisode n'existe plus dans le catalogue
function findOrphans(organized: Organized, catalog: any[]) {
const known = new Set<string>()
for (const sd of catalog)
for (const season of sd.seasons ?? [])
for (const ep of season.episodes ?? []) known.add(String(ep.id))
const orphans: { hash: string; episode_id: number; season: number | null; episode: number | null; dest_path: string | null }[] = []
for (const [hash, eps] of Object.entries(organized))
for (const [episodeId, entry] of Object.entries(eps))
if (!known.has(episodeId))
orphans.push({ hash, episode_id: Number(episodeId), season: entry?.season ?? null, episode: entry?.episode ?? null, dest_path: entry?.dest_path ?? null })
return orphans
}
// Épisodes publiés sur l'API mais écartés par le scraper : pas des orphelins. null si l'API ne répond pas
async function apiOnlyEpisodes(orphans: ReturnType<typeof findOrphans>, catalog: any[], mediaPath: string): Promise<Set<string> | null> {
const idByFolder = new Map(catalog.map(sd => [serieFolderName(sd.title ?? sd.show_title ?? ''), sd.id]))
const serieIds = new Set<number>()
for (const o of orphans) {
if (!o.dest_path || !mediaPath) continue
const id = idByFolder.get(path.relative(mediaPath, o.dest_path).split(path.sep)[0])
if (id != null) serieIds.add(id)
}
try {
const series = await Promise.all([...serieIds].map(fetchSerieFromApi))
return new Set(series.flatMap(sd => (sd.seasons ?? []).flatMap((s: any) => (s.episodes ?? []).map((ep: any) => String(ep.id)))))
} catch (err) {
logger.warn('api', `Épisodes disparus du catalogue : vérification sur l'API Fankai impossible (${err instanceof Error ? err.message : err})`)
return null
}
}
// Dossiers série (enfants directs de la médiathèque) contenant les fichiers suivis d'une série
function serieFolders(sd: any, organized: Organized, mediaPath: string, exclude = new Set<string>()): Map<string, number> {
const ids = new Set<string>()
for (const season of sd.seasons ?? [])
for (const ep of season.episodes ?? []) ids.add(String(ep.id))
const folders = new Map<string, number>()
if (!mediaPath) return folders
for (const eps of Object.values(organized)) {
for (const [episodeId, entry] of Object.entries(eps)) {
if (!ids.has(episodeId) || !entry?.dest_path) continue
const parts = path.relative(mediaPath, entry.dest_path).split(path.sep)
if (parts.length < 2 || parts[0] === '..' || path.isAbsolute(parts[0])) continue
const folder = path.join(mediaPath, parts[0])
if (exclude.has(folder)) continue
folders.set(folder, (folders.get(folder) ?? 0) + 1)
}
}
return folders
}
function otherSerieFolders(catalog: any[], serieId: number, mediaPath: string): Set<string> {
return new Set(catalog.filter(sd => sd.id !== serieId).map(sd => path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))))
}
function listFiles(dir: string): string[] {
const files: string[] = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) files.push(...listFiles(full))
else files.push(full)
}
return files
}
function removeEmptyDirs(dir: string): void {
for (const entry of fs.readdirSync(dir, { withFileTypes: true }))
if (entry.isDirectory()) removeEmptyDirs(path.join(dir, entry.name))
if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir)
}
// ── Import manuel ──────────────────────────────────────────────
router.post('/manual-import', requireAuth, async (req, res) => {
const { serie_id, items } = req.body
if (!serie_id || !Array.isArray(items) || items.length === 0) {
res.status(400).json({ error: 'serie_id et items requis' }); return
res.status(400).json({ error: 'Série ou fichiers à importer manquants' }); return
}
const { mediaPath, organizeMode, nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serie_id))
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return }
const rawTitle = sd.title ?? sd.show_title ?? ''
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
const { mediaPath, organizeMode, nfoSupport, englishDirectory } = readSettings()
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
// Copies asynchrones : modifications réappliquées sur le fichier relu à la fin
const ops: ((data: Organized) => void)[] = []
const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) }
const sd = await resolveSerieData(Number(serie_id), 'Import manuel')
if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
const serieTitle = serieFolderName(sd.title ?? sd.show_title ?? '')
const episodeIndex = new Map<number, { ep: any; season: any }>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
@@ -44,11 +123,15 @@ router.post('/manual-import', requireAuth, async (req, res) => {
}
const done: number[] = []
const errors: { file: string; error: string }[] = []
const fail = (file: string, error: string) => {
errors.push({ file, error })
logger.error('api', `Échec de l'import manuel de « ${file} » : ${error}`)
}
for (const item of items) {
const { file_path, episode_id, hash } = item
if (!file_path || !episode_id) { errors.push({ file: file_path ?? '?', error: 'Paramètres manquants' }); continue }
if (!file_path || !episode_id) { fail(file_path ? path.basename(file_path) : '?', 'Paramètres manquants'); continue }
const found = episodeIndex.get(Number(episode_id))
if (!found) { errors.push({ file: file_path, error: `Épisode ${episode_id} introuvable` }); continue }
if (!found) { fail(path.basename(file_path), `Épisode ${episode_id} introuvable dans la série ${serie_id}`); continue }
const { ep, season } = found
const srcFilename = path.basename(file_path)
const srcExt = path.extname(srcFilename)
@@ -61,18 +144,47 @@ router.post('/manual-import', requireAuth, async (req, res) => {
const destFilename = resolvedExt && resolvedExt !== srcExt
? resolvedName.slice(0, -resolvedExt.length) + srcExt
: resolvedName
const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}`
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
const destDir = path.join(mediaPath, serieTitle, seasonFolderName(season.season_number, englishDirectory))
const destPath = path.join(destDir, destFilename)
try {
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
// Réassignation : retirer les entrées qui pointent ce fichier sous un autre épisode
for (const [h, eps] of Object.entries(organized)) {
for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id))
logger.info('api', `Import manuel : entrée obsolète de l'épisode ${epId} retirée (fichier réassigné)`)
}
}
apply(data => {
for (const eps of Object.values(data))
for (const [epId, entry] of Object.entries(eps))
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) delete eps[epId]
})
fs.mkdirSync(destDir, { recursive: true })
if (file_path !== destPath) {
const serieRootPath = path.join(mediaPath, serieTitle)
const isInSeriePath = file_path.startsWith(serieRootPath + path.sep) || file_path.startsWith(serieRootPath + '/')
if (isInSeriePath && fs.existsSync(destPath) && destPath !== file_path) {
fs.unlinkSync(destPath)
logger.info('api', `Import manuel (réassignation) : suppression de l'ancien fichier « ${destFilename} »`)
for (const eps of Object.values(organized)) {
for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === destPath)
logger.info('api', `Import manuel : entrée obsolète de l'épisode ${epId} retirée (ancien fichier supprimé)`)
}
}
apply(data => {
for (const eps of Object.values(data))
for (const [epId, entry] of Object.entries(eps))
if (entry?.dest_path === destPath) delete eps[epId]
})
}
if (isInSeriePath && !fs.existsSync(destPath)) {
fs.renameSync(file_path, destPath)
logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
logger.info('api', `Import manuel : « ${srcFilename} » renommé en « ${destFilename} »`)
} else if (!isInSeriePath && !fs.existsSync(destPath)) {
if (organizeMode === 'hardlink') {
try { fs.linkSync(file_path, destPath) }
@@ -80,29 +192,34 @@ router.post('/manual-import', requireAuth, async (req, res) => {
} else if (organizeMode === 'move') {
try { fs.renameSync(file_path, destPath) }
catch { await fs.promises.copyFile(file_path, destPath); await fs.promises.unlink(file_path) }
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
} else {
await fs.promises.copyFile(file_path, destPath)
}
logger.info('api', `Import manuel : "${srcFilename}" → "${destPath}"`)
logger.info('api', `Import manuel : « ${srcFilename} » importé vers « ${destPath} »`)
}
} else {
logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`)
logger.debug('api', `Import manuel : « ${srcFilename} » déjà en place`)
}
const torrentHash = String(hash || '').toLowerCase() || 'manual'
if (!organized[torrentHash]) organized[torrentHash] = {}
organized[torrentHash][String(episode_id)] = {
const entry = {
at: new Date().toISOString(), season: season.season_number,
episode: ep.episode_number, episode_id: ep.id,
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
}
apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry })
done.push(episode_id)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
errors.push({ file: srcFilename, error: msg })
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
fail(srcFilename, err instanceof Error ? err.message : UNEXPECTED_ERROR)
}
}
if (ops.length > 0) {
try { updateOrganized(data => { for (const op of ops) op(data) }) }
catch (err) {
logger.error('api', `Import manuel : échec de l'enregistrement (${err instanceof Error ? err.message : err})`)
res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return
}
}
saveOrganizedJson(organized)
if (nfoSupport && done.length > 0) {
const gitlabTitle = getGitlabTitle(serieTitle)
@@ -123,9 +240,9 @@ router.post('/manual-import', requireAuth, async (req, res) => {
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.writeFileSync(dest, Buffer.from(await raw.arrayBuffer()))
}
logger.info('api', `NFO téléchargés pour "${gitlabTitle}" (import manuel)`)
logger.info('api', `NFO téléchargés pour « ${gitlabTitle} » (import manuel)`)
} catch (err) {
logger.warn('api', `Échec téléchargement NFO pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
logger.warn('api', `Échec du téléchargement des NFO de « ${gitlabTitle} » :${err instanceof Error ? err.message : err}`)
}
})()
}
@@ -133,41 +250,54 @@ router.post('/manual-import', requireAuth, async (req, res) => {
res.json({ ok: true, done: done.length, errors })
})
// ── Récap organisés d'une série ────────────────────────────────
// ── Épisodes importés d'une série ──────────────────────────────
router.get('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId)
try {
const sd = await readSerieData(serieId)
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const organized = loadOrganizedJson()
const sd = await resolveSerieData(serieId)
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
const organized = readOrganized()
const { nfoSupport } = readSettings()
const result: Record<string, any> = {}
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let entry: any = null
let entryHash = 'manual'
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const hash = p.infohash.toLowerCase()
const entry = (organized[hash] ?? {})[String(ep.id)]
if (entry) { result[String(ep.id)] = entry; break }
const h = p.infohash.toLowerCase()
const e = (organized[h] ?? {})[String(ep.id)]
if (e) { entry = e; entryHash = h; break }
}
if (!result[String(ep.id)] && organized['manual']?.[String(ep.id)]) {
result[String(ep.id)] = organized['manual'][String(ep.id)]
if (!entry && organized['manual']?.[String(ep.id)]) {
entry = organized['manual'][String(ep.id)]
}
if (!entry) {
const found = findEntry(organized, ep.id)
if (found) { entry = found.entry; entryHash = found.hash }
}
if (!entry) continue
const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport)
result[String(ep.id)] = { ...entry, hash: entryHash, needs_rename: needsRename }
}
}
res.json(result)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
// ── Rename épisode ─────────────────────────────────────────────
// ── Renommage d'un épisode ─────────────────────────────────────
router.post('/rename-episode', requireAuth, async (req, res) => {
const { serie_id, episode_id, torrent_hash } = req.body
if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return }
if (!serie_id || !episode_id) { res.status(400).json({ error: 'Série ou épisode manquant' }); return }
const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serie_id))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const sd = await resolveSerieData(Number(serie_id), 'Renommage')
if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
let foundEp: any = null
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
@@ -181,45 +311,39 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
if (!orgEntry) {
for (const [h, eps] of Object.entries(organized)) {
if (eps[String(episode_id)]) { orgEntry = eps[String(episode_id)]; entryHash = h; break }
}
const found = findEntry(organized, Number(episode_id))
if (found) { orgEntry = found.entry; entryHash = found.hash }
}
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, entryHash)
let newName: string
if (nfoSupport) {
newName = rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName
} else {
newName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
}
if (newName === currentName) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); return }
const { currentName, expectedName: newName, needsRename } = computeExpectedName(ep, orgEntry, entryHash, nfoSupport)
if (!needsRename) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); return }
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), newName)
try {
if (!fs.existsSync(oldPath)) throw new Error('Fichier source introuvable sur le disque')
if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
if (!fs.existsSync(oldPath)) throw new Error('Fichier introuvable sur le disque')
if (fs.existsSync(newPath)) throw new Error(`Un fichier nommé « ${newName} » existe déjà`)
fs.renameSync(oldPath, newPath)
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
saveOrganizedJson(organized)
logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
retargetEntries(organized, new Map([[oldPath, newPath]]))
writeOrganized(organized)
logger.info('api', `Renommage : « ${orgEntry.dest_filename} » en « ${newName} »`)
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Rename échoué pour ep ${episode_id} : ${msg}`)
const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
logger.error('api', `Échec du renommage de l'épisode ${episode_id} : ${msg}`)
res.status(500).json({ error: msg })
}
})
// ── Désimport série complète ───────────────────────────────────
// ── Retrait d'une série ────────────────────────────────────────
router.delete('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const sd = await resolveSerieData(Number(serieId), 'Retrait de la série')
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const episodeIds = new Set<string>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id))
@@ -227,10 +351,15 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
let removed = 0
const errors: string[] = []
const emptyHashes: string[] = []
let serieFolder: string | null = null
for (const [hash, episodes] of Object.entries(organized)) {
for (const epId of Object.keys(episodes)) {
if (!episodeIds.has(epId)) continue
const entry = episodes[epId]
if (!serieFolder && entry) {
const dir = entry.dest_dir ?? path.dirname(entry.dest_path)
serieFolder = path.dirname(dir)
}
if (deleteFile && entry?.dest_path && fs.existsSync(entry.dest_path)) {
try { fs.unlinkSync(entry.dest_path) } catch { errors.push(entry.dest_path) }
}
@@ -239,25 +368,42 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
}
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
}
saveOrganizedJson(organized)
writeOrganized(organized)
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
if (serieFolder && fs.existsSync(serieFolder)) {
try {
fs.rmSync(serieFolder, { recursive: true, force: true })
logger.info('api', `Dossier de la série supprimé : ${serieFolder}`)
} catch (err) {
logger.warn('api', `Impossible de supprimer le dossier de la série « ${serieFolder} » :${err instanceof Error ? err.message : err}`)
}
}
}
logger.info('api', `Désimport série ${serieId} — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
const completedRequests = readRequests().filter(r => r.serieId === Number(serieId) && r.status === 'completed')
for (const r of completedRequests) {
try { deleteRequest(r.id) } catch {}
}
if (completedRequests.length > 0)
logger.info('api', `Retrait de la série ${serieId} : ${completedRequests.length} demande(s) « disponible » supprimée(s)`)
logger.info('api', `Retrait de la série ${serieId} : ${removed} épisode(s) retiré(s)${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retiré(s) du client` : ''}`)
res.json({ ok: true, removed, errors })
})
// ── Désimport saison ───────────────────────────────────────────
// ── Retrait d'une saison ───────────────────────────────────────
router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const seasonId = Number(req.params.seasonId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const sd = await resolveSerieData(Number(serieId), 'Retrait de la saison')
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const season = sd.seasons?.find((s: any) => s.id === seasonId)
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? [])
@@ -276,80 +422,94 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
}
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
}
saveOrganizedJson(organized)
writeOrganized(organized)
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
}
logger.info('api', `Désimport saison ${seasonId} (série ${serieId}) — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
logger.info('api', `Retrait de la saison ${seasonId} (série ${serieId}) : ${removed} épisode(s) retiré(s)${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retiré(s) du client` : ''}`)
res.json({ ok: true, removed, errors })
})
// ── Désimport épisode ──────────────────────────────────────────
// ── Retrait d'un épisode ───────────────────────────────────────
router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const episodeId = String(req.params.episodeId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
let foundHash: string | null = null
let foundEntry: any = null
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const found: { hash: string; entry: any }[] = []
for (const [hash, episodes] of Object.entries(organized)) {
if (episodes[episodeId]) { foundHash = hash; foundEntry = episodes[episodeId]; break }
if (episodes[episodeId]) found.push({ hash, entry: episodes[episodeId] })
}
if (!foundHash || !foundEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
if (found.length === 0) { res.status(404).json({ error: 'Épisode non importé' }); return }
try {
if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) {
fs.unlinkSync(foundEntry.dest_path)
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`)
const deletedPaths = new Set<string>()
if (deleteFile) {
for (const { entry } of found) {
const p = entry?.dest_path
if (!p || deletedPaths.has(p) || !fs.existsSync(p)) continue
fs.unlinkSync(p)
deletedPaths.add(p)
logger.info('api', `Retrait et suppression du fichier « ${p} »`)
}
}
delete organized[foundHash][episodeId]
const torrentEmpty = Object.keys(organized[foundHash]).length === 0
if (torrentEmpty) delete organized[foundHash]
saveOrganizedJson(organized)
// Si plus aucun épisode de ce torrent n'est importé → retirer le torrent du client
if (deleteFile && torrentEmpty && foundHash !== 'manual') {
dispatchRemove(foundHash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${foundHash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
const emptyHashes: string[] = []
for (const { hash } of found) {
delete organized[hash][episodeId]
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
}
logger.info('api', `Désimport ep ${episodeId}${deleteFile && torrentEmpty ? ` + torrent ${foundHash.slice(0, 8)}… retiré du client` : ''}`)
writeOrganized(organized)
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
}
const removedTorrents = deleteFile ? emptyHashes.filter(h => h !== 'manual') : []
logger.info('api', `Retrait de l'épisode ${episodeId}${removedTorrents.length ? `, ${removedTorrents.length} torrent(s) retiré(s) du client` : ''}`)
res.json({ ok: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Désimport échoué : ${msg}`)
const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
logger.error('api', `Échec du retrait de l'épisode ${episodeId} : ${msg}`)
res.status(500).json({ error: msg })
}
})
// ── Récap global des imports ───────────────────────────────────
// ── Récapitulatif des imports ──────────────────────────────────
router.get('/organized-summary', requireAuth, async (_req, res) => {
try {
const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const seriesData = await loadEnrichedSeriesData()
const { series: seriesData, complete } = await loadCatalogStatus()
const organized = readOrganized()
const result: any[] = []
for (const sd of seriesData) {
const rawTitle = sd.title ?? sd.show_title ?? ''
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
const serieTitle = serieFolderName(rawTitle)
const episodes: any[] = []
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
let orgHash: string | null = null
for (const [hash, eps] of Object.entries(organized)) {
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
// Chercher d'abord via ep.paths : le hash détermine le formatted_name
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const h = p.infohash.toLowerCase()
const e = (organized[h] ?? {})[String(ep.id)]
if (e) { orgEntry = e; orgHash = h; break }
}
if (!orgEntry && organized['manual']?.[String(ep.id)]) {
orgEntry = organized['manual'][String(ep.id)]; orgHash = 'manual'
}
if (!orgEntry) {
const found = findEntry(organized, ep.id)
if (found) { orgEntry = found.entry; orgHash = found.hash }
}
if (!orgEntry) continue
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, orgHash)
let expectedName: string
if (nfoSupport) {
expectedName = rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName
} else {
expectedName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
}
const needsRename = expectedName !== currentName
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
episodes.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, title: ep.title, current_name: currentName, expected_name: expectedName, dest_path: orgEntry.dest_path, torrent_hash: orgHash, needs_rename: needsRename, file_exists: fileExists })
}
}
@@ -357,23 +517,152 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
result.push({ serie_id: sd.id, serie_title: rawTitle, serie_title_clean: serieTitle, total: episodes.length, needs_rename: episodes.filter(e => e.needs_rename).length, episodes })
}
}
res.json({ series: result, nfo_support: nfoSupport })
const candidates = complete ? findOrphans(organized, seriesData) : []
const apiOnly = candidates.length > 0 ? await apiOnlyEpisodes(candidates, seriesData, readSettings().mediaPath) : new Set<string>()
const orphans = (apiOnly ? candidates : [])
.filter(o => !apiOnly?.has(String(o.episode_id)))
.map(o => ({ ...o, file_exists: !!o.dest_path && fs.existsSync(o.dest_path) }))
res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete && !!apiOnly })
} catch (err) {
logger.error('api', `organized-summary échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec du récapitulatif des imports : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
// ── Rename en masse ────────────────────────────────────────────
router.post('/rename-all', requireAuth, async (req, res) => {
const { serie_id } = req.body
// ── Retrait des épisodes disparus du catalogue ─────────────────
router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => {
const only = Array.isArray(req.body?.episode_ids) ? new Set(req.body.episode_ids.map(String)) : null
const { series, complete } = await loadCatalogStatus()
const catalogIncomplete = 'Catalogue incomplet (scraper GitHub ou API Fankai injoignable), réessayez plus tard'
if (!complete) { res.status(503).json({ error: catalogIncomplete }); return }
let apiOnly: Set<string> | null
try { apiOnly = await apiOnlyEpisodes(findOrphans(readOrganized(), series), series, readSettings().mediaPath) }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
if (!apiOnly) { res.status(503).json({ error: catalogIncomplete }); return }
let removed = 0
try {
updateOrganized(data => {
for (const o of findOrphans(data, series)) {
if (only && !only.has(String(o.episode_id))) continue
if (apiOnly.has(String(o.episode_id))) continue
delete data[o.hash][String(o.episode_id)]
removed++
}
return removed > 0
})
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return
}
logger.info('api', `Épisodes disparus du catalogue retirés du suivi : ${removed}`)
res.json({ ok: true, removed })
})
// ── Dossier de série ───────────────────────────────────────────
router.get('/organized-folders', requireAdmin, async (_req, res) => {
const { mediaPath } = readSettings()
if (!mediaPath) { res.json([]); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
try {
const result: { serie_id: number; serie_title: string; expected: string; current: string[] }[] = []
const catalog = await loadCatalog()
for (const sd of catalog) {
const title = sd.title ?? sd.show_title ?? ''
const expected = path.join(mediaPath, serieFolderName(title))
const stale = [...serieFolders(sd, organized, mediaPath, otherSerieFolders(catalog, sd.id, mediaPath)).keys()].filter(f => f !== expected && fs.existsSync(f))
if (stale.length > 0) result.push({ serie_id: sd.id, serie_title: title, expected, current: stale })
}
res.json(result)
} catch (err) {
logger.error('api', `Échec de la vérification des dossiers de séries : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
router.get('/organized/:serieId/folder', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId)
const sd = await resolveSerieData(serieId)
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
const { mediaPath } = readSettings()
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
const others = otherSerieFolders(await loadCatalog().catch(() => []), serieId, mediaPath)
const current = [...serieFolders(sd, organized, mediaPath, others)].map(([folder, entries]) => ({ path: folder, entries, exists: fs.existsSync(folder) }))
res.json({ expected, current, needs_rename: current.some(f => f.exists && f.path !== expected) })
})
router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
const serieId = Number(req.params.serieId)
const sd = await resolveSerieData(serieId, 'Renommage du dossier')
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
const { mediaPath } = readSettings()
if (!mediaPath) { res.status(400).json({ error: 'Chemin de la médiathèque non configuré' }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
let catalog: any[]
try { catalog = await loadCatalog() }
catch (err) { res.status(503).json({ error: `Catalogue indisponible : ${err instanceof Error ? err.message : err}` }); return }
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
const sources = [...serieFolders(sd, organized, mediaPath, otherSerieFolders(catalog, serieId, mediaPath)).keys()].filter(f => f !== expected && fs.existsSync(f))
if (sources.length === 0) { res.json({ ok: true, moved: 0, updated: 0 }); return }
// Tout le contenu (vidéos, NFO, images…) est fusionné dans le dossier attendu, sans jamais écraser
const moves = new Map<string, string>()
for (const src of sources)
for (const file of listFiles(src)) moves.set(file, path.join(expected, path.relative(src, file)))
const targets = [...moves.values()]
const conflicts = targets.filter((to, i) => fs.existsSync(to) || targets.indexOf(to) !== i)
if (conflicts.length > 0) {
res.status(409).json({ error: `${conflicts.length} fichier(s) existe(nt) déjà dans « ${path.basename(expected)} »`, conflicts: conflicts.slice(0, 10) }); return
}
const done = new Map<string, string>()
let failure: string | null = null
for (const [from, to] of moves) {
try {
fs.mkdirSync(path.dirname(to), { recursive: true })
fs.renameSync(from, to)
done.set(from, to)
} catch (err) {
failure = `${path.basename(from)} : ${err instanceof Error ? err.message : err}`
break
}
}
for (const src of sources) {
try { removeEmptyDirs(src) } catch {}
}
let updated = 0
try { updateOrganized(data => { updated = retargetEntries(data, done); return updated > 0 }) }
catch (err) {
logger.error('api', `Renommage du dossier de la série ${serieId} : fichiers déplacés mais suivi non mis à jour (${err instanceof Error ? err.message : err})`)
res.status(500).json({ error: 'Fichiers déplacés mais suivi non mis à jour, lancez une analyse de la médiathèque' }); return
}
const label = `« ${sources.map(s => path.basename(s)).join(' », « ')} » en « ${path.basename(expected)} »`
if (failure) {
logger.error('api', `Renommage du dossier ${label} interrompu après ${done.size} fichier(s) : ${failure}`)
res.status(500).json({ error: `Déplacement interrompu après ${done.size} fichier(s) : ${failure}`, moved: done.size, updated }); return
}
logger.info('api', `Dossier de série renommé : ${label} (${done.size} fichier(s), ${updated} entrée(s))`)
res.json({ ok: true, moved: done.size, updated })
})
// ── Renommage groupé ───────────────────────────────────────────
router.post('/rename-all', requireAdmin, async (req, res) => {
const { serie_id, serie_ids } = req.body
const onlyIds = Array.isArray(serie_ids) ? new Set(serie_ids.map(Number)) : serie_id ? new Set([Number(serie_id)]) : null
const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const seriesData = await loadEnrichedSeriesData()
const seriesData = await loadCatalog()
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const done: number[] = []
const errors: { episode_id: number; error: string }[] = []
for (const sd of seriesData) {
if (serie_id && sd.id !== Number(serie_id)) continue
if (onlyIds && !onlyIds.has(sd.id)) continue
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
@@ -382,50 +671,43 @@ router.post('/rename-all', requireAuth, async (req, res) => {
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
}
if (!orgEntry || !orgHash) continue
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, orgHash)
let expectedName: string
if (nfoSupport) {
expectedName = rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName
} else {
expectedName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
}
if (expectedName === currentName) continue
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
if (!needsRename) continue
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (!fs.existsSync(oldPath)) {
const msg = `Fichier introuvable : "${oldPath}"`
logger.error('api', `Rename masse ep ${ep.id} — ${msg}`)
const msg = `Fichier introuvable : « ${oldPath} »`
logger.error('api', `Renommage groupé, épisode ${ep.id} : ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue
}
if (fs.existsSync(newPath)) {
const msg = `Fichier existant : "${expectedName}"`
logger.warn('api', `Rename masse ep ${ep.id} — ${msg}`)
const msg = `Un fichier nommé « ${expectedName} » existe déjà`
logger.warn('api', `Renommage groupé, épisode ${ep.id} : ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue
}
fs.renameSync(oldPath, newPath)
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
retargetEntries(organized, new Map([[oldPath, newPath]]))
done.push(ep.id)
logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`)
logger.info('api', `Renommage : « ${currentName} » en « ${expectedName} »`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Rename masse ep ${ep.id} — exception : ${msg}`)
const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
logger.error('api', `Échec du renommage de l'épisode ${ep.id} : ${msg}`)
errors.push({ episode_id: ep.id, error: msg })
}
}
}
}
saveOrganizedJson(organized)
writeOrganized(organized)
res.json({ ok: true, done: done.length, errors })
})
// ── Purge NFO / images des dossiers série ─────────────────────
// ── Purge des NFO et images ───────────────────────────────────
router.post('/purge-nfo', requireAuth, (req, res) => {
const { mediaPath } = readSettings()
if (!mediaPath || !fs.existsSync(mediaPath)) {
res.status(400).json({ error: 'Chemin médiathèque non configuré ou introuvable' }); return
res.status(400).json({ error: 'Chemin de la médiathèque non configuré ou introuvable' }); return
}
const NFO_EXTS = new Set(['.nfo', '.png', '.jpg', '.jpeg', '.tbn', '.xml'])
@@ -443,10 +725,10 @@ router.post('/purge-nfo', requireAuth, (req, res) => {
try {
fs.unlinkSync(full)
deleted++
logger.debug('api', `Purge NFO : supprimé "${full}"`)
logger.debug('api', `Purge NFO : « ${full} » supprimé`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Purge NFO : impossible de supprimer "${full}" : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('api', `Purge NFO : impossible de supprimer « ${full} » : ${msg}`)
errors.push(full)
}
}
@@ -454,7 +736,7 @@ router.post('/purge-nfo', requireAuth, (req, res) => {
}
walk(mediaPath)
logger.info('api', `Purge NFO — ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
logger.info('api', `Purge NFO : ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
res.json({ ok: true, deleted, errors })
})
+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,
note,
})
res.json(invite)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// DELETE /api/invites/:code (admin)
router.delete('/invites/:code', requireAdmin, (req, res) => {
try {
deleteInvite(String(req.params.code))
res.json({ success: true })
} catch (err) {
res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// GET /api/invites/:code/info (public : page d'inscription)
router.get('/invites/:code/info', (req, res) => {
const result = validateInvite(req.params.code)
if (!result.valid) {
res.status(400).json({ valid: false, reason: result.reason }); return
}
res.json({ valid: true, note: result.invite.note ?? null })
})
// POST /api/invites/:code/register (public : inscription par invitation)
router.post('/invites/:code/register', (req, res) => {
const result = validateInvite(req.params.code)
if (!result.valid) {
res.status(400).json({ error: result.reason }); return
}
const { username, password } = req.body
if (!username || !password) {
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
if (password.length < 6) {
res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères' }); return
}
try {
const user = createUser(username, password, 'user')
consumeInvite(req.params.code)
res.json({ success: true, user: safeUser(user) })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
export default router
+85
View File
@@ -0,0 +1,85 @@
import { Router } from 'express'
import { readSettings, writeSettings } from '../settings.js'
import { readUsers, createUser, safeUser } from '../users.js'
import { testJellyfinConnection, fetchJellyfinUsers } from '../lib/jellyfin.js'
import { logger } from '../logger.js'
const router = Router()
router.get('/jellyfin/settings', (_req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
res.json({
jellyfinUrl,
// Clé API jamais renvoyée : seulement sa présence
hasToken: !!jellyfinAdminToken,
})
})
router.post('/jellyfin/settings', (req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = req.body
const update: Record<string, string> = {}
if (jellyfinUrl !== undefined) update.jellyfinUrl = String(jellyfinUrl).trim()
if (jellyfinAdminToken !== undefined) update.jellyfinAdminToken = String(jellyfinAdminToken).trim()
const updated = writeSettings(update)
res.json({ jellyfinUrl: updated.jellyfinUrl, hasToken: !!updated.jellyfinAdminToken })
})
router.post('/jellyfin/test', async (req, res) => {
const settings = readSettings()
const jellyfinUrl = req.body?.jellyfinUrl || settings.jellyfinUrl
const jellyfinAdminToken = req.body?.jellyfinAdminToken || settings.jellyfinAdminToken
if (!jellyfinUrl || !jellyfinAdminToken) {
return res.status(400).json({
ok: false,
error: 'URL et clé API requises'
})
}
const result = await testJellyfinConnection(
jellyfinUrl,
jellyfinAdminToken
)
res.json(result)
})
export async function runJellyfinSync(): Promise<{ created: number; skipped: number; users: string[] }> {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) return { created: 0, skipped: 0, users: [] }
const jellyfinUsers = await fetchJellyfinUsers()
const fankarrUsers = readUsers()
const results = { created: 0, skipped: 0, users: [] as string[] }
for (const jUser of jellyfinUsers) {
if (jUser.Policy?.IsDisabled) { results.skipped++; continue }
const exists = fankarrUsers.some(u =>
u.username.toLowerCase() === jUser.Name.toLowerCase()
)
if (exists) { results.skipped++; continue }
const randomPass = Math.random().toString(36).slice(2, 10) +
Math.random().toString(36).slice(2, 10)
createUser(jUser.Name, randomPass, 'user')
results.created++
results.users.push(jUser.Name)
logger.info('jellyfin', `Compte créé pour « ${jUser.Name} » (synchronisation Jellyfin)`)
}
logger.info('jellyfin', `Synchronisation Jellyfin : ${results.created} compte(s) créé(s), ${results.skipped} ignoré(s) (existant ou désactivé)`)
return results
}
router.post('/jellyfin/sync', async (_req, res) => {
try {
const results = await runJellyfinSync()
res.json(results)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Échec de la synchronisation Jellyfin' })
}
})
export default router
+2 -2
View File
@@ -10,8 +10,8 @@ router.get('/nfo-updates/recent', requireAuth, (_req, res) => {
})
router.post('/nfo-updates/check', requireAuth, async (_req, res) => {
logger.info('nfo-update', 'Vérification manuelle des MAJ NFO lancée')
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif manuelle échouée : ${err instanceof Error ? err.message : err}`))
logger.info('nfo-update', 'Vérification manuelle des mises à jour NFO lancée')
checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification manuelle des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
res.json({ ok: true, message: 'Vérification lancée en arrière-plan' })
})
+24 -13
View File
@@ -2,8 +2,11 @@ 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 { loadCatalog } from '../lib/serie-helpers.js'
import { recentOrganized, pushNotif, clearNotifs } from '../lib/notifs.js'
import { dispatchGetFiles } from '../torrent-clients/index.js'
import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule } from '../lib/schedule.js'
import { readSettings } from '../settings.js'
import { DATA_DIR } from '../config.js'
import path from 'path'
@@ -11,39 +14,47 @@ const router = Router()
router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
try {
const seriesData = await loadEnrichedSeriesData()
const seriesData = await loadCatalog()
const organizedPath = path.join(DATA_DIR, 'organized.json')
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
res.json({ ok: true, ...result })
} catch (err) {
logger.error('api', `Migration IDs échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec de la migration des IDs : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
router.get('/organize/recent', requireAuth, (_req, res) => {
res.json(recentOrganized)
res.json(recentOrganized.slice(0, 20))
})
router.get('/organize/schedule', requireAuth, (_req, res) => {
res.json({ autoImport: readSettings().autoImport, intervalMs: AUTO_IMPORT_INTERVAL_MS, ...autoImportSchedule })
})
router.post('/organize/recent/clear', requireAuth, (_req, res) => {
recentOrganized.length = 0
clearNotifs()
logger.info('api', 'Historique des imports effacé')
res.json({ ok: true })
})
router.post('/organize', requireAuth, async (req, res) => {
const { hash, name, save_path } = req.body
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
if (!hash || !name || !save_path) { res.status(400).json({ error: 'Hash, nom et dossier du torrent requis' }); return }
try {
const seriesData = await loadEnrichedSeriesData()
const result = await organizeTorrent(hash, name, save_path, seriesData)
const seriesData = await loadCatalog()
// Progression par fichier, facultative (client injoignable toléré)
const files = await dispatchGetFiles(hash).catch(() => [])
const result = await organizeTorrent(hash, name, save_path, seriesData, files)
if (result.done > 0 || result.errors.length > 0) {
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
pushNotif({ hash, name, serieId: result.serieId ?? null, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
}
res.json(result)
} catch (err) {
logger.error('api', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec de l'import manuel de « ${name} » : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
+36 -36
View File
@@ -7,7 +7,7 @@ const router = Router()
const PLEX_TV_API = 'https://plex.tv/api/v2'
// ── Fetch avec bypass SSL pour les serveurs locaux ────────────────────────────
// ── Serveur Plex local : certificat non vérifié ───────────────────────────────
function getServerFetch(serverUrl: string) {
return (url: string, opts: any = {}) =>
undiciFetch(url, {
@@ -16,7 +16,6 @@ function getServerFetch(serverUrl: string) {
})
}
// ── Fetch vers plex.tv (certificat valide, pas besoin de bypass) ──────────────
async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
const res = await fetch(url, {
...options,
@@ -28,7 +27,10 @@ async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
...(options.headers ?? {}),
},
})
if (!res.ok) throw new Error(`Plex ${res.status}: ${await res.text()}`)
if (!res.ok) {
logger.debug('plex', `plex.tv HTTP ${res.status} : ${await res.text().catch(() => '')}`)
throw new Error(`Plex a répondu HTTP ${res.status}`)
}
const text = await res.text()
return text ? JSON.parse(text) : {}
}
@@ -43,7 +45,6 @@ function mapServers(resources: any[]) {
}))
}
// POST /api/plex/connect — auth plex.tv + liste serveurs
router.post('/plex/connect', requireAuth, async (req, res) => {
const { username, password, code } = req.body
if (!username || !password) { res.status(400).json({ error: 'Email et mot de passe requis' }); return }
@@ -56,23 +57,22 @@ router.post('/plex/connect', requireAuth, async (req, res) => {
body : params.toString(),
})
const token = authData.authToken ?? authData.user?.authToken
if (!token) throw new Error('Token non reçu')
if (!token) throw new Error('Plex n\'a pas renvoyé de jeton d\'accès')
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
const servers = mapServers(resources)
logger.info('plex', `Auth réussie pour ${username} — ${servers.length} serveur(s)`)
logger.info('plex', `Connexion Plex réussie pour ${username} (${servers.length} serveur(s))`)
res.json({ ok: true, token, servers })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
if (msg.includes('401')) {
res.status(401).json({ error: '2FA requis ou identifiants incorrects', requires2FA: true })
res.status(401).json({ error: 'Identifiants incorrects, ou code de validation en deux étapes requis', requires2FA: true })
} else {
logger.error('plex', `Auth échouée : ${msg}`)
res.status(401).json({ error: 'Authentification échouée — vérifiez vos identifiants' })
logger.error('plex', `Échec de l'authentification Plex : ${msg}`)
res.status(401).json({ error: 'Échec de l\'authentification : vérifiez vos identifiants' })
}
}
})
// POST /api/plex/oauth/start — démarre le flow OAuth
router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
try {
const data = await plexFetch(`${PLEX_TV_API}/pins`, {
@@ -82,18 +82,17 @@ router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
})
const pinId = data.id
const pinCode = data.code
if (!pinId || !pinCode) throw new Error('Pin non reçu')
if (!pinId || !pinCode) throw new Error('Plex n\'a pas renvoyé de code PIN')
const authUrl = `https://app.plex.tv/auth#?clientID=fankarr&code=${pinCode}&context%5Bdevice%5D%5Bproduct%5D=FanKarr&forwardUrl=https%3A%2F%2Fplex.tv`
logger.info('plex', `OAuth démarré — pinId ${pinId}`)
logger.info('plex', `Connexion Plex démarrée (PIN ${pinId})`)
res.json({ ok: true, pinId, pinCode, authUrl })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
logger.error('plex', `OAuth start échoué : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('plex', `Échec du démarrage de la connexion Plex : ${msg}`)
res.status(500).json({ error: msg })
}
})
// GET /api/plex/oauth/poll/:pinId — vérifie si l'OAuth est complété
router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
const pinId = String(req.params.pinId)
try {
@@ -102,22 +101,20 @@ router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
if (!token) { res.json({ ok: false, pending: true }); return }
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
const servers = mapServers(resources)
logger.info('plex', `OAuth complété — ${servers.length} serveur(s)`)
logger.info('plex', `Connexion Plex réussie (${servers.length} serveur(s))`)
res.json({ ok: true, pending: false, token, servers })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
res.status(500).json({ error: msg })
}
})
// POST /api/plex/setup — enregistre l'agent Fankai + crée la bibliothèque
router.post('/plex/setup', requireAuth, async (req, res) => {
const { token, serverUrl, libraryName, libraryPath } = req.body
if (!token || !serverUrl || !libraryName || !libraryPath) {
res.status(400).json({ error: 'token, serverUrl, libraryName, libraryPath requis' }); return
res.status(400).json({ error: 'Jeton Plex, serveur, nom et dossier de la bibliothèque requis' }); return
}
// Fetch avec SSL bypass pour les appels vers le serveur Plex local
const serverFetch = getServerFetch(serverUrl)
const headers: Record<string, string> = { 'X-Plex-Token': token, 'Accept': 'application/json' }
@@ -127,7 +124,7 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
let groupId: string | null = null
let agentSetupOk = false
// ── Étape 1 : Enregistrer le provider Fankai ─────────────
// ── Étape 1 : enregistrer l'agent Fankai ─────────────────
try {
const providersRes = await serverFetch(`${serverUrl}/media/providers/metadata`, { headers })
if (providersRes.ok) {
@@ -140,7 +137,7 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
}
if (provider) {
agentIdentifier = provider.identifier ?? agentIdentifier
steps.push({ step: 'provider', ok: true, message: `Provider enregistré (${agentIdentifier})` })
steps.push({ step: 'provider', ok: true, message: `Agent Fankai enregistré (${agentIdentifier})` })
}
const groupsRes = await serverFetch(`${serverUrl}/media/providers/metadata/group`, { headers })
if (groupsRes.ok) {
@@ -156,22 +153,22 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
}
if (group) {
groupId = String(group.id ?? '')
steps.push({ step: 'group', ok: true, message: `Groupe d'agents créé (ID: ${groupId})` })
steps.push({ step: 'group', ok: true, message: `Groupe d'agents prêt (ID : ${groupId})` })
agentSetupOk = true
}
}
} else {
const status = providersRes.status
steps.push({ step: 'agent', ok: false, message: `Agent non configuré (HTTP ${status} — Plex < 1.43 ?) — setup manuel requis` })
logger.warn('plex', `Setup agent Fankai — HTTP ${status} depuis ${serverUrl}`)
steps.push({ step: 'agent', ok: false, message: `Agent Fankai non configuré (HTTP ${status}, Plex antérieur à 1.43 ?) : configuration manuelle nécessaire` })
logger.warn('plex', `Configuration de l'agent Fankai : HTTP ${status} depuis ${serverUrl}`)
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
steps.push({ step: 'agent', ok: false, message: `Agent non configuré — setup manuel requis` })
logger.warn('plex', `Setup agent Fankai échoué : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
steps.push({ step: 'agent', ok: false, message: `Agent Fankai non configuré : configuration manuelle nécessaire` })
logger.warn('plex', `Échec de la configuration de l'agent Fankai : ${msg}`)
}
// ── Étape 2 : Créer la bibliothèque ──────────────────────
// ── Étape 2 : créer la bibliothèque ──────────────────────
try {
const params = new URLSearchParams({
type : 'show',
@@ -183,14 +180,17 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
})
if (groupId) params.set('metadataAgentProviderGroupId', groupId)
const libRes = await serverFetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers })
if (!libRes.ok) throw new Error(`HTTP ${libRes.status}: ${await libRes.text()}`)
steps.push({ step: 'library', ok: true, message: `Bibliothèque "${libraryName}" créée` })
logger.info('plex', `Bibliothèque "${libraryName}" créée sur ${serverUrl}`)
if (!libRes.ok) {
logger.debug('plex', `Création de bibliothèque HTTP ${libRes.status} : ${await libRes.text().catch(() => '')}`)
throw new Error(`le serveur Plex a répondu HTTP ${libRes.status}`)
}
steps.push({ step: 'library', ok: true, message: `Bibliothèque « ${libraryName} » créée` })
logger.info('plex', `Bibliothèque « ${libraryName} » créée sur ${serverUrl}`)
res.json({ ok: true, agentSetupOk, steps, manualSetup: !agentSetupOk })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
steps.push({ step: 'library', ok: false, message: `Création bibliothèque échouée : ${msg}` })
logger.error('plex', `Création bibliothèque échouée : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
steps.push({ step: 'library', ok: false, message: `Échec de la création de la bibliothèque : ${msg}` })
logger.error('plex', `Échec de la création de la bibliothèque : ${msg}`)
res.status(500).json({ ok: false, steps, error: msg })
}
})
+199
View File
@@ -0,0 +1,199 @@
import { Router } from 'express'
import { findByApiToken } from '../users.js'
import { validateJellyfinToken } from '../lib/jellyfin.js'
import { readUsers, safeUser } from '../users.js'
import { upsertRequest, approveRequest, getRequestsForUser } from '../requests.js'
import { autoDownloadRequest } from './requests.js'
import { readSettings } from '../settings.js'
import { readAvailable } from '../lib/github-cache.js'
import { fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode } from '../lib/serie-helpers.js'
import { logger } from '../logger.js'
import type { Request, Response, NextFunction } from 'express'
const router = Router()
// ── Jeton d'API ───────────────────────────────────────────────
function requireApiToken(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Jeton d\'API requis (Authorization: Bearer <jeton>)' }); return
}
const user = findByApiToken(header.slice(7))
if (!user) {
res.status(401).json({ error: 'Jeton d\'API invalide' }); return
}
req.user = user
next()
}
// ── POST /api/v1/auth/jellyfin ────────────────────────────────
// Échange un jeton Jellyfin contre un jeton FanKarr
router.post('/v1/auth/jellyfin', async (req, res) => {
const { jellyfinUserId, jellyfinToken } = req.body
if (!jellyfinUserId || !jellyfinToken) {
res.status(400).json({ error: 'jellyfinUserId et jellyfinToken requis' }); return
}
const info = await validateJellyfinToken(jellyfinUserId, jellyfinToken)
if (!info.valid) {
res.status(401).json({ error: info.error ?? 'Échec de l\'authentification Jellyfin' }); return
}
const users = readUsers()
const fanUser = users.find(u => u.username.toLowerCase() === info.username!.toLowerCase())
if (!fanUser) {
logger.warn('public-api', `Connexion via Jellyfin : aucun compte FanKarr pour « ${info.username} » : synchronisation Jellyfin nécessaire`)
res.status(404).json({
error: `Aucun compte FanKarr pour « ${info.username} ». Demandez à l'administrateur de lancer la synchronisation Jellyfin.`,
}); return
}
logger.info('public-api', `Connexion via Jellyfin réussie pour « ${fanUser.username} »`)
res.json({
token : fanUser.apiToken,
username: fanUser.username,
role : fanUser.role,
})
})
// ── GET /api/v1/auth/me ───────────────────────────────────────
router.get('/v1/auth/me', requireApiToken, (req, res) => {
res.json(safeUser(req.user!))
})
// ── GET /api/v1/series/search?q= ─────────────────────────────
router.get('/v1/series/search', requireApiToken, async (req, res) => {
const q = String(req.query.q ?? '').trim().toLowerCase()
try {
const [apiData, availableIds] = await Promise.all([
fankaiGet('/series'),
readAvailable(),
])
const availableSet = new Set<number>(availableIds)
const all: any[] = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
// Demandes actives de cet utilisateur, indexées par serieId
const activeRequests = getRequestsForUser(req.user!.id)
.filter(r => r.status !== 'rejected' && r.status !== 'completed')
const requestMap = new Map<number, { id: string; status: string; seasons: number[]; episodes: number[] }>()
for (const r of activeRequests) {
const me = r.requesters.find(rq => rq.userId === req.user!.id)
if (!me) continue
requestMap.set(r.serieId, {
id : r.id,
status : r.status,
seasons : me.seasons,
episodes: me.episodes ?? [],
})
}
const results = all
.filter(s => availableSet.has(s.id))
.filter(s => !q || s.title?.toLowerCase().includes(q) || s.original_title?.toLowerCase().includes(q))
.slice(0, 50)
.map(s => ({
id : s.id,
title : s.title,
original_title: s.original_title ?? null,
image : s.poster_image ?? null,
year : s.year ?? null,
rating : s.rating?.value ?? null,
description : s.plot ?? null,
request : requestMap.get(s.id) ?? null,
}))
res.json(results)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// ── GET /api/v1/series/:id ────────────────────────────────────
// Relais de l'API Fankai
router.get('/v1/series/:id', requireApiToken, async (req, res) => {
const id = Number(req.params.id)
if (isNaN(id)) { res.status(400).json({ error: 'Identifiant de série invalide' }); return }
try {
const [serieRaw, seasonsData] = await Promise.all([
fankaiGet(`/series/${id}`),
fankaiGet(`/series/${id}/seasons`),
])
const serie = normalizeSerie(serieRaw)
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
const seasonsWithEpisodes = await Promise.all(
seasons.map(async (season: any) => {
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? []))
.map((ep: any) => ({
id : ep.id,
episode_number: ep.episode_number,
title : ep.title ?? null,
image : normalizeEpisode(ep).thumb_image ?? null,
}))
return {
...normalizeSeason(season),
season_number: season.season_number,
episodes,
}
})
)
res.json({
id : serie.id,
title : serie.title,
original_title: serie.original_title ?? null,
image : serie.poster_image ?? null,
seasons : seasonsWithEpisodes,
})
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// ── POST /api/v1/requests ─────────────────────────────────────
router.post('/v1/requests', requireApiToken, (req, res) => {
const { serieId, serieName, seasons, episodes } = req.body
if (!serieId || !serieName) {
res.status(400).json({ error: 'serieId et serieName requis' }); return
}
try {
const submittedSeasons = Array.isArray(seasons) ? seasons.map(Number) : []
const submittedEpisodes = Array.isArray(episodes) ? episodes.map(Number) : []
let request = upsertRequest(
req.user!.id,
Number(serieId),
String(serieName),
submittedSeasons,
submittedEpisodes,
)
const { requestAutoDownloadUsers } = readSettings()
const userId = req.user!.id
const allowed = requestAutoDownloadUsers === 'all'
|| (Array.isArray(requestAutoDownloadUsers) && requestAutoDownloadUsers.includes(userId))
if (allowed) {
const isNew = request.status === 'pending'
if (isNew) request = approveRequest(request.id)
const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes }
autoDownloadRequest(request, dlOverride).catch(err =>
logger.warn('public-api', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
)
}
res.json(request)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// ── GET /api/v1/requests ──────────────────────────────────────
router.get('/v1/requests', requireApiToken, (req, res) => {
res.json(getRequestsForUser(req.user!.id))
})
export default router
+173
View File
@@ -0,0 +1,173 @@
import { Router } from 'express'
import { requireAdmin } from '../auth.js'
import {
readRequests, upsertRequest, approveRequest,
rejectRequest, completeRequest, deleteRequest, withdrawRequest,
getRequestsForUser, getPendingCount, mergedSeasons, mergedEpisodes,
type SerieRequest,
} from '../requests.js'
import { dispatchDownload } from '../torrent-clients/index.js'
import { readSerieData } from '../lib/github-cache.js'
import { resolveRequestTorrents, countRequestedEpisodes } from '../lib/request-scope.js'
import { readSettings } from '../settings.js'
import { logger } from '../logger.js'
const router = Router()
router.get('/requests', async (req, res) => {
const list = req.user!.role === 'admin'
? readRequests()
: getRequestsForUser(req.user!.id)
res.json(await withScope(list))
})
async function withScope(list: SerieRequest[]) {
const serieData = new Map<number, Promise<any | null>>()
return Promise.all(list.map(async r => {
if (!serieData.has(r.serieId)) serieData.set(r.serieId, readSerieData(r.serieId))
const sd = await serieData.get(r.serieId)
const seasons = mergedSeasons(r)
const episodes = mergedEpisodes(r)
return {
...r,
episodeCount: sd ? countRequestedEpisodes(sd, seasons, episodes) : null,
hasTorrents : !!sd && resolveRequestTorrents(sd, seasons, episodes).length > 0,
}
}))
}
router.get('/requests/pending-count', (req, res) => {
const count = req.user!.role === 'admin'
? getPendingCount()
: getRequestsForUser(req.user!.id).filter(r => r.status === 'pending').length
res.json({ count })
})
router.post('/requests', (req, res) => {
const { serieId, serieName, seasons, episodes, torrentOverride } = req.body
if (!serieId || !serieName) {
res.status(400).json({ error: 'Série manquante dans la demande' }); return
}
try {
const submittedSeasons = Array.isArray(seasons) ? seasons.map(Number) : []
const submittedEpisodes = Array.isArray(episodes) ? episodes.map(Number) : []
let request = upsertRequest(
req.user!.id,
Number(serieId),
String(serieName),
submittedSeasons,
submittedEpisodes,
)
const { requestAutoDownloadUsers } = readSettings()
const userId = req.user!.id
const allowed = requestAutoDownloadUsers === 'all'
|| (Array.isArray(requestAutoDownloadUsers) && requestAutoDownloadUsers.includes(userId))
if (allowed) {
const isNew = request.status === 'pending'
if (isNew) request = approveRequest(request.id)
if (torrentOverride?.torrent_url || torrentOverride?.magnet) {
const url = torrentOverride.torrent_url ?? torrentOverride.magnet
dispatchDownload(url, {
infohash : torrentOverride.infohash ?? null,
magnet : torrentOverride.magnet ?? null,
file_index: torrentOverride.file_index ?? null,
file_path : torrentOverride.file_path ?? null,
}).catch(err =>
logger.warn('requests', `Échec du téléchargement automatique du torrent choisi pour « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
)
} else {
const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes }
autoDownloadRequest(request, dlOverride).catch(err =>
logger.warn('requests', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
)
}
}
res.json(request)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
router.patch('/requests/:id', requireAdmin, async (req, res) => {
const { action, rejectionMessage } = req.body
try {
let request: SerieRequest
const id = String(req.params.id)
if (action === 'approve') {
request = approveRequest(id)
autoDownloadRequest(request).catch(err =>
logger.warn('requests', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
)
} else if (action === 'reject') request = rejectRequest(id, rejectionMessage)
else if (action === 'complete') request = completeRequest(id)
else { res.status(400).json({ error: 'Action inconnue (approve, reject ou complete)' }); return }
res.json(request)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
/** Envoie les torrents de la demande ; override limite aux saisons et épisodes qui viennent d'être ajoutés. */
export async function autoDownloadRequest(req: SerieRequest, override?: { seasons: number[]; episodes: number[] }): Promise<void> {
const serieData = await readSerieData(req.serieId)
if (!serieData) {
logger.warn('requests', `Téléchargement automatique : aucune donnée de torrent pour la série ${req.serieId}`)
return
}
const seasons = override !== undefined ? override.seasons : mergedSeasons(req)
const episodes = override !== undefined ? override.episodes : mergedEpisodes(req)
const toDownload = resolveRequestTorrents(serieData, seasons, episodes)
if (toDownload.length === 0) {
logger.warn('requests', `Téléchargement automatique : aucun torrent trouvé pour « ${req.serieName} »`)
return
}
let sent = 0
for (const t of toDownload) {
const url = t.url ?? t.magnet
if (!url) continue
try {
const results = await dispatchDownload(url, { infohash: t.infohash ?? undefined, magnet: t.magnet ?? undefined, file_index: t.file_index ?? null, file_path: t.file_path ?? null })
if (results.some(r => r.ok)) sent++
} catch (err) {
logger.warn('requests', `Téléchargement automatique : erreur à l'envoi d'un torrent : ${err instanceof Error ? err.message : err}`)
}
}
logger.info('requests', `Téléchargement automatique de « ${req.serieName} » : ${sent}/${toDownload.length} torrent(s) envoyé(s)`)
}
router.delete('/requests', requireAdmin, (_req, res) => {
const all = readRequests()
for (const r of all) {
try { deleteRequest(r.id) } catch {}
}
logger.info('requests', `Toutes les demandes supprimées (${all.length})`)
res.json({ deleted: all.length })
})
router.delete('/requests/:id/mine', (req, res) => {
try {
res.json(withdrawRequest(String(req.params.id), req.user!.id))
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
router.delete('/requests/:id', requireAdmin, (req, res) => {
try {
deleteRequest(String(req.params.id))
res.json({ success: true })
} catch (err) {
res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
export default router
+61
View File
@@ -0,0 +1,61 @@
import { Router } from 'express'
import { requireAuth, requireAdmin } from '../auth.js'
import { logger } from '../logger.js'
import { loadSynced, setSync, setSyncBulk, isSynced, runRssSync, reconcileSynced } from '../lib/rss-sync.js'
import { loadCatalogStatus } from '../lib/serie-helpers.js'
const router = Router()
router.get('/rss-sync', requireAuth, (_req, res) => {
res.json(Object.values(loadSynced()))
})
// Routes fixes à déclarer avant /:id
router.post('/rss-sync/run', requireAuth, async (_req, res) => {
try {
const result = await runRssSync()
res.json({ ok: true, ...result })
} catch (err) {
logger.error('rss-sync', `Échec de la vérification manuelle des séries surveillées : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
router.get('/rss-sync/orphans', requireAdmin, async (_req, res) => {
const { series, complete } = await loadCatalogStatus()
res.json({ checked: complete, orphans: complete ? reconcileSynced(series) : [] })
})
router.post('/rss-sync/bulk', requireAdmin, (req, res) => {
const enabled = req.body?.enabled === true
const series = (Array.isArray(req.body?.series) ? req.body.series : [])
.map((s: any) => ({ id: Number(s?.id), name: String(s?.name ?? '') }))
.filter((s: { id: number; name: string }) => s.id && s.name)
if (series.length === 0) { res.status(400).json({ error: 'Aucune série sélectionnée' }); return }
const { changed, map } = setSyncBulk(series, enabled)
logger.info('rss-sync', `Surveillance ${enabled ? 'activée' : 'désactivée'} pour plusieurs séries : ${changed} série(s) modifiée(s) sur ${series.length}`)
res.json({ ok: true, changed, total: Object.keys(map).length })
})
router.get('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id)
res.json({ synced: isSynced(serieId) })
})
router.post('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id)
const serieName = String(req.body.serieName ?? '')
if (!serieId || !serieName) { res.status(400).json({ error: 'Série manquante' }); return }
const map = setSync(serieId, serieName, true)
logger.info('rss-sync', `Surveillance activée pour « ${serieName} » (série ${serieId})`)
res.json({ synced: true, total: Object.keys(map).length })
})
router.delete('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id)
const map = setSync(serieId, '', false)
logger.info('rss-sync', `Surveillance désactivée pour la série ${serieId}`)
res.json({ synced: false, total: Object.keys(map).length })
})
export default router
+40 -32
View File
@@ -7,14 +7,21 @@ import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { dispatchList } from '../torrent-clients/index.js'
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
import { loadSynced } from '../lib/rss-sync.js'
import {
fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode,
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
deduplicateEpisodes,
fankaiGet, fetchSerieFromApi, normalizeSerie, normalizeSeason, normalizeEpisode,
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState, countOrganizedEpisodes,
deduplicateEpisodes, serieHasEpisodes,
} from '../lib/serie-helpers.js'
import { recentImportsBySerie } from '../lib/notifs.js'
const router = Router()
router.get('/library/recent', requireAuth, (req, res) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 12, 1), 50)
res.json(recentImportsBySerie().slice(0, limit))
})
router.get('/series', requireAuth, async (_req, res) => {
try {
const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()])
@@ -22,32 +29,39 @@ router.get('/series', requireAuth, async (_req, res) => {
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const activeTorrents = new Set<string>()
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
const clientHashes = new Set<string>()
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (!t.hash) continue; clientHashes.add(t.hash.toLowerCase()); if (t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
const organizedEpisodeIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
const synced = loadSynced()
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
const serieDataMap = new Map<number, any>()
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
const lastImports = new Map(recentImportsBySerie().map(r => [r.serieId, r.at]))
res.json({ series: seriesRaw.map((serie: any) => {
const serieData = serieDataMap.get(serie.id) ?? null
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) }
const serieData = serieDataMap.get(serie.id) ?? null
const torrents = serieData ? extractTorrentsFromSerieData(serieData) : []
const inClient = torrents.some(t => t.infohash && clientHashes.has(t.infohash.toLowerCase()))
const hasFiles = (serieData?.seasons ?? []).some((s: any) => (s.episodes ?? []).some((ep: any) => organizedEpisodeIds.has(String(ep.id))))
const counts = serieData ? countOrganizedEpisodes(serieData, organized) : { total: 0, organized: 0 }
return {
...serie, torrent_count: torrents.length, has_torrents: torrents.length > 0,
download_state: computeSerieDownloadState(serieData, organized, activeTorrents, counts),
episode_count: counts.total, organized_count: counts.organized, last_imported_at: lastImports.get(serie.id) ?? null,
in_client: inClient, has_files: hasFiles, rss_synced: !!synced[serie.id],
}
})})
} catch (err) {
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec du chargement des séries : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
router.get('/series/:id', requireAuth, async (req, res) => {
const id = Number(req.params.id)
try {
const [serieRaw, seasonsData, serieData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`), readSerieData(id)])
const serie = normalizeSerie(serieRaw)
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
return { ...normalizeSeason(season), episodes }
}))
const [{ seasons, ...serieRaw }, serieData] = await Promise.all([fetchSerieFromApi(id), readSerieData(id)])
const serie = { ...normalizeSerie(serieRaw), wiki: serieData?.wiki ?? null }
const seasonsWithEpisodes = seasons.map((season: any) => ({ ...normalizeSeason(season), episodes: season.episodes.map(normalizeEpisode) }))
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
@@ -57,16 +71,15 @@ router.get('/series/:id', requireAuth, async (req, res) => {
const integraleTorrents : any[] = []
const organizedEpisodeIds = new Set<number>()
const manualOrg = organized['manual'] ?? {}
if (serieData) {
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
}
// Importé = une entrée pour l'épisode sous n'importe quel hash : les torrents du scraper peuvent disparaître
const trackedIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
for (const season of seasonsWithEpisodes) {
for (const ep of season.episodes) {
if (trackedIds.has(String(ep.id))) organizedEpisodeIds.add(ep.id)
}
}
if (serieData) {
// Lookup rapide hash → torrent pack (intégrale ou saison)
// Index hash : pack (intégrale ou saison)
const torrentByHash = new Map<string, { torrent_url: string; magnet: string; type: string; raw: string; fankai: boolean; torrent_name: string | null }>()
for (const t of (serieData.torrents ?? []))
if (t.infohash) torrentByHash.set(t.infohash.toLowerCase(), { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title ?? '', fankai: t.fankai ?? true, torrent_name: t.torrent_name ?? null })
@@ -112,9 +125,6 @@ router.get('/series/:id', requireAuth, async (req, res) => {
organizedEpisodeIds.add(ep.id)
}
}
// Fallback : si l'épisode n'a pas de torrent individuel,
// on crée un torrent synthétique depuis ep.paths (fichier dans un pack).
// On ajoute TOUTES les entrées valides (un pack par path unique).
if (!episodeTorrentMap[ep.id] || episodeTorrentMap[ep.id].length === 0) {
const seenPackHashes = new Set<string>()
for (const pathEntry of (ep.paths ?? [])) {
@@ -161,8 +171,6 @@ router.get('/series/:id', requireAuth, async (req, res) => {
organized: organizedEpisodeIds.has(ep.id),
}
})
// Dédoublonnage : plusieurs variantes (x264/x265…) du même episode_number
// sont fusionnées en un seul épisode avec la liste de torrents combinée
const eps = deduplicateEpisodes(rawEps)
const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).length
@@ -185,10 +193,10 @@ router.get('/series/:id', requireAuth, async (req, res) => {
}
})
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) })
res.json({ serie, seasons: enrichedSeasons, scraper_synced: serieHasEpisodes(serieData), torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) })
} catch (err) {
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec du chargement de la série ${id} : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
+25 -10
View File
@@ -2,25 +2,40 @@ import { Router } from 'express'
import { requireAuth } from '../auth.js'
import { readSettings, writeSettings } from '../settings.js'
import { logger } from '../logger.js'
import { readOnboarding, advanceOnboarding, isOnboardingStep } from '../onboarding.js'
const router = Router()
router.get('/settings/onboarding', requireAuth, (_req, res) => {
res.json(readOnboarding())
})
router.post('/settings/onboarding', requireAuth, (req, res) => {
const { step, complete } = req.body ?? {}
if (step !== undefined && !isOnboardingStep(step)) {
res.status(400).json({ error: 'Étape inconnue' }); return
}
res.json(advanceOnboarding(step, complete === true))
})
router.get('/settings', requireAuth, (_req, res) => {
res.json(readSettings())
})
router.post('/settings', requireAuth, (req, res) => {
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing } = req.body
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing, requestAutoDownloadUsers, englishDirectory } = req.body
const patch: Record<string, any> = {}
if (mediaPath !== undefined) patch.mediaPath = mediaPath
if (completePath !== undefined) patch.completePath = completePath
if (organizeMode !== undefined) patch.organizeMode = organizeMode
if (category !== undefined) patch.category = category
if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport
if (autoImport !== undefined) patch.autoImport = autoImport
if (devMode !== undefined) patch.devMode = devMode
if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing
if (mediaPath !== undefined) patch.mediaPath = mediaPath
if (completePath !== undefined) patch.completePath = completePath
if (organizeMode !== undefined) patch.organizeMode = organizeMode
if (category !== undefined) patch.category = category
if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport
if (autoImport !== undefined) patch.autoImport = autoImport
if (devMode !== undefined) patch.devMode = devMode
if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing
if (requestAutoDownloadUsers !== undefined) patch.requestAutoDownloadUsers = requestAutoDownloadUsers
if (englishDirectory !== undefined) patch.englishDirectory = englishDirectory
const updated = writeSettings(patch)
logger.info('api', 'Paramètres mis à jour')
res.json(updated)
+74 -39
View File
@@ -5,16 +5,27 @@ import { requireAuth } from '../auth.js'
import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
import { DATA_DIR, BASE_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { systemInfo } from '../system.js'
import { scanMediaPath, syncFilenameChanges } from '../organize.js'
import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js'
import { cacheSize } from '../lib/github-cache.js'
import { systemInfo, checkPaths } from '../system.js'
import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js'
import { readAvailable, cacheClear } from '../lib/github-cache.js'
import { loadCatalog } from '../lib/serie-helpers.js'
import { cacheSize, cacheFetchedAt } from '../lib/github-cache.js'
import { recentOrganized } from '../lib/notifs.js'
import { workerRunning } from '../organize.js'
import { workerRunning, lastScan } from '../organize.js'
const router = Router()
// ── Logs ───────────────────────────────────────────────────────
const UNEXPECTED_ERROR = 'Erreur inattendue, consultez les journaux'
function readDirError(dirPath: string, err: unknown): string {
const code = (err as NodeJS.ErrnoException)?.code
if (code === 'ENOENT') return `Dossier introuvable : ${dirPath}`
if (code === 'EACCES' || code === 'EPERM') return `Accès refusé au dossier : ${dirPath}`
if (code === 'ENOTDIR') return `Ce chemin n'est pas un dossier : ${dirPath}`
return `Dossier inaccessible : ${dirPath}`
}
// ── Journaux ───────────────────────────────────────────────────
router.get('/logs', requireAuth, (req, res) => {
const limit = Number(req.query.limit) || 100
const level = (req.query.level as string) || 'all'
@@ -24,7 +35,7 @@ router.get('/logs', requireAuth, (req, res) => {
router.post('/logs/clear', requireAuth, (_req, res) => {
clearLogs()
logger.info('api', 'Logs effacés')
logger.info('api', 'Journaux effacés')
res.json({ ok: true })
})
@@ -35,6 +46,14 @@ router.get('/system', requireAuth, (_req, res) => {
res.json({ isDocker: fs.existsSync('/.dockerenv') })
})
router.post('/system/check-paths', requireAuth, (req, res) => {
const { mediaPath, completePath } = req.body ?? {}
if (typeof mediaPath !== 'string' || (completePath !== undefined && typeof completePath !== 'string')) {
res.status(400).json({ error: 'Chemin de la médiathèque requis' }); return
}
res.json(checkPaths(mediaPath, completePath))
})
router.get('/version', (_req, res) => {
try {
const versionPath = path.join(BASE_DIR, 'version.txt')
@@ -45,7 +64,7 @@ router.get('/version', (_req, res) => {
}
})
// ── Parcours fichiers ──────────────────────────────────────────
// ── Parcours des dossiers ──────────────────────────────────────
router.get('/browse', requireAuth, (req, res) => {
const isWindows = process.platform === 'win32'
const dirPath = (req.query.path as string) || '/'
@@ -65,7 +84,7 @@ router.get('/browse', requireAuth, (req, res) => {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b))
// Sur Windows, remonter à '/' quand on est à la racine d'un lecteur (ex: C:\)
// Sous Windows, remonter à '/' depuis la racine d'un lecteur (ex. C:\)
let parent: string | null
if (dirPath === '/') {
parent = null
@@ -73,13 +92,13 @@ router.get('/browse', requireAuth, (req, res) => {
parent = '/'
} else {
const up = path.dirname(dirPath)
parent = up === dirPath ? null : up // dirname de C:\ retourne C:\ → null
parent = up === dirPath ? null : up // dirname de C:\ renvoie C:\ : pas de parent
}
res.json({ path: dirPath, parent, dirs })
} catch (err) {
logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
logger.warn('api', `Lecture du dossier « ${dirPath} » impossible : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: readDirError(dirPath, err) })
}
})
@@ -87,10 +106,8 @@ router.get('/browse-files', requireAuth, (req, res) => {
const dirPath = (req.query.path as string) || '/'
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
try {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
logger.info('api', `Dossier créé : ${dirPath}`)
}
// Lecture seule : le dossier est créé au moment de l'import
if (!fs.existsSync(dirPath)) { res.json({ path: dirPath, files: [], exists: false }); return }
const files: { name: string; path: string; size: number }[] = []
function walk(dir: string) {
let entries: fs.Dirent[]
@@ -111,10 +128,10 @@ router.get('/browse-files', requireAuth, (req, res) => {
}
walk(dirPath)
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
res.json({ path: dirPath, files })
res.json({ path: dirPath, files, exists: true })
} catch (err) {
logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
logger.warn('api', `Lecture du dossier « ${dirPath} » impossible : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: readDirError(dirPath, err) })
}
})
@@ -122,51 +139,69 @@ router.get('/browse-files', requireAuth, (req, res) => {
router.get('/torrents/status', requireAuth, async (_req, res) => {
try {
const available = await readAvailable()
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0, syncedAt: cacheFetchedAt('available.json') })
} catch { res.json({ exists: false, count: 0, empty: true }) }
})
router.post('/update', requireAuth, async (_req, res) => {
router.post('/update', requireAuth, async (req, res) => {
const force = req.body?.force === true
const organizedPath = path.join(DATA_DIR, 'organized.json')
try {
cacheClear()
logger.info('api', 'Cache GitHub vidé — rechargement forcé')
const availableIds = await readAvailable()
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
logger.info('api', force
? 'Resynchronisation complète du catalogue (caches ignorés), noms de fichiers recalculés'
: 'Cache GitHub vidé : rechargement du catalogue')
const availableIds = await readAvailable(force)
if (!Array.isArray(availableIds)) throw new Error('Catalogue GitHub invalide (available.json)')
logger.info('api', `Catalogue rechargé : ${availableIds.length} séries disponibles`)
if (force) {
const seriesData = await loadCatalog(true)
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
const { removed } = dedupeOrganizedEpisodes(organizedPath)
const { renamed, errors } = await syncFilenameChanges(seriesData, organizedPath)
logger.info('api', `Resynchronisation terminée : ${updated} ID(s) migré(s), ${removed} doublon(s) retiré(s), ${renamed} fichier(s) renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
res.json({ ok: true, count: availableIds.length, migrated: updated, deduped: removed, renamed, errors })
return
}
res.json({ ok: true, count: availableIds.length })
;(async () => {
try {
const seriesData = await loadEnrichedSeriesData()
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
const seriesData = await loadCatalog()
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0)
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
logger.info('api', `Renommage après synchronisation : ${renamed} fichier(s) renommé(s)`)
} catch (err) {
logger.warn('api', `Sync noms après mise à jour échoué : ${err instanceof Error ? err.message : err}`)
logger.warn('api', `Échec du renommage après synchronisation : ${err instanceof Error ? err.message : err}`)
}
})()
} catch (err) {
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec de la synchronisation du catalogue : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
router.get('/scan', requireAuth, (_req, res) => {
res.json({ lastScan })
})
router.post('/scan', requireAuth, async (_req, res) => {
try {
const { mediaPath } = readSettings()
logger.info('api', 'Scan médiathèque manuel lancé')
const seriesData = await loadEnrichedSeriesData()
logger.info('api', 'Analyse manuelle de la médiathèque lancée')
const seriesData = await loadCatalog()
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
res.json({ ok: true, ...result })
} catch (err) {
logger.error('api', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec de l'analyse de la médiathèque : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
// ── Debug ──────────────────────────────────────────────────────
// ── Diagnostic ─────────────────────────────────────────────────
router.get('/debug/stats', requireAuth, (req, res) => {
const { devMode } = readSettings()
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
const mem = process.memoryUsage()
const uptimeS = Math.floor(process.uptime())
const h = Math.floor(uptimeS / 3600)
@@ -182,7 +217,7 @@ router.get('/debug/stats', requireAuth, (req, res) => {
} catch {}
res.json({
memory : { heapUsed: Math.round(mem.heapUsed / 1024 / 1024), heapTotal: Math.round(mem.heapTotal / 1024 / 1024), rss: Math.round(mem.rss / 1024 / 1024) },
cache : { entries: cacheSize(), ttlHours: 6 },
cache : { entries: cacheSize(), ttlHours: 1 },
uptime : `${h}h ${m}m ${s}s`,
uptimeSeconds: uptimeS,
worker : { running: workerRunning },
+8 -8
View File
@@ -18,8 +18,8 @@ router.get('/torrent-clients', requireAuth, (_req, res) => {
router.post('/torrent-clients', requireAuth, (req, res) => {
const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
if (!name || !type || !config) { res.status(400).json({ error: 'Nom, type et réglages du client requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
res.json(sanitizeClient(addClient(name, type, config)))
})
@@ -31,8 +31,8 @@ router.delete('/torrent-clients/:uuid', requireAuth, (req, res) => {
router.put('/torrent-clients/:uuid', requireAuth, (req, res) => {
const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
if (!name || !type || !config) { res.status(400).json({ error: 'Nom, type et réglages du client requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
const oldClient = getClient(String(req.params.uuid))
if (oldClient) {
const driver = getDriver(type)
@@ -51,9 +51,9 @@ router.put('/torrent-clients/:uuid', requireAuth, (req, res) => {
router.post('/torrent-clients/test-config', requireAuth, async (req, res) => {
const { type, config, uuid } = req.body
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
if (!type || !config) { res.status(400).json({ error: 'Type et réglages du client requis' }); return }
const driver = getDriver(type)
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
if (!driver) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
if (uuid) {
const oldClient = getClient(uuid)
if (oldClient) {
@@ -71,7 +71,7 @@ router.post('/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type)
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
if (!driver) { res.status(400).json({ error: 'Type de client non pris en charge' }); return }
res.json(await driver.test(client.config))
})
@@ -79,7 +79,7 @@ router.get('/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) =
const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type)
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
if (!driver) { res.status(400).json({ error: 'Type de client non pris en charge' }); return }
res.json(await driver.healthcheck(client.config))
})
+60
View File
@@ -0,0 +1,60 @@
import { Router } from 'express'
import {
readUsers, createUser, updateUser, deleteUser,
regenerateApiToken, safeUser,
} from '../users.js'
const router = Router()
router.get('/users', (_req, res) => {
res.json(readUsers().map(safeUser))
})
router.post('/users', (req, res) => {
const { username, password, role } = req.body
if (!username || !password) {
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
if (password.length < 6) {
res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères' }); return
}
try {
const user = createUser(username, password, role === 'admin' ? 'admin' : 'user')
res.json(safeUser(user))
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
router.patch('/users/:id', (req, res) => {
const { username, password, role } = req.body
try {
const user = updateUser(req.params.id, { username, password, role })
res.json(safeUser(user))
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
router.delete('/users/:id', (req, res) => {
if (req.user?.id === req.params.id) {
res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte' }); return
}
try {
deleteUser(req.params.id)
res.json({ success: true })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
router.post('/users/:id/regenerate-token', (req, res) => {
try {
const token = regenerateApiToken(req.params.id)
res.json({ apiToken: token })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
export default router
+2 -12
View File
@@ -1,10 +1,3 @@
/**
* secret.ts
* =========
* Génère et persiste le JWT secret dans data/secret.key
* Si JWT_SECRET est défini en variable d'environnement, il est utilisé tel quel.
* Sinon, on lit data/secret.key — s'il n'existe pas, on en génère un et on le sauvegarde.
*/
import fs from 'fs'
import path from 'path'
@@ -18,25 +11,22 @@ function generateSecret(): string {
}
function loadOrCreateSecret(): string {
// 1. Variable d'environnement — priorité absolue
if (process.env.JWT_SECRET && process.env.JWT_SECRET !== 'fankarr-secret-change-me') {
return process.env.JWT_SECRET
}
// 2. Fichier existant
if (fs.existsSync(SECRET_PATH)) {
const secret = fs.readFileSync(SECRET_PATH, 'utf-8').trim()
if (secret.length > 0) return secret
}
// 3. Génération + sauvegarde
const secret = generateSecret()
try {
fs.mkdirSync(path.dirname(SECRET_PATH), { recursive: true })
fs.writeFileSync(SECRET_PATH, secret, { encoding: 'utf-8', mode: 0o600 })
console.log('[auth] JWT secret généré et sauvegardé dans data/secret.key')
console.log(`[auth] Secret JWT généré dans ${SECRET_PATH}`)
} catch (err) {
console.error('[auth] Impossible de sauvegarder le secret:', err)
console.error('[auth] Impossible d\'enregistrer le secret JWT :', err)
}
return secret
+23 -9
View File
@@ -7,23 +7,37 @@ const DATA_PATH = path.join(DATA_DIR, 'settings.json')
export interface Settings {
mediaPath : string
completePath : string
organizeMode : 'hardlink' | 'copy' | 'move' // ← ajouter 'copy'
organizeMode : 'hardlink' | 'copy' | 'move'
category : string
nfoSupport : boolean
autoImport : boolean
devMode : boolean
deleteTorrentOnMove : boolean // ← ajouter
deleteTorrentOnMove : boolean
requestAutoDownloadUsers: 'all' | string[] // Utilisateurs dont les demandes sont approuvées et téléchargées automatiquement ; [] = aucun
jellyfinUrl : string // URL du serveur Jellyfin (ex. http://jellyfin:8096)
jellyfinAdminToken : string // Clé API Jellyfin pour la synchronisation
englishDirectory : boolean // Dossiers de saison en anglais (« Season 01 » au lieu de « Saison 1 »)
autoUnimportMissing : boolean
onboardingStep : string | null // étape la plus avancée atteinte dans l'assistant
onboardingCompletedAt: string | null
}
const defaults: Settings = {
mediaPath : '',
completePath: '',
organizeMode: 'hardlink',
category : 'fankai',
nfoSupport : false,
autoImport : true,
devMode : false,
mediaPath : '',
completePath : '',
organizeMode : 'hardlink',
category : 'fankai',
nfoSupport : false,
autoImport : true,
devMode : false,
deleteTorrentOnMove: false,
requestAutoDownloadUsers: [],
jellyfinUrl : '',
jellyfinAdminToken : '',
englishDirectory : false,
autoUnimportMissing: false,
onboardingStep : null,
onboardingCompletedAt: null,
}
export function readSettings(): Settings {
+141 -1
View File
@@ -1,5 +1,7 @@
import fs from 'fs'
import os from 'os'
import path from 'path'
import { randomBytes } from 'crypto'
import type { Request, Response } from 'express'
// GET /api/system/info
@@ -12,4 +14,142 @@ export function systemInfo(_req: Request, res: Response): void {
isTermux,
defaultPath: isTermux ? '/sdcard/' : '/',
})
}
}
// ── Vérification des dossiers (assistant de configuration) ────
export interface PathCheck {
input : string
resolved : string
exists : boolean
isDirectory: boolean
writable : boolean
error? : string
}
export interface HardlinkCheck {
tested : boolean
ok : boolean
code? : string
message: string
}
export type PathRelation = 'distinct' | 'same' | 'nested'
function tempName(prefix: string): string {
return `.fankarr-${prefix}-${process.pid}-${randomBytes(4).toString('hex')}`
}
function removeQuietly(file: string): void {
try { fs.rmSync(file, { force: true, maxRetries: 3, retryDelay: 100 }) } catch {}
}
export function checkDirectory(input: string, { allowRoot = true } = {}): PathCheck {
const raw = (input ?? '').trim()
const result: PathCheck = { input: raw, resolved: '', exists: false, isDirectory: false, writable: false }
if (!raw) return { ...result, error: 'Chemin vide' }
if (/^[A-Za-z]:$/.test(raw)) return { ...result, error: 'Chemin incomplet : ajoutez « \\ » après la lettre du lecteur (ex. C:\\)' }
if (!path.isAbsolute(raw)) return { ...result, error: 'Le chemin doit être absolu' }
result.resolved = path.resolve(raw)
if (!allowRoot && path.parse(result.resolved).root === result.resolved) {
return { ...result, error: 'La racine du disque ne peut pas servir de médiathèque' }
}
let stat: fs.Stats
try { stat = fs.statSync(result.resolved) }
catch { return { ...result, error: 'Dossier introuvable' } }
result.exists = true
if (!stat.isDirectory()) return { ...result, error: "Ce chemin n'est pas un dossier" }
result.isDirectory = true
// Test d'écriture réel
const probe = path.join(result.resolved, tempName('write'))
try {
fs.writeFileSync(probe, '')
result.writable = true
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
result.error = code === 'EACCES' || code === 'EPERM'
? `Écriture impossible : permissions insuffisantes (${code})`
: `Écriture impossible${code ? ` (${code})` : ''}`
} finally {
removeQuietly(probe)
}
return result
}
export function pathRelation(a: string, b: string): PathRelation {
const norm = (p: string) => {
const r = path.resolve(p).replace(/[\\/]+$/, '')
return process.platform === 'win32' ? r.toLowerCase() : r
}
const na = norm(a), nb = norm(b)
if (na === nb) return 'same'
if (na.startsWith(nb + path.sep) || nb.startsWith(na + path.sep)) return 'nested'
return 'distinct'
}
function hardlinkMessage(code: string | undefined, err: unknown): string {
switch (code) {
case 'EXDEV':
return 'Volumes différents : hardlink impossible. Passez en mode Copier ou, sous Docker, montez un seul volume parent contenant téléchargements et médiathèque.'
case 'EPERM':
case 'ENOTSUP':
case 'ENOSYS':
return 'Ce système de fichiers ne gère pas les hardlinks (FAT/exFAT, partage réseau…).'
case 'EACCES':
return 'Permissions insuffisantes pour créer un hardlink.'
default:
return `Hardlink impossible : ${err instanceof Error ? err.message : String(err)}`
}
}
export function testHardlink(srcDir: string, destDir: string): HardlinkCheck {
const name = tempName('linktest')
const src = path.join(srcDir, name)
const dest = path.join(destDir, `${name}.link`)
try {
fs.writeFileSync(src, '')
fs.linkSync(src, dest)
return { tested: true, ok: true, message: 'Hardlink possible entre les deux dossiers' }
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
return { tested: true, ok: false, code, message: hardlinkMessage(code, err) }
} finally {
removeQuietly(dest)
removeQuietly(src)
}
}
export function checkPaths(mediaPath: string, completePath?: string) {
const media = checkDirectory(mediaPath, { allowRoot: false })
const hasComplete = !!completePath?.trim()
const complete = hasComplete ? checkDirectory(completePath!) : null
const valid = (c: PathCheck | null) => !!c && c.writable && !c.error
const relation = hasComplete && media.resolved && complete?.resolved
? pathRelation(media.resolved, complete.resolved)
: null
let hardlink: HardlinkCheck
if (!hasComplete) {
hardlink = { tested: false, ok: false, message: 'Renseignez le dossier des téléchargements pour tester le hardlink' }
} else if (!valid(media) || !valid(complete)) {
hardlink = { tested: false, ok: false, message: 'Test impossible tant que les dossiers ne sont pas valides' }
} else if (relation === 'same') {
hardlink = { tested: false, ok: false, message: 'Les deux dossiers sont identiques' }
} else {
hardlink = testHardlink(complete!.resolved, media.resolved)
}
return {
ok: valid(media) && (!hasComplete || valid(complete)) && relation !== 'same',
mediaPath : media,
completePath: complete,
relation,
hardlink,
}
}
+291
View File
@@ -0,0 +1,291 @@
/** Deluge via l'API JSON-RPC de l'interface web. Progression en pourcentage (0–100). */
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
// ─── Sessions ──────────────────────────────────────────────────────────────────
interface SessionEntry { cookie: string; expires: number }
const _sessions = new Map<string, SessionEntry>()
function sessionKey(config: ClientConfig): string {
return `${config.url}::${config.password}`
}
// ─── HTTP ──────────────────────────────────────────────────────────────────────
function delugeUrl(config: ClientConfig): string {
return String(config.url ?? '').replace(/\/+$/, '') + '/json'
}
function parseCookie(raw: string): string {
// "_session_id=abc123; Path=/; HttpOnly" devient "_session_id=abc123"
return raw.split(';')[0].trim()
}
async function delugeRPC(
config : ClientConfig,
url : string,
method : string,
params : unknown[],
cookie : string,
): Promise<{ result: any; newCookie: string | null }> {
const res = await clientFetch(config, url, {
method : 'POST',
headers: {
'Content-Type': 'application/json',
'Accept' : 'application/json',
...(cookie ? { Cookie: cookie } : {}),
},
body: JSON.stringify({ method, params, id: 1 }),
})
if (!res.ok) throw new Error(`Deluge a répondu HTTP ${res.status} : vérifiez l'URL de l'interface web`)
const json = await res.json()
if (json.error) {
throw new Error(`Erreur Deluge : ${json.error.message ?? `code ${json.error.code ?? 'inconnu'}`}`)
}
const setCookie = res.headers.get('set-cookie')
return {
result : json.result,
newCookie: setCookie ? parseCookie(setCookie) : null,
}
}
// ─── Connexion ─────────────────────────────────────────────────────────────────
async function delugeLogin(config: ClientConfig): Promise<string> {
const url = delugeUrl(config)
const res = await clientFetch(config, url, {
method : 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body : JSON.stringify({ method: 'auth.login', params: [String(config.password ?? '')], id: 1 }),
})
if (!res.ok) throw new Error(`Deluge a répondu HTTP ${res.status} : vérifiez l'URL de l'interface web`)
const json = await res.json()
if (json.error) throw new Error(`Connexion à Deluge refusée : ${json.error.message ?? `code ${json.error.code ?? 'inconnu'}`}`)
if (!json.result) throw new Error('Connexion à Deluge refusée : mot de passe incorrect ?')
const setCookie = res.headers.get('set-cookie')
if (!setCookie) throw new Error('Échec de la connexion à Deluge : aucun cookie de session reçu')
return parseCookie(setCookie)
}
// ─── Appel avec gestion de session ────────────────────────────────────────────
async function delugeCall(
config : ClientConfig,
method : string,
params : unknown[] = [],
): Promise<any> {
const url = delugeUrl(config)
const key = sessionKey(config)
let entry = _sessions.get(key)
if (!entry || Date.now() >= entry.expires) {
const cookie = await delugeLogin(config)
entry = { cookie, expires: Date.now() + 30 * 60_000 }
_sessions.set(key, entry)
}
try {
const { result, newCookie } = await delugeRPC(config, url, method, params, entry.cookie)
if (newCookie) {
entry.cookie = newCookie
entry.expires = Date.now() + 30 * 60_000
}
return result
} catch (err) {
const msg = err instanceof Error ? err.message : ''
if (msg.includes('Not authorized') || msg.includes('auth') || msg.includes('401')) {
_sessions.delete(key)
const cookie = await delugeLogin(config)
const fresh = { cookie, expires: Date.now() + 30 * 60_000 }
_sessions.set(key, fresh)
const { result } = await delugeRPC(config, url, method, params, cookie)
return result
}
throw err
}
}
// ─── États ─────────────────────────────────────────────────────────────────────
function mapState(state: string): TorrentInfo['state'] {
switch (state) {
case 'Downloading':
case 'Queued' :
case 'Allocating' :
case 'Moving' : return 'downloading'
case 'Seeding' : return 'seeding'
case 'Paused' : return 'paused'
case 'Checking' : return 'checking'
case 'Error' : return 'error'
default : return 'unknown'
}
}
// ─── Aplatissement de l'arborescence de fichiers Deluge ────────────────────────
// web.get_torrent_files retourne :
// { contents: { "nom": { type: "file"|"dir", index, progress, priority, contents? } } }
function flattenDelugeFiles(
contents: Record<string, any>,
prefix = '',
): TorrentFileProgress[] {
const result: TorrentFileProgress[] = []
for (const [name, node] of Object.entries(contents ?? {})) {
const fullPath = prefix ? `${prefix}/${name}` : name
if (node.type === 'dir') {
result.push(...flattenDelugeFiles(node.contents ?? {}, fullPath))
} else {
result.push({
index : typeof node.index === 'number' ? node.index : 0,
name : fullPath,
progress: typeof node.progress === 'number' ? node.progress : 0,
priority: typeof node.priority === 'number' ? node.priority : 1,
})
}
}
return result
}
// ─── Sélection de fichier (en arrière-plan) ──────────────────────────────────
async function applyFilePriority(
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise(r => setTimeout(r, 1000))
try {
const status = await delugeCall(config, 'core.get_torrent_status', [hash, ['num_files', 'has_metadata']])
const numFiles = typeof status?.num_files === 'number' ? status.num_files : 0
if (numFiles === 0 || !status?.has_metadata) continue
const priorities = Array.from({ length: numFiles }, (_, i) => i === fileIndex ? 1 : 0)
await delugeCall(config, 'core.set_torrent_options', [[hash], { file_priorities: priorities }])
logger.info('deluge', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
logger.warn('deluge', `Délai dépassé : priorité de fichier non appliquée pour ${hash.slice(0, 8)}…`)
}
// ─── Client ───────────────────────────────────────────────────────────────────
const delugeDriver: TorrentClientDriver = {
definition: {
id : 'deluge',
label : 'Deluge',
fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8112', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie (plugin Label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
async test(config) {
try {
_sessions.delete(sessionKey(config))
const cookie = await delugeLogin(config)
_sessions.set(sessionKey(config), { cookie, expires: Date.now() + 30 * 60_000 })
logger.info('deluge', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('deluge', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const version = await delugeCall(config, 'daemon.get_version')
return { online: true, version: String(version ?? 'inconnue') }
} catch {
return { online: false }
}
},
async list(config, category?) {
const keys = [
'name', 'state', 'progress', 'total_size', 'total_done',
'total_uploaded', 'ratio', 'download_payload_rate', 'upload_payload_rate',
'eta', 'save_path', 'label',
]
// Tous les torrents, filtrés par label ici pour ne pas dépendre du plugin Label
const raw: Record<string, any> = await delugeCall(config, 'core.get_torrents_status', [{}, keys]) ?? {}
return Object.entries(raw)
.filter(([_, t]) => !category || (t.label ?? '') === category)
.map(([hash, t]): TorrentInfo => ({
hash : hash.toLowerCase(),
name : String(t.name ?? ''),
state : mapState(String(t.state ?? '')),
progress : Math.round(t.progress ?? 0), // déjà en %
size : typeof t.total_size === 'number' ? t.total_size : 0,
downloaded: typeof t.total_done === 'number' ? t.total_done : 0,
uploaded : typeof t.total_uploaded === 'number' ? t.total_uploaded : 0,
ratio : Math.round((t.ratio ?? 0) * 100) / 100,
speed : typeof t.download_payload_rate === 'number' ? t.download_payload_rate : 0,
upspeed : typeof t.upload_payload_rate === 'number' ? t.upload_payload_rate : 0,
eta : typeof t.eta === 'number' && t.eta >= 0 ? t.eta : -1,
save_path : String(t.save_path ?? ''),
category : String(t.label ?? ''),
}))
},
async add(config, url, options?: DownloadOptions) {
const addOpts: Record<string, unknown> = {}
if (config.savePath) addOpts['download_location'] = String(config.savePath)
let hash: string | null = null
if (url.startsWith('magnet:')) {
hash = await delugeCall(config, 'core.add_torrent_magnet', [url, addOpts])
logger.info('deluge', `Magnet ajouté (hash ${hash?.slice(0, 8) ?? '?'}…)`)
} else {
const torrentRes = await fetch(url)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status})`)
const buf = await torrentRes.arrayBuffer()
const b64 = Buffer.from(buf).toString('base64')
const filename = url.split('/').pop()?.replace(/\?.*$/, '') ?? 'torrent.torrent'
hash = await delugeCall(config, 'core.add_torrent_file', [filename, b64, addOpts])
logger.info('deluge', `Fichier .torrent envoyé (hash ${hash?.slice(0, 8) ?? '?'}…)`)
}
// Plugin Label facultatif : erreurs ignorées
if (config.category && hash) {
delugeCall(config, 'label.set_torrent', [hash, String(config.category)]).catch(() => {})
}
if (options?.file_index != null && hash) {
applyFilePriority(config, hash, options.file_index).catch(() => {})
}
},
async remove(config, hash, deleteFiles = false) {
await delugeCall(config, 'core.remove_torrent', [hash, deleteFiles])
logger.info('deluge', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
async getFiles(config, hash) {
const data = await delugeCall(config, 'web.get_torrent_files', [hash])
if (!data?.contents) return []
return flattenDelugeFiles(data.contents)
},
}
export default delugeDriver
+156 -67
View File
@@ -1,43 +1,45 @@
/**
* Torrent Client Registry
* =======================
* Interface commune + registry de tous les clients disponibles.
*/
import { randomUUID } from 'crypto'
import fs from 'fs'
import path from 'path'
import { Agent, fetch as undiciFetch } from 'undici'
import { DATA_DIR } from '../config.js'
import { logger } from '../logger.js'
// ─── Types ────────────────────────────────────────────────────────────────────
/** Config d'un client torrent (les champs `boolean` sont stockés en booléens). */
export type ClientConfig = Record<string, string | number | boolean>
export interface FieldDef {
key : string
label : string
type : 'text' | 'password' | 'number' | 'url'
type : 'text' | 'password' | 'number' | 'url' | 'boolean'
placeholder?: string
required : boolean
default? : string | number
default? : string | number | boolean
}
export interface TorrentClientDefinition {
id : string
label : string
fields : FieldDef[]
id : string
label : string
fields : FieldDef[]
/** Clients sans catégories : dispatchList ne garde que les torrents ajoutés par FanKarr. */
filterByManagedHashes?: boolean
}
export interface SavedClient {
uuid : string
name : string
type : string
config: Record<string, string | number>
config: ClientConfig
}
export interface TorrentFileProgress {
index : number
name ?: string // chemin relatif dans le torrent (ex: "Pack S1/Episode1.mkv") — optionnel selon le client
name ?: string // chemin relatif dans le torrent (ex. "Pack S1/Episode1.mkv"), selon le client
progress: number // 0–1
priority: number // 0 = non téléchargé, 1 = normale, 6 = haute, 7 = maximale
}
export interface TorrentInfo {
@@ -60,27 +62,62 @@ export interface TorrentInfo {
export interface DownloadOptions {
file_index?: number | null
file_path ?: string | null
infohash ?: string | null // hash direct depuis les données API (plus fiable que parser le magnet)
magnet ?: string | null // fallback pour extraire le hash si infohash absent
infohash ?: string | null // hash issu du catalogue (plus fiable que le magnet)
magnet ?: string | null
}
export interface TorrentClientDriver {
definition : TorrentClientDefinition
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
add : (config: Record<string, string | number>, url: string, options?: DownloadOptions) => Promise<void>
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
getFiles ?: (config: Record<string, string | number>, hash: string) => Promise<TorrentFileProgress[]>
test : (config: ClientConfig) => Promise<{ ok: boolean; message: string }>
healthcheck : (config: ClientConfig) => Promise<{ online: boolean; version?: string }>
add : (config: ClientConfig, url: string, options?: DownloadOptions) => Promise<void>
list : (config: ClientConfig, category?: string) => Promise<TorrentInfo[]>
remove : (config: ClientConfig, hash: string, deleteFiles?: boolean) => Promise<void>
getFiles ?: (config: ClientConfig, hash: string) => Promise<TorrentFileProgress[]>
}
// ─── Registry ─────────────────────────────────────────────────────────────────
// ─── Certificats TLS ──────────────────────────────────────────────────────────
// ignoreCertificateErrors : Agent undici sous Node, option tls du fetch sous Bun
let _insecureAgent: Agent | null = null
function insecureAgent(): Agent {
if (!_insecureAgent) _insecureAgent = new Agent({ connect: { rejectUnauthorized: false } })
return _insecureAgent
}
export function ignoreCertificateErrors(config: ClientConfig): boolean {
const v = config.ignoreCertificateErrors
return v === true || v === 'true' || v === 1
}
// Option tls propre à Bun, absente des types standards
interface BunRequestInit extends RequestInit {
tls?: { rejectUnauthorized?: boolean }
}
const isBun = 'bun' in process.versions
export function clientFetch(
config : ClientConfig,
url : string,
init : RequestInit = {},
): Promise<Response> {
if (!ignoreCertificateErrors(config)) return fetch(url, init)
if (isBun) {
const bunInit: BunRequestInit = { ...init, tls: { rejectUnauthorized: false } }
return fetch(url, bunInit)
}
return undiciFetch(url, { ...init, dispatcher: insecureAgent() } as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>
}
// ─── Registre ─────────────────────────────────────────────────────────────────
const drivers = new Map<string, TorrentClientDriver>()
export function registerDriver(driver: TorrentClientDriver) {
drivers.set(driver.definition.id, driver)
logger.debug('torrent-clients', `Driver enregistré : ${driver.definition.label}`)
logger.debug('torrent-clients', `Type de client enregistré : ${driver.definition.label}`)
}
export function getDriver(type: string): TorrentClientDriver | undefined {
@@ -91,9 +128,59 @@ export function getAvailableClients(): TorrentClientDefinition[] {
return [...drivers.values()].map(d => d.definition)
}
// ─── Persistence ──────────────────────────────────────────────────────────────
// ─── Stockage ─────────────────────────────────────────────────────────────────
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
// ─── Torrents suivis ──────────────────────────────────────────────────────────
// Hashes ajoutés par FanKarr, par client : filtre les clients sans catégories (Synology)
const MANAGED_PATH = path.join(DATA_DIR, 'managed_tasks.json')
function loadManagedMap(): Record<string, string[]> {
try {
if (!fs.existsSync(MANAGED_PATH)) return {}
return JSON.parse(fs.readFileSync(MANAGED_PATH, 'utf-8'))
} catch { return {} }
}
function saveManagedMap(data: Record<string, string[]>) {
try {
fs.mkdirSync(path.dirname(MANAGED_PATH), { recursive: true })
fs.writeFileSync(MANAGED_PATH, JSON.stringify(data, null, 2), 'utf-8')
} catch {}
}
function getManagedSet(clientUuid: string): Set<string> | null {
const list = loadManagedMap()[clientUuid]
if (!list || list.length === 0) return null
return new Set(list)
}
export function trackManagedHash(clientUuid: string, infohash: string) {
const data = loadManagedMap()
if (!data[clientUuid]) data[clientUuid] = []
const h = infohash.toLowerCase()
if (!data[clientUuid].includes(h)) {
data[clientUuid].push(h)
saveManagedMap(data)
}
}
export function untrackManagedHash(clientUuid: string, infohash: string) {
const data = loadManagedMap()
if (!data[clientUuid]) return
data[clientUuid] = data[clientUuid].filter(h => h !== infohash.toLowerCase())
if (data[clientUuid].length === 0) delete data[clientUuid]
saveManagedMap(data)
}
function clearManagedHashes(clientUuid: string) {
const data = loadManagedMap()
if (!data[clientUuid]) return
delete data[clientUuid]
saveManagedMap(data)
}
function loadClients(): SavedClient[] {
try {
@@ -113,12 +200,12 @@ export function listClients(): SavedClient[] {
return loadClients()
}
export function addClient(name: string, type: string, config: Record<string, string | number>): SavedClient {
export function addClient(name: string, type: string, config: ClientConfig): SavedClient {
const clients = loadClients()
const client: SavedClient = { uuid: randomUUID(), name, type, config }
clients.push(client)
saveClients(clients)
logger.info('torrent-clients', `Client ajouté : "${name}" (${type})`)
logger.info('torrent-clients', `Client ajouté : « ${name} » (${type})`)
return client
}
@@ -128,7 +215,8 @@ export function removeClient(uuid: string): boolean {
const filtered = clients.filter(c => c.uuid !== uuid)
if (filtered.length === clients.length) return false
saveClients(filtered)
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
clearManagedHashes(uuid)
logger.info('torrent-clients', `Client supprimé : « ${target?.name} » (${target?.type})`)
return true
}
@@ -136,13 +224,13 @@ export function getClient(uuid: string): SavedClient | undefined {
return loadClients().find(c => c.uuid === uuid)
}
export function updateClient(uuid: string, name: string, type: string, config: Record<string, string | number>): SavedClient | null {
export function updateClient(uuid: string, name: string, type: string, config: ClientConfig): SavedClient | null {
const clients = loadClients()
const index = clients.findIndex(c => c.uuid === uuid)
if (index === -1) return null
clients[index] = { ...clients[index], name, type, config }
saveClients(clients)
logger.info('torrent-clients', `Client modifié : "${name}" (${type})`)
logger.info('torrent-clients', `Client modifié : « ${name} » (${type})`)
return clients[index]
}
@@ -158,8 +246,8 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
return { ...client, config }
}
// ─── Backoff par client ───────────────────────────────────────────────────────
// Si un client échoue à l'auth, on le met en cooldown pour éviter le ban IP
// ─── Pause après échecs de connexion ──────────────────────────────────────────
// Client refusant l'authentification mis en pause pour éviter un bannissement d'IP
interface BackoffEntry {
failCount : number
@@ -168,7 +256,6 @@ interface BackoffEntry {
const _backoff = new Map<string, BackoffEntry>()
// Délais progressifs : 1min, 5min, 15min, 30min, 60min (plafonné)
const BACKOFF_DELAYS = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
function isInCooldown(uuid: string): boolean {
@@ -182,7 +269,7 @@ function cooldownRemaining(uuid: string): string {
if (!entry) return ''
const remaining = Math.max(0, entry.nextRetry - Date.now())
const m = Math.ceil(remaining / 60_000)
return m > 0 ? `${m}min` : ''
return m > 0 ? `${m} min` : ''
}
function registerFailure(uuid: string, clientName: string) {
@@ -192,14 +279,14 @@ function registerFailure(uuid: string, clientName: string) {
entry.nextRetry = Date.now() + delay
_backoff.set(uuid, entry)
const min = Math.round(delay / 60_000)
logger.warn('torrent-clients', `Client "${clientName}" en cooldown ${min}min après ${entry.failCount} échec(s)`)
logger.warn('torrent-clients', `Client « ${clientName} » en pause ${min} min après ${entry.failCount} échec(s) de connexion`)
}
function registerSuccess(uuid: string) {
_backoff.delete(uuid)
}
// Exposé pour l'API de status
// Exposé pour l'API d'état
export function getClientCooldowns(): Record<string, { failCount: number; nextRetry: number }> {
const result: Record<string, { failCount: number; nextRetry: number }> = {}
for (const [uuid, entry] of _backoff.entries()) {
@@ -208,11 +295,9 @@ export function getClientCooldowns(): Record<string, { failCount: number; nextRe
return result
}
// ─── Remote path remapping ────────────────────────────────────────────────────
// ─── Correspondance des chemins ───────────────────────────────────────────────
// FIX : remplace le préfixe remotePath par localPath dans save_path
// ex: remotePath=/downloads, localPath=/mnt/nas/downloads
// /downloads/fankai/Serie → /mnt/nas/downloads/fankai/Serie
// Remplace le préfixe remotePath par localPath (ex. /downloads/x devient /mnt/nas/downloads/x)
function remapSavePath(savePath: string, remotePath: string, localPath: string): string {
const remote = remotePath.replace(/\/+$/, '')
const local = localPath.replace(/\/+$/, '')
@@ -221,13 +306,13 @@ function remapSavePath(savePath: string, remotePath: string, localPath: string):
return local + savePath.slice(remote.length)
}
// ─── Dispatch download ─────────────────────────────────────────────────────────
// ─── Envoi d'un téléchargement ────────────────────────────────────────────────
export async function dispatchDownload(url: string, options?: DownloadOptions): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
const clients = loadClients()
if (clients.length === 0) {
logger.warn('torrent-clients', 'Aucun client configuré — téléchargement impossible')
logger.warn('torrent-clients', 'Aucun client torrent configuré : téléchargement impossible')
return []
}
@@ -236,17 +321,21 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver) {
logger.error('torrent-clients', `Driver "${client.type}" introuvable pour le client "${client.name}"`)
results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Driver "${client.type}" introuvable` })
logger.error('torrent-clients', `Type de client « ${client.type} » non pris en charge (client « ${client.name} »)`)
results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Type de client « ${client.type} » non pris en charge` })
continue
}
try {
await driver.add(client.config, url, options)
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
if (options?.infohash) {
trackManagedHash(client.uuid, options.infohash)
logger.debug('torrent-clients', `Torrent ${options.infohash.slice(0, 8)}… suivi pour « ${client.name} »`)
}
logger.info('torrent-clients', `Téléchargement envoyé à « ${client.name} »`)
results.push({ uuid: client.uuid, name: client.name, ok: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('torrent-clients', `Échec envoi vers "${client.name}" : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('torrent-clients', `Échec de l'envoi à « ${client.name} » : ${msg}`)
results.push({ uuid: client.uuid, name: client.name, ok: false, error: msg })
}
}
@@ -254,7 +343,7 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
return results
}
// ─── Dispatch remove ───────────────────────────────────────────────────────────
// ─── Suppression ──────────────────────────────────────────────────────────────
export async function dispatchRemove(hash: string, deleteFiles = false): Promise<{ ok: boolean; error?: string }> {
const clients = loadClients()
@@ -264,17 +353,18 @@ export async function dispatchRemove(hash: string, deleteFiles = false): Promise
if (!driver) continue
try {
await driver.remove(client.config, hash, deleteFiles)
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de « ${client.name} »${deleteFiles ? ' (avec fichiers)' : ''}`)
untrackManagedHash(client.uuid, hash)
found = true
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('torrent-clients', `Impossible de supprimer le torrent de "${client.name}" : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('torrent-clients', `Impossible de supprimer le torrent de « ${client.name} » : ${msg}`)
}
}
return found ? { ok: true } : { ok: false, error: 'Torrent introuvable dans les clients' }
return found ? { ok: true } : { ok: false, error: 'Torrent introuvable dans les clients torrent' }
}
// Map inversée : titre normalisé → infohash (pour matcher les torrents Synology)
// Titre normalisé vers infohash, pour retrouver les torrents Synology
function buildTitleIndex(infohashMap: Record<string, string>): Map<string, string> {
const index = new Map<string, string>()
for (const [hash, title] of Object.entries(infohashMap)) {
@@ -297,7 +387,7 @@ export async function dispatchList(
if (isInCooldown(client.uuid)) {
const remaining = cooldownRemaining(client.uuid)
logger.debug('torrent-clients', `Client "${client.name}" en cooldown — retry dans ${remaining}`)
logger.debug('torrent-clients', `Client « ${client.name} » en pause, nouvel essai dans ${remaining}`)
continue
}
@@ -316,28 +406,29 @@ export async function dispatchList(
? remapSavePath(t.save_path, remotePath, localPath)
: t.save_path
// Pour Synology (hash = dbid_xxx), on essaie de résoudre le vrai infohash
// en matchant le titre du torrent dans la infohashMap
// Synology (dbid_xxx) : retrouver l'infohash par le titre
let hash = t.hash
if (titleIndex && t.hash.startsWith('dbid_')) {
const resolved = titleIndex.get(t.name.toLowerCase().trim())
if (resolved) {
logger.debug('torrent-clients', `Synology hash résolu : "${t.name}" → ${resolved.slice(0, 8)}…`)
logger.debug('torrent-clients', `Hash Synology résolu : « ${t.name} » (${resolved.slice(0, 8)}…)`)
hash = resolved
}
}
if (driver.definition.filterByManagedHashes) {
const managed = getManagedSet(client.uuid)
if (managed && !managed.has(hash)) continue
}
results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('torrent-clients', `Impossible de récupérer la liste depuis "${client.name}" : ${msg}`)
const isAuthError = msg.toLowerCase().includes('login') ||
msg.toLowerCase().includes('auth') ||
msg.toLowerCase().includes('ban') ||
msg.toLowerCase().includes('forbidden') ||
msg.toLowerCase().includes('401') ||
msg.toLowerCase().includes('403')
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('torrent-clients', `Impossible de récupérer la liste des torrents de « ${client.name} » : ${msg}`)
const lower = msg.toLowerCase()
const isAuthError = ['login', 'auth', 'ban', 'forbidden', '401', '403', 'connexion refusée', 'refusé la connexion',
'connexion à deluge refusée', 'clé api', 'échec de la connexion'].some(k => lower.includes(k))
if (isAuthError) registerFailure(client.uuid, client.name)
}
}
@@ -345,9 +436,7 @@ 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.
// ─── Progression par fichier ──────────────────────────────────────────────────
export async function dispatchGetFiles(
hash : string,
+125 -143
View File
@@ -1,10 +1,19 @@
/**
* qBittorrent Driver
*/
/** qBittorrent 4.x et 5.x (clé API à partir de 5.2.0). */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
// Réponse « Ok. », ou JSON derrière certains reverse proxies
function isQbAddSuccess(text: string): boolean {
if (text.trim() === 'Ok.') return true
try {
const json = JSON.parse(text)
if (typeof json?.success_count === 'number') return json.success_count > 0
} catch {}
return false
}
function mapState(state: string): TorrentInfo['state'] {
if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading'
if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding'
@@ -14,30 +23,38 @@ function mapState(state: string): TorrentInfo['state'] {
return 'unknown'
}
async function qbLogin(config: Record<string, string | number>): Promise<string> {
async function qbAuth(config: ClientConfig): Promise<Record<string, string>> {
if (config.apiKey) {
return { Authorization: `Bearer ${config.apiKey}` }
}
const cookie = await qbLogin(config)
return { Cookie: cookie }
}
async function qbLogin(config: ClientConfig): Promise<string> {
const form = new URLSearchParams()
form.append('username', String(config.username ?? ''))
form.append('password', String(config.password ?? ''))
const res = await fetch(`${config.url}/api/v2/auth/login`, {
const res = await clientFetch(config, `${config.url}/api/v2/auth/login`, {
method : 'POST',
body : form,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
if (!res.ok) throw new Error(`Échec de la connexion : ${text.trim() || `HTTP ${res.status}`}`)
if (text.trim() === 'Fails.') throw new Error('Identifiants incorrects (vérifiez le nom d\'utilisateur et le mot de passe)')
const cookie = res.headers.get('set-cookie') ?? ''
const sid = cookie.match(/SID=([^;]+)/)?.[1]
if (!sid) throw new Error('Cookie SID introuvable')
return sid
const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0].trim()
if (!cookie) throw new Error('Cookie de session introuvable')
return cookie
}
async function qbTorrentExists(config: Record<string, string | number>, sid: string, hash: string): Promise<boolean> {
async function qbTorrentExists(config: ClientConfig, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
try {
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, {
headers: { Cookie: `SID=${sid}` },
const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?hashes=${hash}`, {
headers: authHeaders,
})
if (!res.ok) return false
const list = await res.json()
@@ -47,99 +64,65 @@ async function qbTorrentExists(config: Record<string, string | number>, sid: str
}
}
/**
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
* applique les priorités, puis reprend le torrent.
*/
async function qbApplyFilePriority(
config : Record<string, string | number>,
config : ClientConfig,
hash : string,
fileIndex: number,
resume : boolean,
): Promise<void> {
// 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 sid = await qbLogin(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: { Cookie: `SID=${sid}` },
const authH = await qbAuth(config)
const res = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: authH,
})
if (!res.ok) continue
const files: any[] = await res.json()
if (!Array.isArray(files) || files.length === 0) continue
if (fileIndex >= files.length) {
throw new Error(`Index fichier ${fileIndex} hors limites (torrent a ${files.length} fichiers)`)
throw new Error(`Fichier n° ${fileIndex} hors limites : le torrent contient ${files.length} fichiers`)
}
// Si tous les fichiers sont à priorité normale (état initial du torrent),
// on applique le filtrage complet : on désactive tout sauf la cible.
// Si certains sont déjà à 0 (filtrage déjà effectué pour un épisode précédent),
// on se contente d'activer la nouvelle cible sans toucher aux autres.
// Premier épisode choisi : tout désactiver sauf la cible ; sinon activer seulement la cible
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
const headers = { Cookie: `SID=${sid}`, 'Content-Type': 'application/x-www-form-urlencoded' }
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
if (isInitialState) {
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
if (unwanted.length > 0) {
const body = new URLSearchParams({ hash, id: unwanted.join('|'), priority: '0' })
const r = await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers })
if (!r.ok) { logger.warn('qbittorrent', `filePrio désactivation échouée (${r.status}) — retry`); continue }
const r = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers })
if (!r.ok) { logger.warn('qbittorrent', `Priorités non appliquées (HTTP ${r.status}), nouvel essai`); continue }
}
}
// Activer le fichier cible (dans tous les cas)
const r2 = await fetch(`${config.url}/api/v2/torrents/filePrio`, {
const r2 = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, {
method: 'POST',
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
headers,
})
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
if (!r2.ok) { logger.warn('qbittorrent', `Priorité du fichier cible non appliquée (HTTP ${r2.status}), nouvel essai`); continue }
// Vérification : le fichier cible est bien à priorité > 0
const verif = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: { Cookie: `SID=${sid}` } })
const verif = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
if (verif.ok) {
const verifiedFiles: any[] = await verif.json()
const target = verifiedFiles[fileIndex]
if (!target || (target.priority ?? 0) === 0) {
logger.warn('qbittorrent', `Fichier ${fileIndex} toujours à priorité 0 après application — retry`)
logger.warn('qbittorrent', `Fichier n° ${fileIndex} toujours à priorité 0 après application, nouvel essai`)
continue
}
}
// Reprendre (torrent mis en pause via paused/stopped à l'ajout)
// On appelle les deux endpoints : resume (qBit ≤ 4.x) et start (qBit ≥ 5.0)
if (resume) {
const resumeBody = new URLSearchParams({ hashes: hash })
await Promise.allSettled([
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeBody, headers }),
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeBody, headers }),
])
}
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné${resume ? ' + reprise' : ''} pour ${hash.slice(0, 8)}…`)
logger.info('qbittorrent', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch (err) {
if (err instanceof Error && err.message.includes('hors limites')) throw err
}
}
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
if (resume) {
try {
const sid = await qbLogin(config)
const headers = { Cookie: `SID=${sid}`, 'Content-Type': 'application/x-www-form-urlencoded' }
const body = new URLSearchParams({ hashes: hash })
await Promise.allSettled([
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body, headers }),
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body, headers }),
])
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
}
const QB: TorrentClientDriver = {
@@ -147,60 +130,64 @@ const QB: TorrentClientDriver = {
id : 'qbittorrent',
label : 'qBittorrent',
fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'url', label: 'URL de la WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'apiKey', label: 'Clé API (qBittorrent 5.2 ou plus)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
async test(config) {
try {
await qbLogin(config)
const authH = await qbAuth(config)
const res = await clientFetch(config, `${config.url}/api/v2/app/version`, { headers: authH })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('qbittorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('qbittorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const sid = await qbLogin(config)
const res = await fetch(`${config.url}/api/v2/app/version`, {
headers: { Cookie: `SID=${sid}` },
const authH = await qbAuth(config)
const res = await clientFetch(config, `${config.url}/api/v2/app/version`, {
headers: authH,
})
if (!res.ok) return { online: false }
const version = (await res.text()).trim()
logger.debug('qbittorrent', `Healthcheck OK — version ${version}`)
if (version.startsWith('<')) throw new Error('Réponse HTML reçue : clé API invalide ou non prise en charge par cette version de qBittorrent')
logger.debug('qbittorrent', `Client en ligne (version ${version})`)
return { online: true, version }
} catch (err) {
logger.debug('qbittorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
logger.debug('qbittorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const sid = await qbLogin(config)
const authH = await qbAuth(config)
const params = new URLSearchParams()
if (category) params.set('category', category)
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
headers: { Cookie: `SID=${sid}` },
const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?${params}`, {
headers: authH,
})
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
if (!res.ok) throw new Error(`Liste des torrents inaccessible (HTTP ${res.status})`)
const data: any[] = await res.json()
// Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
const fileMap = new Map<string, any[]>()
await Promise.all(data.map(async (t: any) => {
try {
const r = await fetch(`${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
headers: { Cookie: `SID=${sid}` },
const r = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
headers: authH,
})
if (r.ok) fileMap.set(t.hash, await r.json())
} catch {}
@@ -227,6 +214,7 @@ const QB: TorrentClientDriver = {
index : i,
name : String(f.name ?? ''),
progress: f.progress ?? 0, // 0–1
priority: f.priority ?? 1,
}))
: undefined,
} satisfies TorrentInfo
@@ -234,9 +222,9 @@ const QB: TorrentClientDriver = {
},
async getFiles(config, hash) {
const sid = await qbLogin(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: { Cookie: `SID=${sid}` },
const authH = await qbAuth(config)
const res = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: authH,
})
if (!res.ok) return []
const data: any[] = await res.json()
@@ -244,13 +232,13 @@ const QB: TorrentClientDriver = {
index : i,
name : String(f.name ?? ''),
progress: f.progress ?? 0,
priority: f.priority ?? 1,
}))
},
async add(config, url, options?: DownloadOptions) {
const sid = await qbLogin(config)
const authH = await qbAuth(config)
// 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()
@@ -258,110 +246,104 @@ const QB: TorrentClientDriver = {
if (options?.file_index != null) {
if (hash) {
const exists = await qbTorrentExists(config, sid, hash)
const exists = await qbTorrentExists(config, authH, hash)
if (exists) {
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
qbApplyFilePriority(config, hash, options.file_index, false).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('qbittorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
}
// Snapshot des hashes avant ajout (pour retrouver le nouveau si hash inconnu)
// Hashes présents avant l'ajout, pour repérer le nouveau torrent si le hash est inconnu
let knownHashes: Set<string> = new Set()
if (!hash) {
try {
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: { Cookie: `SID=${sid}` } })
const lr = await clientFetch(config, `${config.url}/api/v2/torrents/info`, { headers: authH })
if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
} catch {}
}
// Stratégie d'ajout selon le type de lien :
// .torrent URL → FanKarr télécharge le fichier et l'uploade directement à qBit
// (qBit n'a pas forcément accès à internet / nyaa.si depuis son réseau Docker)
// magnet link → passage de l'URL brute, ajout normal sans pause
// (les métadonnées arrivent via les pairs, la pause bloquerait tout)
// .torrent récupéré par FanKarr : le client n'a pas forcément accès à Internet
const isMagnet = url.startsWith('magnet:')
const form = new FormData()
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
let res: Response
if (isMagnet) {
form.append('urls', url)
const params = new URLSearchParams()
params.set('urls', url)
if (config.category) params.set('category', String(config.category))
if (config.savePath) params.set('savepath', String(config.savePath))
res = await clientFetch(config, `${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)}`)
logger.debug('qbittorrent', `Téléchargement du fichier .torrent par FanKarr : ${url.slice(0, 120)}`)
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } })
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent (${torrentRes.status}) : ${url}`)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status}) : ${url}`)
const torrentBytes = await torrentRes.arrayBuffer()
const form = new FormData()
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent')
// Pause : métadonnées déjà dans le fichier → liste de fichiers disponible immédiatement
form.append('paused', 'true') // qBit < 5.0
form.append('stopped', 'true') // qBit ≥ 5.0
res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
}
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
if (!isQbAddSuccess(text)) throw new Error(`qBittorrent a refusé le torrent : ${text}`)
if (hash) {
logger.info('qbittorrent', `Torrent ajouté (${isMagnet ? 'magnet, actif' : 'en pause'}) — sélection fichier ${options.file_index} en attente des métadonnées`)
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.info('qbittorrent', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('qbittorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
} else {
// Hash inconnu (pas d'infohash, pas de magnet) → retrouve le nouveau torrent par diff de liste
logger.info('qbittorrent', `Torrent ajouté sans hash connu — recherche dans la liste (sélection fichier ${options.file_index})`)
logger.info('qbittorrent', `Torrent ajouté sans hash connu : recherche dans la liste (fichier n° ${options.file_index})`)
const fileIndex = options.file_index
;(async () => {
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const s = await qbLogin(config)
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: { Cookie: `SID=${s}` } })
const ah = await qbAuth(config)
const lr = await clientFetch(config, `${config.url}/api/v2/torrents/info`, { headers: ah })
if (!lr.ok) continue
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
if (!newT) continue
const resolvedHash = String(newT.hash).toLowerCase()
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
await qbApplyFilePriority(config, resolvedHash, fileIndex, true)
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}…, sélection du fichier n° ${fileIndex}`)
await qbApplyFilePriority(config, resolvedHash, fileIndex)
return
} catch {}
}
logger.warn('qbittorrent', `Impossible de résoudre le hash pour appliquer la priorité fichier ${fileIndex}`)
logger.warn('qbittorrent', `Hash introuvable : impossible de sélectionner le fichier n° ${fileIndex}`)
})().catch(() => {})
}
return
}
// Pas de sélection de fichier → ajout normal
const form = new FormData()
form.append('urls', url)
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
const params = new URLSearchParams()
params.set('urls', url)
if (config.category) params.set('category', String(config.category))
if (config.savePath) params.set('savepath', String(config.savePath))
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
const res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, {
method: 'POST', body: params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
logger.info('qbittorrent', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
if (!isQbAddSuccess(text)) throw new Error(`qBittorrent a refusé le torrent : ${text}`)
logger.info('qbittorrent', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
const sid = await qbLogin(config)
const form = new FormData()
form.append('hashes', hash)
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
const authH = await qbAuth(config)
const params = new URLSearchParams({ hashes: hash, deleteFiles: deleteFiles ? 'true' : 'false' })
const res = await clientFetch(config, `${config.url}/api/v2/torrents/delete`, {
method : 'POST',
body : form,
headers: { Cookie: `SID=${sid}` },
body : params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
if (!res.ok) throw new Error(`Échec de la suppression (HTTP ${res.status})`)
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
+19 -40
View File
@@ -1,24 +1,11 @@
/**
* 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).
*/
/** Real-Debrid : torrents téléchargés côté RD, fichiers lus depuis le dossier local monté (rclone, WebDAV…). */
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
import { logger } from '../logger.js'
const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
function rdHeaders(config: Record<string, string | number>) {
function rdHeaders(config: ClientConfig) {
return {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
@@ -26,7 +13,7 @@ function rdHeaders(config: Record<string, string | number>) {
}
async function rdFetch(
config : Record<string, string | number>,
config : ClientConfig,
method : 'GET' | 'POST' | 'DELETE',
path : string,
body ?: URLSearchParams,
@@ -37,9 +24,10 @@ async function rdFetch(
body : body,
})
if (res.status === 204) return null
if (res.status === 401 || res.status === 403) throw new Error('Clé API Real-Debrid invalide ou compte non premium')
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Real-Debrid HTTP ${res.status} : ${text}`)
const data = await res.json().catch(() => null)
throw new Error(`Real-Debrid a répondu HTTP ${res.status}${data?.error ? ` : ${data.error}` : ''}`)
}
return res.json()
}
@@ -67,18 +55,18 @@ const rdDriver: TorrentClientDriver = {
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 },
{ key: 'localPath', label: 'Dossier où Real-Debrid est monté (rclone, WebDAV…)', type: 'text', placeholder: '/mnt/real-debrid', required: false },
],
},
async test(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
logger.info('real-debrid', `Connexion réussie — utilisateur : ${user.username}`)
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}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('real-debrid', `Échec du test de connexion : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -86,7 +74,7 @@ const rdDriver: TorrentClientDriver = {
async healthcheck(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
return { online: true, version: `RD — ${user.username}` }
return { online: true, version: `Compte ${user.username}` }
} catch {
return { online: false }
}
@@ -98,13 +86,12 @@ const rdDriver: TorrentClientDriver = {
|| 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`)
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur Real-Debrid`)
if (options?.file_index != null && existing.status === 'waiting_files_selection') {
const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`)
.then((info: any) => info.files ?? [])
@@ -125,11 +112,10 @@ const rdDriver: TorrentClientDriver = {
const data = await rdFetch(config, 'POST', '/torrents/addMagnet',
new URLSearchParams({ magnet: url }))
torrentId = data.id
logger.info('real-debrid', `Magnet ajouté → ID ${torrentId}`)
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}`)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status})`)
const torrentBuf = await torrentRes.arrayBuffer()
const formData = new FormData()
formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
@@ -138,15 +124,13 @@ const rdDriver: TorrentClientDriver = {
headers: { Authorization: `Bearer ${config.apiKey}` },
body : formData,
})
if (!uploadRes.ok) throw new Error(`Ajout .torrent échoué : ${uploadRes.status}`)
if (!uploadRes.ok) throw new Error(`Échec de l'envoi du fichier .torrent (HTTP ${uploadRes.status})`)
const data = await uploadRes.json()
torrentId = data.id
logger.info('real-debrid', `.torrent uploadé → ID ${torrentId}`)
logger.info('real-debrid', `Fichier .torrent envoyé (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))
@@ -164,16 +148,13 @@ const rdDriver: TorrentClientDriver = {
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}`)
logger.info('real-debrid', `Fichier n° ${options.file_index} sélectionné pour le torrent Real-Debrid ${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 {
@@ -195,10 +176,9 @@ const rdDriver: TorrentClientDriver = {
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
// save_path virtuel : dossier monté et 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) => ({
@@ -228,7 +208,6 @@ const rdDriver: TorrentClientDriver = {
},
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`)
+38 -59
View File
@@ -1,14 +1,10 @@
/**
* rTorrent Driver
* ===============
* Communique via XML-RPC (HTTP POST).
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
*/
/** rTorrent via XML-RPC (ruTorrent ou rTorrent seul, sur /RPC2 ou /XMLRPC). */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
// ─── XML-RPC helpers ──────────────────────────────────────────
// ─── XML-RPC ──────────────────────────────────────────────────
function xmlValue(val: any): string {
if (typeof val === 'string') return `<value><string>${val}</string></value>`
@@ -55,7 +51,7 @@ function parseXmlValue(node: Element): any {
}
async function rpcCall(
config : Record<string, string | number>,
config : ClientConfig,
method : string,
params : any[] = [],
): Promise<any> {
@@ -69,22 +65,21 @@ async function rpcCall(
const rpcPath = String(config.rpcPath || '/RPC2')
const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}`
const res = await fetch(url, { method: 'POST', headers, body })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const res = await clientFetch(config, url, { method: 'POST', headers, body })
if (!res.ok) throw new Error(`rTorrent a répondu HTTP ${res.status} : vérifiez l'URL et le chemin RPC`)
const text = await res.text()
// Parse XML — Node.js n'a pas DOMParser natif, on fait un parse minimaliste
// Pas de DOMParser sous Node : analyse XML minimale
const faultMatch = text.match(/<name>faultString<\/name>\s*<value><string>([^<]*)<\/string>/)
if (faultMatch) throw new Error(`rTorrent fault : ${faultMatch[1]}`)
if (faultMatch) throw new Error(`Erreur rTorrent : ${faultMatch[1]}`)
const valueMatch = text.match(/<methodResponse>\s*<params>\s*<param>\s*<value>([\s\S]*?)<\/value>\s*<\/param>/)
if (!valueMatch) return null
// Parse simplifié pour les types courants
// Types courants uniquement
const inner = valueMatch[1].trim()
// Array
if (inner.startsWith('<array>')) {
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
return items.map(m => parseInnerValue(m[1].trim()))
@@ -107,9 +102,8 @@ function parseInnerValue(inner: string): any {
return inner
}
// multicall pour récupérer plusieurs champs en une requête
async function d_multicall(
config : Record<string, string | number>,
config : ClientConfig,
view : string,
methods: string[],
): Promise<any[][]> {
@@ -118,7 +112,7 @@ async function d_multicall(
return Array.isArray(result) ? result : []
}
async function rtTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
async function rtTorrentExists(config: ClientConfig, hash: string): Promise<boolean> {
try {
const rows = await d_multicall(config, 'main', ['d.hash'])
return rows.some(r => String(r[0]).toLowerCase() === hash.toLowerCase())
@@ -127,28 +121,20 @@ async function rtTorrentExists(config: Record<string, string | number>, hash: st
}
}
/**
* Attend les métadonnées via polling rapide (300 ms), applique les priorités.
* Le torrent est ajouté avec load.start — il fetch les métadonnées activement via DHT/peers.
* Pendant le fetch (état "métadonnées en cours"), aucun byte de fichier n'est téléchargé.
* On set les priorités dès que les fichiers sont disponibles, avant que le download réel commence.
*/
/** Attend les métadonnées puis applique les priorités, avant le début du téléchargement. */
async function rtApplyFilePriority(
config : Record<string, string | number>,
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
// 100 tentatives × 300 ms = 30 s max
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 300))
try {
// Récupérer taille ET priorité courante de chaque fichier
const files: any[] = await rpcCall(config, 'f.multicall', [hash, '', 'f.size_bytes=', 'f.priority='])
const fileCount = Array.isArray(files) ? files.length : 0
if (fileCount === 0) continue
// Si tous les fichiers sont à priorité normale (état initial),
// on filtre tout sauf la cible. Sinon on active juste la cible.
// Premier épisode choisi : tout désactiver sauf la cible ; sinon activer seulement la cible
const isInitialState = files.every((f: any) => Number(f[1]) > 0)
for (let i = 0; i < fileCount; i++) {
@@ -157,20 +143,17 @@ async function rtApplyFilePriority(
} else if (isInitialState) {
await rpcCall(config, 'f.priority.set', [hash, i, 0])
}
// Si pas initial state : on ne touche pas aux autres fichiers
}
logger.info('rtorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
logger.info('rtorrent', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
}
// ─── State mapping ────────────────────────────────────────────
// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1)
// + is_open (0/1)
// ─── États ────────────────────────────────────────────────────
function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] {
if (isChecking) return 'checking'
if (!isOpen) return 'paused'
@@ -186,23 +169,24 @@ const RT: TorrentClientDriver = {
fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'rpcPath', label: 'Chemin RPC', type: 'text', placeholder: '/RPC2', required: false, default: '/RPC2' },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false },
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie (label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
async test(config) {
try {
const version = await rpcCall(config, 'system.client_version')
logger.info('rtorrent', `Test de connexion réussi sur ${config.url} — version ${version}`)
logger.info('rtorrent', `Test de connexion réussi sur ${config.url} (version ${version})`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('rtorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('rtorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -210,10 +194,10 @@ const RT: TorrentClientDriver = {
async healthcheck(config) {
try {
const version = await rpcCall(config, 'system.client_version')
logger.debug('rtorrent', `Healthcheck OK — version ${version}`)
logger.debug('rtorrent', `Client en ligne (version ${version})`)
return { online: true, version: String(version) }
} catch (err) {
logger.debug('rtorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
logger.debug('rtorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
@@ -228,8 +212,8 @@ const RT: TorrentClientDriver = {
'd.complete',
'd.size_bytes',
'd.bytes_done',
'd.up.total', // total uploadé
'd.ratio', // ratio x1000
'd.up.total',
'd.ratio', // ratio × 1000
'd.down.rate',
'd.up.rate',
'd.left_bytes',
@@ -239,12 +223,11 @@ const RT: TorrentClientDriver = {
const rows = await d_multicall(config, 'main', methods)
// Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
const fileMap = new Map<string, any[]>()
await Promise.all(rows.map(async (r: any) => {
const hash = String(r[0]).toUpperCase()
try {
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path='])
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path=', 'f.priority='])
if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files)
} catch {}
}))
@@ -279,6 +262,7 @@ const RT: TorrentClientDriver = {
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
@@ -298,42 +282,37 @@ const RT: TorrentClientDriver = {
const exists = await rtTorrentExists(config, hash)
if (exists) {
// Torrent déjà présent → mettre à jour la priorité directement (async OK)
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('rtorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
// Nouveau torrent → load.start (fetch métadonnées via DHT/peers actif)
// Pendant le fetch des métadonnées, aucun byte de fichier n'est téléchargé.
// On poll à 300 ms et on set les priorités dès que les fichiers sont disponibles.
// Nouveau torrent : priorités appliquées dès que load.start a récupéré les métadonnées
const args: any[] = ['', url]
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
if (config.category) args.push(`d.custom1.set=${config.category}`)
await rpcCall(config, 'load.start', args)
logger.info('rtorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
logger.info('rtorrent', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('rtorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
// Pas de sélection de fichier → ajout et démarrage normal
const args: any[] = ['', url]
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
if (config.category) args.push(`d.custom1.set=${config.category}`)
await rpcCall(config, 'load.start', args)
logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
logger.info('rtorrent', `Torrent ajouté${config.savePath ? ` (dossier : ${config.savePath})` : ''}`)
},
async remove(config, hash, deleteFiles = false) {
const h = hash.toUpperCase()
if (deleteFiles) {
// Supprimer les fichiers puis effacer
await rpcCall(config, 'd.delete_tied', [h])
await rpcCall(config, 'd.erase', [h])
} else {
+289 -90
View File
@@ -1,23 +1,106 @@
/**
* Synology Download Station Driver
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
import fs from 'fs'
import path from 'path'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
// ─── Cache URI et infohash ─────────────────────────────────────────────────────
// DSM anciens : pas de hash BT dans l'API, on mémorise l'URI et son infohash à l'ajout
const URI_HASH_PATH = path.join(DATA_DIR, 'synology_uri_hash.json')
// ─── Cache hash BT et ID Synology (en mémoire) ────────────────────────────────
// Hash BT vers dbid_X, pour que remove() et getFiles() retrouvent la tâche
const _hashToSynoId = new Map<string, string>()
function loadUriHashMap(): Record<string, string> {
try {
if (fs.existsSync(URI_HASH_PATH)) return JSON.parse(fs.readFileSync(URI_HASH_PATH, 'utf-8'))
} catch {}
return {}
}
const _uriToHash: Map<string, string> = (() => {
const m = new Map<string, string>()
for (const [k, v] of Object.entries(loadUriHashMap())) m.set(k, v)
return m
})()
function storeUriHash(uri: string, hash: string) {
const key = uri.toLowerCase()
if (_uriToHash.get(key) === hash) return
_uriToHash.set(key, hash)
try {
const map = loadUriHashMap()
map[key] = hash
fs.mkdirSync(path.dirname(URI_HASH_PATH), { recursive: true })
fs.writeFileSync(URI_HASH_PATH, JSON.stringify(map, null, 2), 'utf-8')
} catch {}
}
function resolveHash(detailHash: string, detailUri: string, taskId: string): string {
// 1. Hash direct depuis l'API Synology (DSM récent)
if (detailHash.length > 0) return detailHash.toLowerCase()
// 2. Hash extrait du magnet de detail.uri
if (detailUri.startsWith('magnet:')) {
const m = detailUri.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
if (m) return m[1].toLowerCase()
}
// 3. Cache URI mémorisé à l'ajout
if (detailUri) {
const cached = _uriToHash.get(detailUri.toLowerCase())
if (cached) return cached
}
// 4. À défaut : ID Synology natif (dbid_X)
logger.debug('synology-ds', `Hash BT non résolu, ID Synology utilisé : ${taskId}`)
return String(taskId ?? '').toLowerCase()
}
// ─── États ─────────────────────────────────────────────────────────────────────
// Synology Download Station task status codes
function mapState(status: string): TorrentInfo['state'] {
if (status === 'downloading') return 'downloading'
if (status === 'seeding' || status === 'finished') return 'seeding'
if (status === 'paused') return 'paused'
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
if (status === 'hash_checking') return 'checking'
if (status === 'error') return 'error'
if (status === 'downloading') return 'downloading'
if (status === 'seeding' || status === 'finished') return 'seeding'
if (status === 'finishing' || status === 'extracting') return 'seeding'
if (status === 'paused') return 'paused'
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
if (status === 'hash_checking') return 'checking'
if (status === 'error') return 'error'
return 'unknown'
}
// Authentification DSM — retourne un SID
async function dsLogin(config: Record<string, string | number>): Promise<string> {
// ─── Authentification ──────────────────────────────────────────────────────────
const AUTH_ERRORS: Record<number, string> = {
400: 'nom d\'utilisateur ou mot de passe incorrect',
401: 'compte désactivé',
402: 'accès refusé',
403: 'code de validation en deux étapes requis',
404: 'code de validation en deux étapes refusé',
}
const TASK_ERRORS: Record<number, string> = {
105: 'accès refusé',
106: 'session expirée',
400: 'envoi du fichier refusé',
401: 'nombre maximal de tâches atteint',
402: 'accès au dossier de destination refusé',
403: 'dossier de destination introuvable',
406: 'aucun dossier de destination par défaut',
408: 'fichier introuvable',
}
function dsErrorText(error: any, table: Record<number, string>): string {
const code = error?.code
return table[code] ? `${table[code]} (code ${code})` : `code ${code ?? 'inconnu'}`
}
async function dsLogin(config: ClientConfig): Promise<string> {
const params = new URLSearchParams({
api : 'SYNO.API.Auth',
version: '3',
@@ -27,16 +110,15 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
session: 'DownloadStation',
format : 'sid',
})
const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const res = await clientFetch(config, `${config.url}/webapi/auth.cgi?${params}`)
if (!res.ok) throw new Error(`Synology a répondu HTTP ${res.status} : vérifiez l'URL de DSM`)
const data = await res.json()
if (!data.success) throw new Error(`Auth échouée : ${JSON.stringify(data.error)}`)
if (!data.success) throw new Error(`Connexion refusée par Synology : ${dsErrorText(data.error, AUTH_ERRORS)}`)
return data.data.sid
}
async function dsRequest(
config : Record<string, string | number>,
config : ClientConfig,
api : string,
method : string,
version: string,
@@ -44,25 +126,29 @@ async function dsRequest(
sid : string,
): Promise<any> {
const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra })
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`Download Station a répondu HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`)
if (!data.success) throw new Error(`Erreur Download Station : ${dsErrorText(data.error, TASK_ERRORS)}`)
return data.data
}
// ─── Client ────────────────────────────────────────────────────────────────────
const DS: TorrentClientDriver = {
definition: {
id : 'synology-ds',
label : 'Synology Download Station',
fields: [
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
id : 'synology-ds',
label : 'Synology Download Station',
filterByManagedHashes: true,
fields : [
{ key: 'url', label: 'URL de DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/volume1/downloads', required: false },
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -72,8 +158,8 @@ const DS: TorrentClientDriver = {
logger.info('synology-ds', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('synology-ds', `Test de connexion échoué sur ${config.url} : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('synology-ds', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -87,15 +173,13 @@ const DS: TorrentClientDriver = {
method : 'getinfo',
_sid : sid,
})
const res = await fetch(`${config.url}/webapi/DownloadStation/info.cgi?${params}`)
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/info.cgi?${params}`)
if (!res.ok) return { online: false }
const data = await res.json()
if (!data.success) return { online: false }
const version = data.data?.version_string ?? 'inconnue'
logger.debug('synology-ds', `Healthcheck OK — version ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('synology-ds', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
} catch {
return { online: false }
}
},
@@ -103,76 +187,191 @@ const DS: TorrentClientDriver = {
async list(config, category) {
const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
{ additional: 'transfer,detail' }, sid)
{ additional: 'transfer,detail,file' }, sid)
const tasks: any[] = data?.tasks ?? []
return tasks
.map(t => {
const transfer = t.additional?.transfer ?? {}
const size = t.size ?? 0
const dl = transfer.size_downloaded ?? 0
return {
hash : t.id,
name : t.title,
state : mapState(t.status),
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
size,
downloaded: dl,
uploaded : transfer.size_uploaded ?? 0,
ratio : size > 0 ? Math.round(((transfer.size_uploaded ?? 0) / size) * 100) / 100 : 0,
speed : transfer.speed_download ?? 0,
upspeed : transfer.speed_upload ?? 0,
eta : -1,
save_path : t.additional?.detail?.destination ?? '',
category : category ?? '',
} satisfies TorrentInfo
// Pas de catégories chez Synology : filtre sur le dossier de téléchargement s'il est renseigné
const normalizedSavePath = String(config.savePath ?? '').replace(/\/+$/, '').toLowerCase()
const filtered = normalizedSavePath
? tasks.filter(t => {
const dest = String(t.additional?.detail?.destination ?? '').replace(/\/+$/, '').toLowerCase()
return dest === normalizedSavePath || dest.startsWith(normalizedSavePath + '/')
})
: tasks
return filtered.map(t => {
const transfer = t.additional?.transfer ?? {}
const detail = t.additional?.detail ?? {}
const fileList: any[] = t.additional?.file ?? []
const size = t.size ?? 0
const dl = transfer.size_downloaded ?? 0
const hash = resolveHash(
String(detail.hash ?? '').trim(),
String(detail.uri ?? '').trim(),
String(t.id ?? ''),
)
const synoIdStr = String(t.id ?? '').toLowerCase()
if (!hash.startsWith('dbid_') && synoIdStr) {
_hashToSynoId.set(hash, synoIdStr)
}
const files = fileList.length > 0
? fileList.map((f: any, i: number) => {
const fSize = typeof f.size === 'number' ? f.size : 0
const fDl = typeof f.size_downloaded === 'number' ? f.size_downloaded : 0
return {
index : i,
name : String(f.filename ?? ''),
progress: fSize > 0 ? fDl / fSize : 0,
priority: String(f.priority ?? 'normal') === 'skip' ? 0 : 1,
}
})
: undefined
return {
hash,
name : t.title,
state : mapState(t.status),
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
size,
downloaded: dl,
uploaded : transfer.size_uploaded ?? 0,
ratio : size > 0 ? Math.round(((transfer.size_uploaded ?? 0) / size) * 100) / 100 : 0,
speed : transfer.speed_download ?? 0,
upspeed : transfer.speed_upload ?? 0,
eta : -1,
save_path : detail.destination ?? '',
category : category ?? '',
files,
} satisfies TorrentInfo
})
},
async add(config, url, options?: DownloadOptions) {
if (options?.file_index != null) logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
if (options?.file_index != null)
logger.warn('synology-ds', 'Download Station ne permet pas de choisir un fichier : torrent complet téléchargé')
if (options?.infohash) {
storeUriHash(url, options.infohash)
logger.debug('synology-ds', `Hash mémorisé pour l'URI : ${options.infohash.slice(0, 8)}… (${url.slice(-50)})`)
}
const sid = await dsLogin(config)
const extra: Record<string, string> = { uri: url }
if (config.savePath) extra.destination = String(config.savePath)
// IDs existants avant l'ajout, pour repérer la nouvelle tâche
const beforeIds = new Set<string>()
try {
const before = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
for (const t of before?.tasks ?? []) beforeIds.add(String(t.id))
} catch {}
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'create',
_sid : sid,
...extra,
})
const doAdd = async (withDestination: boolean) => {
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'create',
_sid : sid,
uri : url,
})
if (withDestination && config.savePath) params.append('destination', String(config.savePath))
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`Download Station a répondu HTTP ${res.status}`)
return res.json()
}
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
let data = await doAdd(true)
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
// Code 403 : dossier introuvable sur le NAS, nouvel essai sans destination
if (!data.success && data.error?.code === 403 && config.savePath) {
logger.warn('synology-ds', `Dossier « ${config.savePath} » introuvable sur le NAS (code 403) : ajout dans le dossier par défaut`)
data = await doAdd(false)
}
if (!data.success) throw new Error(`Download Station a refusé le torrent : ${dsErrorText(data.error, TASK_ERRORS)}`)
if (options?.infohash) {
try {
const after = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
const newTask = (after?.tasks ?? []).find((t: any) => !beforeIds.has(String(t.id)))
if (newTask) {
const synoId = String(newTask.id).toLowerCase()
_hashToSynoId.set(options.infohash.toLowerCase(), synoId)
logger.debug('synology-ds', `ID Synology ${synoId} associé à ${options.infohash.slice(0, 8)}…`)
}
} catch {}
}
logger.info('synology-ds', `Torrent ajouté${config.savePath ? ` (dossier : ${config.savePath})` : ''}`)
},
async remove(config, hash, deleteFiles = false) {
const sid = await dsLogin(config)
// Synology utilise son ID interne — on cherche via la liste
async getFiles(config, hash) {
const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
{ additional: 'detail,file' }, sid)
const tasks: any[] = data?.tasks ?? []
const h = hash.toLowerCase()
const synoIdFromHash = _hashToSynoId.get(h) ?? ''
const task = tasks.find((t: any) => {
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
const detailUri = String(t.additional?.detail?.uri ?? '').toLowerCase()
const synoId = String(t.id ?? '').toLowerCase()
const cached = _uriToHash.get(detailUri) ?? ''
return detailHash === h
|| synoId === h
|| cached === h
|| (synoIdFromHash && synoId === synoIdFromHash)
})
if (!task) return []
const fileList: any[] = task.additional?.file ?? []
return fileList.map((f: any, i: number) => {
const fSize = typeof f.size === 'number' ? f.size : 0
const fDl = typeof f.size_downloaded === 'number' ? f.size_downloaded : 0
return {
index : i,
name : String(f.filename ?? ''),
progress: fSize > 0 ? fDl / fSize : 0,
priority: String(f.priority ?? 'normal') === 'skip' ? 0 : 1,
}
})
},
async remove(config, hash, _deleteFiles?) {
const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid)
const tasks: any[] = data?.tasks ?? []
// Matcher par hash si résolu, sinon par id
const found = tasks.find(t => t.id?.toLowerCase() === hash.toLowerCase())
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'delete',
id : found.id,
force_complete: deleteFiles ? 'false' : 'false',
_sid : sid,
const h = hash.toLowerCase()
const synoIdFromHash = _hashToSynoId.get(h) ?? ''
const found = tasks.find((t: any) => {
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
const detailUri = String(t.additional?.detail?.uri ?? '').toLowerCase()
const synoId = String(t.id ?? '').toLowerCase()
const cachedHash = _uriToHash.get(detailUri) ?? ''
return detailHash === h
|| synoId === h
|| cachedHash === h
|| (synoIdFromHash && synoId === synoIdFromHash)
})
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version : '1',
method : 'delete',
id : found.id,
force_complete: 'false',
_sid : sid,
})
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`Échec de la suppression (HTTP ${res.status})`)
logger.info('synology-ds', `Torrent ${found.id} supprimé`)
},
}
export default DS
export default DS
+28 -35
View File
@@ -1,14 +1,10 @@
/**
* Transmission Driver
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
function mapState(status: number): TorrentInfo['state'] {
// Transmission status codes :
// 0 = stopped, 1 = check queued, 2 = checking, 3 = download queued,
// 4 = downloading, 5 = seed queued, 6 = seeding
// 0 arrêté, 1-2 vérification (attente, en cours), 3-4 téléchargement (attente, en cours), 5-6 partage (attente, en cours)
if (status === 4 || status === 3) return 'downloading'
if (status === 6 || status === 5) return 'seeding'
if (status === 0) return 'paused'
@@ -17,7 +13,7 @@ function mapState(status: number): TorrentInfo['state'] {
}
async function trRequest(
config: Record<string, string | number>,
config: ClientConfig,
method: string,
args: Record<string, unknown> = {},
sessionId = ''
@@ -31,39 +27,35 @@ async function trRequest(
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
}
const res = await fetch(`${config.url}/transmission/rpc`, {
const res = await clientFetch(config, `${config.url}/transmission/rpc`, {
method : 'POST',
headers,
body : JSON.stringify({ method, arguments: args }),
})
// Transmission renvoie 409 avec le session ID à la première requête
// Première requête : 409 avec l'identifiant de session
if (res.status === 409) {
const newSessionId = res.headers.get('X-Transmission-Session-Id') ?? ''
if (!newSessionId) throw new Error('Session ID introuvable dans la réponse 409')
if (!newSessionId) throw new Error('Identifiant de session absent de la réponse de Transmission (HTTP 409)')
return trRequest(config, method, args, newSessionId)
}
if (!res.ok) throw new Error(`Transmission HTTP ${res.status}`)
if (!res.ok) throw new Error(`Transmission a répondu HTTP ${res.status}`)
const data = await res.json()
if (data.result !== 'success') throw new Error(`Transmission erreur : ${data.result}`)
if (data.result !== 'success') throw new Error(`Erreur Transmission : ${data.result}`)
return { ...data, sessionId }
}
async function trGetIdByHash(config: Record<string, string | number>, hash: string): Promise<number | null> {
async function trGetIdByHash(config: ClientConfig, hash: string): Promise<number | null> {
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
const torrents = data.arguments?.torrents ?? []
return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null
}
/**
* Attend que les métadonnées soient disponibles (polling 500 ms),
* puis applique les priorités : fichier cible voulu, tous les autres ignorés.
*/
async function trApplyFilePriority(
config : Record<string, string | number>,
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
@@ -83,11 +75,11 @@ async function trApplyFilePriority(
'files-wanted' : [fileIndex],
'files-unwanted' : unwanted,
})
logger.info('transmission', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
logger.info('transmission', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
}
const TR: TorrentClientDriver = {
@@ -96,12 +88,13 @@ const TR: TorrentClientDriver = {
label : 'Transmission',
fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'transmission', required: false },
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'transmission', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -111,8 +104,8 @@ const TR: TorrentClientDriver = {
logger.info('transmission', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('transmission', `Test de connexion échoué sur ${config.url} : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('transmission', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -121,10 +114,10 @@ const TR: TorrentClientDriver = {
try {
const data = await trRequest(config, 'session-get')
const version = data.arguments?.version ?? 'inconnue'
logger.debug('transmission', `Healthcheck OK — version ${version}`)
logger.debug('transmission', `Client en ligne (version ${version})`)
return { online: true, version }
} catch (err) {
logger.debug('transmission', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
logger.debug('transmission', `Client injoignable : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
@@ -134,7 +127,7 @@ const TR: TorrentClientDriver = {
'hashString', 'name', 'status', 'percentDone', 'totalSize',
'downloadedEver', 'uploadedEver', 'uploadRatio',
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
'files', // bytesCompleted + length par fichier (fileStats n'a pas 'length')
'files', // bytesCompleted et length par fichier (fileStats n'a pas length)
]
const data = await trRequest(config, 'torrent-get', { fields })
const torrents: any[] = data.arguments?.torrents ?? []
@@ -182,21 +175,21 @@ const TR: TorrentClientDriver = {
if (options?.file_index != null && hash) {
const isDuplicate = !!res.arguments?.['torrent-duplicate']
if (isDuplicate) {
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
} else {
logger.info('transmission', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
logger.info('transmission', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
}
trApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('transmission', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('transmission', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
logger.info('transmission', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
logger.info('transmission', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
// Transmission identifie les torrents par ID numérique — on cherche via la liste
// Torrents identifiés par ID numérique : recherche dans la liste
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
const torrents: any[] = data.arguments?.torrents ?? []
const found = torrents.find(t => t.hashString?.toLowerCase() === hash.toLowerCase())
+39 -53
View File
@@ -1,27 +1,22 @@
/**
* uTorrent Driver
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
// uTorrent status flags (bitmask) :
// 1 = Started, 2 = Checking, 4 = Start after check, 8 = Checked,
// 16 = Error, 32 = Paused, 64 = Queued, 128 = Loaded
// Drapeaux d'état : 1 démarré, 2 vérification, 4 démarrage après vérification, 8 vérifié, 16 erreur, 32 pause, 64 file d'attente, 128 chargé
function mapState(status: number): TorrentInfo['state'] {
if (status & 16) return 'error'
if (status & 32) return 'paused'
if (status & 2) return 'checking'
// Démarré + chargé + vérifié → on regarde le ratio de progression
// Démarré et vérifié : en cours (le seeding est déduit de la progression)
if (status & 1 && status & 8) return 'downloading'
if (status & 64) return 'downloading' // queued
if (status & 64) return 'downloading'
return 'unknown'
}
// uTorrent nécessite un token CSRF + cookie à chaque session
interface UTSession { token: string; cookie: string }
async function utTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
async function utTorrentExists(config: ClientConfig, hash: string): Promise<boolean> {
try {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getfiles', hash: hash.toUpperCase() }, session)
@@ -30,19 +25,15 @@ async function utTorrentExists(config: Record<string, string | number>, hash: st
} catch { return false }
}
/**
* Attend que les métadonnées soient disponibles (polling), puis applique les priorités :
* tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal).
*/
/** Attend la liste des fichiers puis les passe à 0, sauf la cible à 2 (normale). */
async function utApplyFilePriority(
config : Record<string, string | number>,
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const HASH = hash.toUpperCase()
// 100 tentatives × 500 ms = 50 s max
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
@@ -51,38 +42,37 @@ async function utApplyFilePriority(
const files = data?.files?.[1] as any[][] | undefined
if (!Array.isArray(files) || files.length === 0) continue
// Passer tous les fichiers à priorité 0 (skip)
const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' })
for (let i = 0; i < files.length; i++) skipQs.append('f', String(i))
await fetch(`${config.url}/gui/?${skipQs}`, {
await clientFetch(config, `${config.url}/gui/?${skipQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
// Activer le fichier cible (priorité 2 = normal)
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
await fetch(`${config.url}/gui/?${selectQs}`, {
await clientFetch(config, `${config.url}/gui/?${selectQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
logger.info('utorrent', `Fichier ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
logger.info('utorrent', `Fichier n° ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`)
throw new Error(`Délai dépassé : fichiers du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
}
async function utGetSession(config: Record<string, string | number>): Promise<UTSession> {
async function utGetSession(config: ClientConfig): Promise<UTSession> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const headers : Record<string, string> = {
'Authorization': `Basic ${auth}`,
}
const res = await fetch(`${config.url}/gui/token.html`, { headers })
if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`)
const res = await clientFetch(config, `${config.url}/gui/token.html`, { headers })
if (res.status === 401) throw new Error('uTorrent a refusé la connexion : vérifiez le nom d\'utilisateur et le mot de passe')
if (!res.ok) throw new Error(`uTorrent a répondu HTTP ${res.status} : vérifiez l'URL de la WebUI`)
const text = await res.text()
const match = text.match(/<div[^>]+id=['"]token['"][^>]*>([^<]+)</)
if (!match) throw new Error('Token CSRF introuvable dans la réponse')
if (!match) throw new Error('Réponse inattendue de uTorrent : vérifiez l\'URL de la WebUI')
const token = match[1].trim()
const cookie = res.headers.get('set-cookie') ?? ''
@@ -90,28 +80,25 @@ async function utGetSession(config: Record<string, string | number>): Promise<UT
}
async function utRequest(
config : Record<string, string | number>,
config : ClientConfig,
params : Record<string, string>,
session: UTSession,
): Promise<any> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const qs = new URLSearchParams({ token: session.token, ...params })
const res = await fetch(`${config.url}/gui/?${qs}`, {
const res = await clientFetch(config, `${config.url}/gui/?${qs}`, {
headers: {
'Authorization': `Basic ${auth}`,
'Cookie' : session.cookie,
},
})
if (!res.ok) throw new Error(`uTorrent HTTP ${res.status}`)
if (!res.ok) throw new Error(`uTorrent a répondu HTTP ${res.status}`)
return res.json()
}
function mapTorrent(t: any[]): TorrentInfo {
// uTorrent retourne un tableau par torrent :
// [hash, status, name, size, progress, downloaded, uploaded, ratio,
// ul_speed, dl_speed, eta, label, peers_connected, peers_swarm,
// seeds_connected, seeds_swarm, availability, queue_order, remaining]
// Tableau par torrent : [hash, status, name, size, progress, downloaded, uploaded, ratio, ul_speed, dl_speed, eta, label, …]
const [hash, status, name, size, progress, downloaded, uploaded, ratio, ulSpeed, dlSpeed, eta, label] = t
return {
hash : hash.toLowerCase(),
@@ -121,7 +108,7 @@ function mapTorrent(t: any[]): TorrentInfo {
size,
downloaded,
uploaded : uploaded ?? 0,
ratio : Math.round(((ratio ?? 0) / 1000) * 100) / 100, // uTorrent ratio est x1000
ratio : Math.round(((ratio ?? 0) / 1000) * 100) / 100, // ratio × 1000
speed : dlSpeed ?? 0,
upspeed : ulSpeed ?? 0,
eta : eta ?? -1,
@@ -135,13 +122,14 @@ const UT: TorrentClientDriver = {
id : 'utorrent',
label : 'uTorrent',
fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'url', label: 'URL de la WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -151,8 +139,8 @@ const UT: TorrentClientDriver = {
logger.info('utorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('utorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('utorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -162,10 +150,10 @@ const UT: TorrentClientDriver = {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getsettings' }, session)
const version = data?.['build']?.toString() ?? 'inconnue'
logger.debug('utorrent', `Healthcheck OK — build ${version}`)
logger.debug('utorrent', `Client en ligne (build ${version})`)
return { online: true, version }
} catch (err) {
logger.debug('utorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
logger.debug('utorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
@@ -183,7 +171,6 @@ const UT: TorrentClientDriver = {
.map(t => {
const info = mapTorrent(t)
// Récupérer le save_path depuis les properties si disponible
const props = data?.props?.find((p: any) => p[0]?.toLowerCase() === info.hash)
if (props) info.save_path = props[1] ?? ''
@@ -199,9 +186,9 @@ const UT: TorrentClientDriver = {
if (options?.file_index != null && hash) {
const exists = await utTorrentExists(config, hash)
if (exists) {
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
utApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('utorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
@@ -213,22 +200,21 @@ const UT: TorrentClientDriver = {
const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url })
if (config.savePath) qs.set('path', String(config.savePath))
const res = await fetch(`${config.url}/gui/?${qs}`, {
const res = await clientFetch(config, `${config.url}/gui/?${qs}`, {
method : 'GET',
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`)
if (!res.ok) throw new Error(`Échec de l'ajout (HTTP ${res.status})`)
if (options?.file_index != null && hash) {
logger.info('utorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
logger.info('utorrent', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
utApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('utorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
} else {
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
logger.info('utorrent', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
}
// Appliquer le label/catégorie si défini
if (config.category) {
setTimeout(async () => {
try {
+177
View File
@@ -0,0 +1,177 @@
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import bcrypt from 'bcryptjs'
import { DATA_DIR } from './config.js'
import { logger } from './logger.js'
export type UserRole = 'admin' | 'user'
export interface User {
id : string
username : string
passwordHash: string
role : UserRole
apiToken : string
createdAt : string
tourSeenAt? : string | null
lastLoginAt?: string | null
}
const USERS_PATH = path.join(DATA_DIR, 'users.json')
const LEGACY_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10
// ── Migration depuis auth.json ────────────────────────────────
export function migrateIfNeeded(): void {
if (fs.existsSync(USERS_PATH)) return
if (!fs.existsSync(LEGACY_PATH)) return
try {
const legacy = JSON.parse(fs.readFileSync(LEGACY_PATH, 'utf-8')) as {
username: string
passwordHash: string
}
const admin: User = {
id : crypto.randomUUID(),
username : legacy.username,
passwordHash: legacy.passwordHash,
role : 'admin',
apiToken : crypto.randomBytes(32).toString('hex'),
createdAt : new Date().toISOString(),
}
writeUsers([admin])
logger.info('users', `Migration de auth.json vers users.json réussie pour « ${legacy.username} »`)
} catch (err) {
logger.error('users', `Échec de la migration de auth.json : ${err instanceof Error ? err.message : err}`)
}
}
// ── Fichier des utilisateurs ──────────────────────────────────
export function readUsers(): User[] {
try {
if (!fs.existsSync(USERS_PATH)) return []
return JSON.parse(fs.readFileSync(USERS_PATH, 'utf-8')) as User[]
} catch { return [] }
}
export function writeUsers(users: User[]): void {
fs.mkdirSync(path.dirname(USERS_PATH), { recursive: true })
fs.writeFileSync(USERS_PATH, JSON.stringify(users, null, 2))
}
export function hasUsers(): boolean { return readUsers().length > 0 }
export function hasAdmin(): boolean { return readUsers().some(u => u.role === 'admin') }
export function findById(id: string): User | undefined { return readUsers().find(u => u.id === id) }
export function findByUsername(username: string): User | undefined { return readUsers().find(u => u.username.toLowerCase() === username.toLowerCase()) }
export function findByApiToken(token: string): User | undefined { return readUsers().find(u => u.apiToken === token) }
export function safeUser(user: User) {
const { passwordHash: _, ...safe } = user
return safe
}
// ── Gestion des comptes ───────────────────────────────────────
export function createUser(username: string, password: string, role: UserRole = 'user'): User {
const users = readUsers()
if (users.some(u => u.username.toLowerCase() === username.toLowerCase())) {
throw new Error(`Nom d'utilisateur « ${username} » déjà utilisé`)
}
const user: User = {
id : crypto.randomUUID(),
username,
passwordHash: bcrypt.hashSync(password, SALT_ROUNDS),
role,
apiToken : crypto.randomBytes(32).toString('hex'),
createdAt : new Date().toISOString(),
tourSeenAt : null,
}
writeUsers([...users, user])
logger.info('users', `Utilisateur « ${username} » créé (rôle : ${role})`)
return user
}
export function updateUser(
id : string,
changes: Partial<{ username: string; password: string; role: UserRole }>,
): User {
const users = readUsers()
const idx = users.findIndex(u => u.id === id)
if (idx === -1) throw new Error('Utilisateur introuvable')
const user = users[idx]
if (changes.username !== undefined && changes.username !== user.username) {
if (users.some((u, i) => i !== idx && u.username.toLowerCase() === changes.username!.toLowerCase())) {
throw new Error(`Nom d'utilisateur « ${changes.username} » déjà utilisé`)
}
user.username = changes.username
}
if (changes.role !== undefined) user.role = changes.role
if (changes.password !== undefined) user.passwordHash = bcrypt.hashSync(changes.password, SALT_ROUNDS)
writeUsers(users)
logger.info('users', `Utilisateur « ${user.username} » mis à jour`)
return user
}
export function deleteUser(id: string): void {
const users = readUsers()
const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable')
writeUsers(users.filter(u => u.id !== id))
logger.info('users', `Utilisateur « ${user.username} » supprimé`)
}
export function changePassword(id: string, currentPassword: string, newPassword: string): void {
const users = readUsers()
const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable')
if (!bcrypt.compareSync(currentPassword, user.passwordHash)) {
throw new Error('Mot de passe actuel incorrect')
}
user.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS)
writeUsers(users)
logger.info('users', `Mot de passe changé pour « ${user.username} »`)
}
export function markLogin(id: string): string | null {
const users = readUsers()
const user = users.find(u => u.id === id)
if (!user) return null
user.lastLoginAt = new Date().toISOString()
writeUsers(users)
return user.lastLoginAt
}
export function markTourSeen(id: string): string {
const users = readUsers()
const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable')
user.tourSeenAt = user.tourSeenAt ?? new Date().toISOString()
writeUsers(users)
return user.tourSeenAt
}
export function markAllToursSeen(at: string): number {
const users = readUsers()
const pending = users.filter(u => !u.tourSeenAt)
if (pending.length === 0) return 0
for (const u of pending) u.tourSeenAt = at
writeUsers(users)
return pending.length
}
export function regenerateApiToken(id: string): string {
const users = readUsers()
const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable')
user.apiToken = crypto.randomBytes(32).toString('hex')
writeUsers(users)
return user.apiToken
}
+3 -4
View File
@@ -48,7 +48,7 @@ export interface Torrent {
url: string
}
// Parse "Nom@url|Nom@url" → Torrent[]
// "Nom@url|Nom@url" en liste de torrents
export function parseTorrents(raw: string | null): Torrent[] {
if (!raw) return []
return raw.split('|').flatMap(item => {
@@ -58,7 +58,6 @@ export function parseTorrents(raw: string | null): Torrent[] {
})
}
// Préfixe les images relatives
export function imageUrl(path: string | null): string | null {
if (!path) return null
const encoded = encodeURIComponent(`https://api.fankai.fr${path}`)
@@ -67,14 +66,14 @@ export function imageUrl(path: string | null): string | null {
export async function fetchProductions(): Promise<Production[]> {
const res = await fetch(`${BASE}/productions`)
if (!res.ok) throw new Error('Erreur chargement catalogue')
if (!res.ok) throw new Error('Impossible de charger le catalogue.')
const data = await res.json()
return data.pageProps.data
}
export async function fetchProduction(id: number, name: string): Promise<ProductionDetail> {
const res = await fetch(`${BASE}/productions/${id}?name=${encodeURIComponent(name)}`)
if (!res.ok) throw new Error('Erreur chargement production')
if (!res.ok) throw new Error('Impossible de charger la série.')
const data = await res.json()
return data.pageProps.data
}
+229 -88
View File
@@ -1,18 +1,19 @@
@import "tailwindcss";
/* ─── Tokens custom consommables via Tailwind v4 ─── */
/* ─── Des polices un peu stylées si vous voulez mon avis ─── */
@theme {
--font-sans: 'Zen Kaku Gothic New', 'Yu Gothic', 'Hiragino Sans', system-ui, sans-serif;
--font-display: 'Shippori Mincho', 'Yu Mincho', Georgia, serif;
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-dim: var(--accent-dim);
--color-accent-muted: var(--accent-muted);
--color-on-accent: var(--on-accent);
--color-shell: var(--bg-shell);
--color-sidebar: var(--bg-sidebar);
--color-main: var(--bg-main);
--color-card: var(--bg-card);
--color-hover: var(--bg-hover);
--color-active: var(--bg-active);
--color-border: var(--border);
--color-border-light: var(--border-light);
@@ -21,85 +22,55 @@
--color-secondary: var(--text-secondary);
--color-muted: var(--text-muted);
--color-gold: var(--gold);
--color-ok: var(--ok);
--color-err: var(--err);
--radius-card: 14px;
--radius-field: 10px;
--radius-poster: 6px;
--text-body: 13.5px;
--text-body--line-height: 1.5;
--text-meta: 12.5px;
--text-meta--line-height: 1.45;
}
/* ─── Thème Indigo Électrique (défaut) ─── */
:root,
[data-theme="indigo"] {
--accent: #7B7EF8;
--accent-hover: #9496fa;
--accent-dim: #1c1c38;
--accent-muted: rgba(123, 126, 248, 0.15);
/* ─── Palette ─── */
:root {
--bg-sidebar: #0e0a08;
--bg-main: #13100c;
--bg-card: #1d1814;
--bg-hover: #27221d;
--bg-shell: #111116;
--bg-sidebar: #0d0d14;
--bg-main: #16161e;
--bg-card: #111116;
--bg-hover: #1c1c28;
--bg-active: #22223a;
--border: #352f2a;
--border-light: #27221d;
--border: #2a2a3a;
--border-light: #1e1e2e;
--text-primary: #efe9dd;
--text-secondary: #b8b0a3;
--text-muted: #948b80;
--text-primary: #e8e8f8;
--text-secondary: #9898b8;
--text-muted: #55556a;
--gold: #f2c44a;
--ok: #89d298;
--err: #ea8470;
--on-accent: #0e0a08;
--gold: #f5c842;
--accent: #f2c44a;
--accent-hover: color-mix(in srgb, var(--accent) 80%, white);
--accent-muted: color-mix(in srgb, var(--accent) 14%, transparent);
}
/* ─── Thème Crimson Night ─── */
[data-theme="crimson"] {
--accent: #E74C3C;
--accent-hover: #f06050;
--accent-dim: #2a0e0e;
--accent-muted: rgba(231, 76, 60, 0.15);
--bg-shell: #110e0e;
--bg-sidebar: #0d0a0a;
--bg-main: #161010;
--bg-card: #110e0e;
--bg-hover: #1e1414;
--bg-active: #2a1616;
--border: #2e2020;
--border-light: #201616;
--text-primary: #f0e8e8;
--text-secondary: #b89898;
--text-muted: #6a5555;
--gold: #f5c842;
}
/* ─── Thème Teal Froid ─── */
[data-theme="teal"] {
--accent: #00CDBF;
--accent-hover: #1de8da;
--accent-dim: #001e1c;
--accent-muted: rgba(0, 205, 191, 0.15);
--bg-shell: #0a0f0f;
--bg-sidebar: #060c0c;
--bg-main: #0e1616;
--bg-card: #0a0f0f;
--bg-hover: #101e1e;
--bg-active: #142828;
--border: #1a2e2e;
--border-light: #121e1e;
--text-primary: #e0f0f0;
--text-secondary: #88b0b0;
--text-muted: #4a6868;
--gold: #f5c842;
}
/* ─── Thèmes ─── */
[data-theme="vermillon"] { --accent: #e8734f; }
[data-theme="jade"] { --accent: #6fc79a; }
/* ─── Base globale ─── */
*, *::before, *::after { box-sizing: border-box; }
html {
color-scheme: dark;
accent-color: var(--accent);
}
html, body, #app {
height: 100%;
margin: 0;
@@ -109,41 +80,211 @@ html, body, #app {
body {
background: var(--bg-main);
color: var(--text-primary);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width: 6px; }
:where(a, button, [role="switch"], summary):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ─── Composants réutilisables ─── */
@layer components {
/* Typo */
.page-title {
@apply font-display text-[28px] leading-tight font-bold text-primary;
}
.section-title {
@apply font-display text-[21px] leading-snug font-bold text-primary;
}
.card-title {
@apply text-[15px] font-bold text-primary;
}
.tag-label {
@apply text-[10.5px] font-bold uppercase tracking-[0.14em] text-muted;
}
/* Boutons */
.btn-primary,
.btn-secondary,
.btn-ghost,
.btn-danger {
@apply inline-flex items-center justify-center gap-2 h-10 px-[18px] rounded-full text-body whitespace-nowrap
transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply bg-accent text-white px-4 py-2 text-sm rounded-lg
hover:bg-accent-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer;
@apply px-5 bg-accent text-on-accent font-bold hover:bg-accent-hover;
}
.btn-secondary {
@apply border border-border text-secondary px-4 py-2 text-sm rounded-lg
hover:border-accent hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer;
@apply border border-border text-primary font-semibold hover:bg-hover;
}
.btn-ghost {
@apply text-muted px-2 py-1 text-sm rounded-md
hover:text-primary hover:bg-hover transition-colors cursor-pointer;
@apply border border-border-light text-secondary font-medium hover:bg-hover hover:text-primary;
}
.btn-danger {
@apply border border-err/30 text-err font-semibold hover:bg-err/10;
}
.btn-icon {
@apply inline-flex items-center justify-center shrink-0 w-10 h-10 rounded-full border border-border-light text-secondary
transition-colors cursor-pointer hover:bg-hover hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-sm {
@apply h-8 px-3.5 text-meta;
}
.btn-icon.btn-sm {
@apply w-8 px-0;
}
.settings-card {
@apply bg-card border border-border rounded-xl p-4;
/* Cartes et champs */
.card {
@apply bg-card rounded-card px-5 py-4;
}
.settings-label {
@apply text-xs text-muted block;
.field-label {
@apply block text-meta text-secondary;
}
.settings-input {
@apply w-full bg-shell border border-border text-primary text-sm px-3 py-2.5 rounded-lg
outline-none focus:border-accent transition-colors;
.field {
@apply w-full bg-main border border-border-light text-primary text-body px-3.5 py-2.5 rounded-field
placeholder:text-muted outline-none focus:border-accent transition-colors;
}
}
/* Pastilles d'état */
.pill {
@apply inline-flex items-center gap-[7px] h-6 px-[11px] rounded-full border border-transparent
text-[11.5px] font-bold whitespace-nowrap;
}
.pill-ok { @apply border-ok/30 text-ok; }
.pill-active { @apply bg-accent-muted text-accent; }
.pill-wait { @apply border-accent/35 text-accent; }
.pill-neutral { @apply border-border text-secondary; }
.pill-err { @apply border-err/35 text-err; }
.pill-muted { @apply border-border-light text-muted; }
/* Filtres et onglets à compteur */
.chip {
@apply inline-flex items-center gap-2 h-[34px] px-3.5 rounded-full border border-border-light text-[13px] font-medium
text-secondary whitespace-nowrap transition-colors cursor-pointer hover:text-primary hover:bg-hover;
}
.chip-count {
@apply text-muted;
}
.chip.is-active {
@apply border-primary bg-primary text-on-accent font-bold hover:bg-primary;
}
.chip.is-active .chip-count {
@apply text-on-accent/55 font-medium;
}
.chip-row {
@apply flex items-center gap-1.5 flex-wrap;
}
.chip-row > * {
@apply shrink-0;
}
/* Sur mobile, les filtres défilent sur une ligne jusqu'aux bords de l'écran */
@media (width < 48rem) {
.chip-row {
@apply flex-nowrap overflow-x-auto -mx-4 px-4 -my-1 py-1;
scrollbar-width: none;
}
}
/* Bascule */
.switch {
@apply relative inline-flex shrink-0 w-[46px] h-[26px] p-[3px] rounded-full bg-hover transition-colors cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed;
}
.switch-knob {
@apply block w-5 h-5 rounded-full bg-muted transition-transform duration-200;
}
.switch[aria-checked="true"] {
@apply bg-accent;
}
.switch[aria-checked="true"] .switch-knob {
@apply translate-x-5 bg-on-accent;
}
/* Sélecteur segmenté */
.segmented {
@apply inline-flex items-center gap-0.5 h-[34px] p-[3px] rounded-full border border-border-light;
}
.segmented-item {
@apply inline-flex items-center h-[26px] px-3.5 rounded-full text-meta text-secondary whitespace-nowrap
transition-colors cursor-pointer hover:text-primary;
}
.segmented-item.is-active {
@apply bg-accent text-on-accent font-bold hover:text-on-accent;
}
.segmented-lg {
@apply h-[38px];
}
.segmented-lg .segmented-item {
@apply h-[30px] px-4 text-[13px];
}
/* Barres de progression */
.progress {
@apply h-[3px] rounded-[2px] bg-hover overflow-hidden;
}
.progress-bar {
@apply h-full rounded-[2px] bg-accent transition-[width] duration-500;
}
/* Menus déroulants et modales */
.menu {
@apply bg-card border border-border rounded-card p-1.5 shadow-[0_16px_40px_rgb(0_0_0/0.5)] flex flex-col gap-0.5;
}
.menu-item {
@apply flex items-center gap-2.5 w-full min-h-9 px-3 py-2 rounded-lg text-left text-body text-secondary
transition-colors cursor-pointer hover:bg-hover hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed;
}
.modal-backdrop {
@apply fixed inset-0 z-50 flex items-center justify-center px-4 bg-sidebar/75;
}
.modal {
@apply w-full bg-card border border-border rounded-card p-6 flex flex-col gap-5 shadow-[0_24px_60px_rgb(0_0_0/0.55)];
}
.stamp-new {
@apply inline-block px-2 pt-[3px] pb-[2px] border-2 border-accent bg-sidebar/85 text-accent
font-display text-[11px] font-bold leading-none tracking-[0.12em] -rotate-6;
}
@media (pointer: coarse) {
.btn-primary,
.btn-secondary,
.btn-ghost,
.btn-danger {
@apply h-11;
}
.btn-icon {
@apply w-11 h-11;
}
.btn-sm {
@apply h-10;
}
.btn-icon.btn-sm {
@apply w-10;
}
.chip {
@apply h-10;
}
.menu-item {
@apply min-h-11;
}
}
.dot-grid {
@apply absolute inset-0 pointer-events-none;
background-image: radial-gradient(color-mix(in srgb, var(--accent) 12%, transparent) 1px, transparent 1.5px);
background-size: 7px 7px;
mask-image: linear-gradient(var(--dot-grid-angle, 90deg), #000 0%, transparent var(--dot-grid-fade, 55%));
}
}
-52
View File
@@ -1,52 +0,0 @@
<template>
<div class="bg-[#0d1219] flex gap-4 p-3 hover:bg-[#121920] transition-colors">
<div class="w-24 flex-shrink-0">
<img
v-if="ep.cover"
:src="imageUrl(ep.cover)"
:alt="ep.name"
class="w-full aspect-video object-cover"
/>
<div v-else class="w-full aspect-video bg-[#121920]"></div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-[10px] text-[#e8513a]">FILM {{ ep.number }}</span>
<span class="text-sm font-bold text-white truncate">{{ ep.name }}</span>
</div>
<div class="text-[10px] text-[#5a7a94] mb-1">
Épisodes {{ ep.episodes }} · {{ ep.duration }}
</div>
<p class="text-xs text-[#5a7a94] leading-relaxed line-clamp-2">{{ ep.description }}</p>
</div>
<div class="flex-shrink-0 flex items-center">
<a
v-if="ep.path_fr"
:href="`https://fankai.fr/productions/${productionId}?episode=${ep.number}`"
target="_blank"
title="Streamable sur fankai.fr"
class="text-[10px] text-[#3ae87a] border border-[#3ae87a] px-2 py-1 tracking-widest hover:bg-[#3ae87a]/10 transition-colors"
@click.stop
>
▶ STREAM
</a>
<div
v-else
class="text-[10px] text-[#1e2d3d] border border-[#1e2d3d] px-2 py-1 tracking-widest"
>
BIENTÔT
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { imageUrl, type Episode } from '@/api/fankai'
defineProps<{
ep: Episode
productionId: number
}>()
</script>
+6
View File
@@ -0,0 +1,6 @@
<template>
<svg viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
</svg>
</template>
+29 -41
View File
@@ -1,74 +1,62 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')">
<div class="bg-card border border-border rounded-xl w-full max-w-lg flex flex-col" style="max-height: 70vh">
<div class="modal-backdrop" @click.self="$emit('cancel')">
<div class="bg-card border border-border rounded-card w-full max-w-lg flex flex-col max-h-[70vh] shadow-[0_24px_60px_rgb(0_0_0/0.55)]" role="dialog" aria-modal="true" aria-label="Choisir un dossier">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
<div>
<p class="text-xs text-accent font-medium mb-0.5">Choisir un dossier</p>
<p class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</p>
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-hover shrink-0">
<div class="min-w-0">
<p class="card-title">Choisir un dossier</p>
<p class="text-meta text-muted truncate">{{ current }}</p>
</div>
<button @click="$emit('cancel')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
<button @click="$emit('cancel')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div>
<!-- Chemin éditable -->
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" :title="drivesRoot ? 'Lecteurs' : 'Retour à la racine'">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
<div class="px-5 py-3 border-b border-hover shrink-0 flex gap-2">
<button @click="navigateTo('/')" class="btn-icon h-[42px] w-[42px]" :title="drivesRoot ? 'Lecteurs' : 'Retour à la racine'" :aria-label="drivesRoot ? 'Lecteurs' : 'Retour à la racine'">
<House :size="15" />
</button>
<input
v-model="inputPath"
@keydown.enter="navigateTo(inputPath)"
class="settings-input flex-1 font-mono text-xs"
class="field flex-1"
placeholder="/"
aria-label="Chemin"
/>
<button @click="navigateTo(inputPath)" class="btn-secondary px-3 text-xs">
OK
<button @click="navigateTo(inputPath)" class="btn-secondary h-[42px]">
Aller
</button>
</div>
<!-- Retour parent -->
<div v-if="parent !== null" class="px-5 shrink-0">
<button
@click="navigateTo(parent)"
class="flex items-center gap-2 w-full py-2.5 text-xs text-muted hover:text-primary transition-colors border-b border-border"
class="flex items-center gap-2 w-full py-2.5 text-meta text-secondary hover:text-primary transition-colors border-b border-hover"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
..
<ArrowLeft :size="14" />
Dossier parent
</button>
</div>
<!-- Liste dossiers -->
<div class="overflow-y-auto flex-1">
<div v-if="loading" class="flex items-center justify-center py-10 text-muted text-xs gap-2">
<div v-if="loading" class="flex items-center justify-center py-10 text-muted text-meta gap-2">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
Chargement…
</div>
<div v-else-if="error" class="px-5 py-4 text-xs text-red-400">{{ error }}</div>
<div v-else-if="dirs.length === 0" class="px-5 py-4 text-xs text-muted">Aucun sous-dossier</div>
<div v-else-if="error" class="px-5 py-4 text-meta text-err">{{ error }}</div>
<div v-else-if="dirs.length === 0" class="px-5 py-4 text-meta text-muted">Aucun sous-dossier</div>
<button
v-for="dir in dirs"
:key="dir"
@click="navigateTo(joinPath(current, dir))"
class="flex items-center gap-3 w-full px-5 py-2.5 text-xs text-primary hover:bg-hover transition-colors border-b border-border/50 text-left"
class="flex items-center gap-3 w-full px-5 min-h-10 py-2 text-body text-primary hover:bg-hover transition-colors border-b border-hover text-left"
>
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" fill="none">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
</svg>
<Folder :size="15" :stroke-width="1.75" class="text-muted shrink-0" />
{{ dir }}
</button>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-5 py-4 border-t border-border shrink-0">
<span class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</span>
<!-- FIX : regex sortie dans selectCurrent() pour éviter l'erreur de parsing Vue -->
<div class="flex items-center justify-between gap-4 px-5 py-4 border-t border-hover shrink-0">
<span class="text-meta text-muted truncate">{{ current }}</span>
<button @click="selectCurrent()" class="btn-primary">
Choisir
</button>
@@ -81,6 +69,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ArrowLeft, Folder, House, X } from 'lucide-vue-next'
const props = defineProps<{ initialPath?: string }>()
const emit = defineEmits<{ select: [path: string]; cancel: [] }>()
@@ -93,13 +82,12 @@ const loading = ref(false)
const error = ref('')
const drivesRoot = ref(false) // true quand la liste affiche les lecteurs Windows
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
function selectCurrent() {
emit('select', current.value.replace(/\/+$/, '') || '/')
}
function joinPath(base: string, name: string): string {
// Lecteur Windows complet (ex: "C:\") → chemin absolu, pas de jointure
// Lecteur Windows (ex. "C:\") : déjà absolu
if (/^[A-Za-z]:\\$/.test(name)) return name
if (base === '/') return `/${name}`
const cleanBase = base.replace(/[\\/]+$/, '')
@@ -113,7 +101,7 @@ async function navigateTo(p: string) {
try {
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
if (!res.ok) {
error.value = (await res.json()).error ?? 'Erreur'
error.value = (await res.json()).error ?? "Impossible d'ouvrir ce dossier."
return
}
const data = await res.json()
@@ -123,7 +111,7 @@ async function navigateTo(p: string) {
dirs.value = data.dirs
drivesRoot.value = data.drivesRoot ?? false
} catch {
error.value = 'Impossible de lire ce dossier'
error.value = 'Impossible de lire ce dossier.'
} finally {
loading.value = false
}
+134 -129
View File
@@ -1,87 +1,81 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="$emit('close')">
<div class="bg-card border border-border rounded-xl w-full max-w-3xl flex flex-col" style="max-height: 85vh">
<div class="modal-backdrop" @click.self="$emit('close')">
<div class="bg-card border border-border rounded-card w-full max-w-3xl flex flex-col shadow-[0_24px_60px_rgb(0_0_0/0.55)] max-h-[85vh]" role="dialog" aria-modal="true" aria-labelledby="manual-import-title">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div>
<h3 class="text-sm font-semibold text-primary">Import manuel — {{ serieName }}</h3>
<p class="text-xs text-muted font-mono mt-0.5 truncate">{{ serieFolder }}</p>
<div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
<div class="min-w-0 flex flex-col gap-1">
<h3 id="manual-import-title" class="font-display text-xl font-bold text-primary">Import manuel <span class="font-sans text-body font-normal text-muted">· {{ serieName }}</span></h3>
<p class="text-meta text-muted truncate" :title="serieFolder">{{ serieFolder }}</p>
</div>
<button @click="$emit('close')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
<button @click="$emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div>
<!-- Loading -->
<div v-if="scanning" class="flex flex-col items-center justify-center gap-3 py-16 text-muted">
<div class="w-5 h-5 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-sm">Scan du dossier…</p>
<p class="text-body">Analyse du dossier…</p>
</div>
<!-- Dossier vide / introuvable -->
<div v-else-if="!scanning && items.length === 0 && !scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
<p class="text-sm text-muted">Aucun fichier vidéo trouvé dans ce dossier.</p>
<p class="text-xs text-muted">Placez vos fichiers dans <span class="font-mono text-primary">{{ serieFolder }}</span> puis réessayez.</p>
<button @click="scan" class="btn-secondary mt-2">Rescanner</button>
<p class="text-body text-primary">{{ folderMissing ? 'Ce dossier n\'existe pas encore.' : 'Aucun fichier vidéo trouvé dans ce dossier.' }}</p>
<p class="text-meta text-muted">
{{ folderMissing ? 'Créez' : 'Placez vos fichiers dans' }} <span class="text-secondary break-all">{{ serieFolder }}</span>{{ folderMissing ? ', placez-y vos fichiers' : '' }} puis réessayez.
</p>
<button @click="scan" class="btn-secondary btn-sm mt-2"><RefreshCw :size="14" /> Relancer l'analyse</button>
</div>
<!-- Erreur -->
<div v-else-if="scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
<p class="text-sm text-red-400">{{ scanError }}</p>
<button @click="scan" class="btn-secondary">Réessayer</button>
<p class="text-body text-err">{{ scanError }}</p>
<button @click="scan" class="btn-secondary btn-sm">Réessayer</button>
</div>
<!-- Liste fichiers -->
<template v-else-if="items.length > 0">
<template v-else-if="items.length > 0 && step !== 'done'">
<!-- Toolbar -->
<div class="flex items-center justify-between px-6 py-3 border-b border-border shrink-0 gap-3">
<p class="text-xs text-muted">
<span class="text-primary font-medium">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }}
<span v-if="alreadyImportedCount > 0"> · <span class="text-blue-400">{{ alreadyImportedCount }} déjà importé{{ alreadyImportedCount > 1 ? 's' : '' }}</span></span>
· <span class="text-green-400">{{ newMatchedCount }} nouveau{{ newMatchedCount > 1 ? 'x' : '' }}</span>
<span v-if="unmatchedCount > 0"> · <span class="text-yellow-500">{{ unmatchedCount }} non associé{{ unmatchedCount > 1 ? 's' : '' }}</span></span>
<div class="flex items-center justify-between px-6 py-3 border-b border-hover shrink-0 gap-3 flex-wrap">
<p class="text-meta text-muted">
<span class="text-primary font-bold">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }}
<span v-if="alreadyImportedCount > 0"> · <span class="text-ok">{{ plural(alreadyImportedCount, 'déjà importé', 'déjà importés') }}</span></span>
· <span class="text-accent">{{ plural(newMatchedCount, 'nouveau', 'nouveaux') }}</span>
<span v-if="unmatchedCount > 0"> · {{ plural(unmatchedCount, 'non associé', 'non associés') }}</span>
</p>
<button @click="scan" class="text-xs text-muted hover:text-primary transition-colors">↺ Rescanner</button>
<button @click="scan" class="text-meta text-secondary hover:text-primary transition-colors flex items-center gap-1.5"><RefreshCw :size="13" /> Relancer l'analyse</button>
</div>
<!-- Fichiers -->
<div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2">
<div
v-for="(item, i) in items"
:key="item.file.path"
class="flex items-center gap-3 p-3 rounded-lg border transition-colors"
class="flex items-center gap-3 px-3.5 py-3 rounded-field border transition-colors flex-wrap sm:flex-nowrap"
:class="item.alreadyImported
? 'border-blue-500/20 bg-blue-500/5'
? 'border-ok/25 bg-ok/5'
: item.episode_id
? 'border-green-500/20 bg-green-500/5'
: 'border-border bg-shell'"
? 'border-accent/30 bg-accent/5'
: 'border-border-light bg-main'"
>
<!-- Icône -->
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0"
:class="item.alreadyImported ? 'bg-blue-500/15 text-blue-400' :
item.episode_id ? 'bg-green-500/15 text-green-400' : 'bg-border text-muted'">
<svg v-if="item.alreadyImported || item.episode_id" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2" fill="none"><line x1="6" y1="2" x2="6" y2="7"/><circle cx="6" cy="9.5" r="0.5" fill="currentColor"/></svg>
</div>
<span
class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
:class="item.alreadyImported ? 'bg-ok/15 text-ok' : item.episode_id ? 'bg-accent-muted text-accent' : 'bg-hover text-muted'"
>
<Check v-if="item.alreadyImported || item.episode_id" :size="12" :stroke-width="3" />
<Minus v-else :size="12" :stroke-width="3" />
</span>
<!-- Nom + taille -->
<div class="flex-1 min-w-0">
<p class="text-xs text-primary font-mono truncate">{{ item.file.name }}</p>
<p class="text-[11px] text-muted mt-0.5">
<p class="text-body text-primary truncate" :title="item.file.name">{{ item.file.name }}</p>
<p class="text-xs text-muted mt-0.5">
{{ formatSize(item.file.size) }}
<span v-if="item.alreadyImported" class="text-blue-400 ml-1">— déjà importé</span>
<span v-else-if="item.willRename" class="text-accent ml-1">— sera renommé</span>
<span v-if="item.alreadyImported" class="text-ok"> · déjà importé</span>
<span v-else-if="item.willRename" class="text-accent"> · sera renommé</span>
</p>
</div>
<!-- Sélecteur épisode -->
<select
v-model="items[i].episode_id"
class="settings-input text-xs py-1 max-w-[260px] shrink-0"
v-model="item.episode_id"
class="field py-2 text-meta w-full sm:w-auto sm:max-w-[280px] shrink-0"
@change="onEpisodeChange(i)"
:aria-label="`Épisode associé à ${item.file.name}`"
>
<option :value="null">— Non associé —</option>
<option :value="null">Aucun épisode</option>
<optgroup v-for="season in seasons" :key="season.id" :label="season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}`">
<option
v-for="ep in season.episodes"
@@ -89,56 +83,60 @@
:value="ep.id"
:disabled="isEpisodeAlreadyUsed(ep.id, i)"
>
E{{ String(ep.episode_number).padStart(2, '0') }} — {{ ep.title || `Épisode ${ep.episode_number}` }}
{{ isEpisodeOrganized(ep.id) ? '✓' : '' }}
E{{ String(ep.episode_number).padStart(2, '0') }} · {{ ep.title || `Épisode ${ep.episode_number}` }}
{{ isEpisodeOrganized(ep.id) ? '(déjà importé)' : '' }}
</option>
</optgroup>
</select>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-6 py-4 border-t border-border shrink-0">
<p v-if="importError" class="text-xs text-red-400">{{ importError }}</p>
<p v-else class="text-xs text-muted">
Les fichiers dans ce dossier seront <span class="text-accent">renommés sur place</span>.
<div class="flex items-center justify-between gap-4 px-6 py-4 border-t border-hover shrink-0 flex-wrap">
<p v-if="importError" class="text-meta text-err">{{ importError }}</p>
<p v-else class="text-meta text-muted">
Les fichiers de ce dossier seront <span class="text-accent">renommés sur place</span>.
</p>
<div class="flex gap-2">
<button @click="$emit('close')" class="btn-secondary">Annuler</button>
<div class="flex gap-2.5">
<button @click="$emit('close')" class="btn-ghost">Annuler</button>
<button
@click="doImport"
:disabled="newMatchedCount === 0 || importing"
class="btn-primary"
>
{{ importing ? 'Import…' : `Importer ${newMatchedCount} fichier${newMatchedCount > 1 ? 's' : ''}` }}
{{ importing ? 'Import…' : `Importer ${plural(newMatchedCount, 'fichier')}` }}
</button>
</div>
</div>
</template>
<!-- Résultat -->
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center">
<div class="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 24 24" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<div>
<p class="text-sm font-semibold text-primary">Import terminé</p>
<p class="text-xs text-muted mt-1">
{{ importResult.done }} fichier{{ importResult.done > 1 ? 's' : '' }} importé{{ importResult.done > 1 ? 's' : '' }}
<span v-if="importResult.errors.length > 0" class="text-red-400">
· {{ importResult.errors.length }} erreur{{ importResult.errors.length > 1 ? 's' : '' }}
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center overflow-y-auto">
<span
class="w-12 h-12 rounded-full flex items-center justify-center"
:class="importResult.errors.length === 0 ? 'bg-ok/10 text-ok' : 'bg-err/10 text-err'"
>
<Check v-if="importResult.errors.length === 0" :size="22" :stroke-width="2.5" />
<TriangleAlert v-else :size="22" :stroke-width="2" />
</span>
<div class="flex flex-col gap-1">
<p class="font-display text-xl font-bold text-primary">Import terminé</p>
<p class="text-body text-secondary">
{{ plural(importResult.done, 'fichier importé', 'fichiers importés') }}
<span v-if="importResult.errors.length > 0" class="text-err">
· {{ plural(importResult.errors.length, 'erreur') }}
</span>
</p>
</div>
<div v-if="importResult.errors.length > 0" class="w-full text-left bg-red-500/5 border border-red-500/20 rounded-lg p-3">
<p class="text-xs text-red-400 font-medium mb-1.5">Erreurs</p>
<div v-for="e in importResult.errors" :key="e.file" class="text-xs text-muted mb-1">
<span class="text-primary font-mono">{{ e.file }}</span> — {{ e.error }}
</div>
<div v-if="importResult.errors.length > 0" class="w-full text-left rounded-field border border-err/25 bg-err/5 px-4 py-3">
<p class="tag-label text-err mb-1.5">Erreurs</p>
<p v-for="e in importResult.errors" :key="e.file" class="text-meta text-secondary mb-1 break-words">
<span class="text-primary">{{ e.file }}</span> : {{ e.error }}
</p>
</div>
<div class="flex gap-2.5">
<button @click="backToList" class="btn-secondary">Voir les fichiers</button>
<button @click="$emit('close')" class="btn-primary">Fermer</button>
</div>
<button @click="$emit('close'); $emit('imported')" class="btn-primary">Fermer</button>
</div>
</div>
@@ -149,6 +147,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import { Check, Minus, RefreshCw, TriangleAlert, X } from 'lucide-vue-next'
import { matchEpisodeFile } from '@/utils/episode-match'
import { formatSize, plural } from '@/utils/format'
const { add: toast } = useToast()
@@ -165,12 +166,13 @@ const emit = defineEmits<{
imported: []
}>()
// ─── State ────────────────────────────────────────────────────
// ── État ──
const step = ref<'list' | 'done'>('list')
const scanning = ref(false)
const importing = ref(false)
const importError = ref('')
const scanError = ref('')
const folderMissing = ref(false)
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
// Dossier de la série dans la médiathèque
@@ -180,21 +182,22 @@ const serieTitle = computed(() =>
const serieFolder = computed(() => `${props.initialPath}/${serieTitle.value}`)
interface FileItem {
file : { name: string; path: string; size: number }
episode_id : number | null
hash : string | null
alreadyImported: boolean
willRename : boolean
file : { name: string; path: string; size: number }
episode_id : number | null
originalEpisodeId : number | null // épisode auquel le fichier était déjà associé lors de l'analyse
hash : string | null
alreadyImported : boolean
willRename : boolean
}
const items = ref<FileItem[]>([])
// ─── Computed ──────────────────────────────────────────────────
// ── Calculs ──
const alreadyImportedCount = computed(() => items.value.filter(i => i.alreadyImported).length)
const newMatchedCount = computed(() => items.value.filter(i => i.episode_id !== null && !i.alreadyImported).length)
const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
// ─── Helpers ──────────────────────────────────────────────────
// ── Utilitaires ──
function isEpisodeOrganized(epId: number): boolean {
return !!props.organized?.[String(epId)]
}
@@ -224,48 +227,28 @@ function buildOrganizedByPath(): Map<string, number> {
}
function autoMatch(filename: string): { episode_id: number | null; hash: string | null } {
const nameNoExt = filename.replace(/\.[^.]+$/, '').toLowerCase()
for (const season of props.seasons) {
for (const ep of season.episodes) {
const candidates = [
ep.original_filename,
ep.nfo_filename?.replace(/\.nfo$/, ''),
ep.formatted_name?.replace(/[<>:"/\\|?*]/g, '').trim(),
].filter(Boolean).map((s: string) => s.toLowerCase().replace(/\.[^.]+$/, ''))
if (candidates.some(c => c === nameNoExt || nameNoExt.startsWith(c + '.'))) {
return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
}
}
}
// Fallback numéro d'épisode
const numMatch = filename.match(/[.\s_-]0*(\d{1,3})[.\s_-]/i)
if (numMatch) {
const num = parseInt(numMatch[1], 10)
for (const season of props.seasons) {
const ep = season.episodes.find((e: any) => e.episode_number === num)
if (ep) return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
}
}
return { episode_id: null, hash: null }
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
const episodeId = matchEpisodeFile(filename, props.seasons)
return { episode_id: episodeId, hash: episodeId === null ? null : getHashForEpisode(episodeId) }
}
function onEpisodeChange(i: number) {
items.value[i].hash = getHashForEpisode(Number(items.value[i].episode_id))
items.value[i].alreadyImported = false
const item = items.value[i]
if (!item) return
const newEpId = item.episode_id // v-model a déjà mis à jour la valeur
item.hash = getHashForEpisode(Number(newEpId))
item.alreadyImported = false
item.willRename = newEpId !== null
// Le fichier choisi remplace ceux déjà importés pour cet épisode
if (newEpId !== null) {
items.value = items.value.filter(other =>
other === item || !(other.episode_id === newEpId && other.alreadyImported)
)
}
}
// ─── Actions ──────────────────────────────────────────────────
// ── Actions ──
async function scan() {
scanning.value = true
scanError.value = ''
@@ -275,26 +258,26 @@ async function scan() {
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(serieFolder.value)}`, { credentials: 'include' })
if (!res.ok) {
const data = await res.json()
scanError.value = data.error ?? 'Erreur lors du scan'
scanError.value = data.error ?? "Impossible d'analyser le dossier."
return
}
const data = await res.json()
folderMissing.value = data.exists === false
const files: { name: string; path: string; size: number }[] = data.files ?? []
const organizedByPath = buildOrganizedByPath()
items.value = files.map(f => {
// Vérifier si déjà importé via dest_path
const existingEpId = organizedByPath.get(f.path) ?? null
if (existingEpId) {
return { file: f, episode_id: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
return { file: f, episode_id: existingEpId, originalEpisodeId: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
}
const match = autoMatch(f.name)
return { file: f, ...match, alreadyImported: false, willRename: match.episode_id !== null }
return { file: f, ...match, originalEpisodeId: null, alreadyImported: false, willRename: match.episode_id !== null }
})
} catch {
scanError.value = 'Impossible de contacter le serveur'
scanError.value = 'Impossible de contacter le serveur.'
} finally {
scanning.value = false
}
@@ -309,7 +292,7 @@ async function doImport() {
.map(i => ({ file_path: i.file.path, episode_id: i.episode_id, hash: i.hash }))
if (payload.length === 0) {
importError.value = 'Aucun nouveau fichier à importer'
importError.value = 'Aucun nouveau fichier à importer.'
importing.value = false
return
}
@@ -322,15 +305,37 @@ async function doImport() {
body : JSON.stringify({ serie_id: props.serieId, items: payload }),
})
const data = await res.json()
if (!res.ok) { importError.value = data.error ?? 'Erreur inconnue'; return }
if (!res.ok) {
importError.value = data.error ?? "Impossible d'importer les fichiers."
toast(data.error ?? "Impossible d'importer les fichiers", 'error')
return
}
importResult.value = { done: data.done, errors: data.errors ?? [] }
if (data.done > 0) {
toast(plural(data.done, 'fichier importé', 'fichiers importés'), 'success')
}
if ((data.errors ?? []).length > 0) {
toast(`${plural(data.errors.length, 'fichier')} en erreur`, 'error')
}
// Le parent recharge les épisodes importés
emit('imported')
step.value = 'done'
} catch {
importError.value = 'Impossible de contacter le serveur'
importError.value = 'Impossible de contacter le serveur.'
toast('Impossible de contacter le serveur', 'error')
} finally {
importing.value = false
}
}
function backToList() {
step.value = 'list'
scan() // nouvelle analyse pour marquer les fichiers importés
}
onMounted(scan)
</script>
+23 -20
View File
@@ -1,38 +1,40 @@
<template>
<Teleport to="body">
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
<div class="fixed bottom-4 right-4 left-4 sm:left-auto z-[70] flex flex-col items-end gap-2 pointer-events-none" role="status" aria-live="polite">
<TransitionGroup name="toast">
<div
v-for="t in toasts"
:key="t.id"
@click="remove(t.id)"
class="flex items-center gap-3 px-4 py-3 rounded-xl border text-sm cursor-pointer min-w-64 max-w-sm"
class="pointer-events-auto flex items-center gap-3 pl-3.5 pr-4 py-3 rounded-card border bg-card cursor-pointer w-full sm:w-auto sm:min-w-72 max-w-sm shadow-[0_16px_40px_rgb(0_0_0/0.5)]"
:class="{
'bg-card border-green-500/30 text-green-400' : t.type === 'success',
'bg-card border-red-500/30 text-red-400' : t.type === 'error',
'bg-card border-accent/30 text-accent' : t.type === 'info',
'border-ok/30' : t.type === 'success',
'border-err/35' : t.type === 'error',
'border-accent/35' : t.type === 'info',
}"
>
<svg v-if="t.type === 'success'" width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
<polyline points="2 7 5.5 10.5 12 3"/>
</svg>
<svg v-else-if="t.type === 'error'" width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
<line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
<circle cx="7" cy="7" r="5"/><line x1="7" y1="5" x2="7" y2="7.5"/><circle cx="7" cy="9.5" r="0.5" fill="currentColor"/>
</svg>
<span
class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
:class="{
'bg-ok/15 text-ok' : t.type === 'success',
'bg-err/15 text-err' : t.type === 'error',
'bg-accent-muted text-accent' : t.type === 'info',
}"
>
<Check v-if="t.type === 'success'" :size="13" :stroke-width="2.75" />
<X v-else-if="t.type === 'error'" :size="13" :stroke-width="2.75" />
<Info v-else :size="13" :stroke-width="2.5" />
</span>
<span class="flex-1 text-xs leading-snug">{{ t.message }}</span>
<span class="flex-1 text-meta text-primary leading-snug">{{ t.message }}</span>
<!-- Badge count si groupé -->
<span
v-if="t.count && t.count > 1"
class="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full"
class="shrink-0 pill h-5 px-2 text-[10.5px]"
:class="{
'bg-green-500/20 text-green-400' : t.type === 'success',
'bg-red-500/20 text-red-400' : t.type === 'error',
'bg-accent/20 text-accent' : t.type === 'info',
'pill-ok' : t.type === 'success',
'pill-err' : t.type === 'error',
'pill-active' : t.type === 'info',
}"
>
×{{ t.count }}
@@ -44,6 +46,7 @@
</template>
<script setup lang="ts">
import { Check, Info, X } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
const { toasts, remove } = useToast()
</script>
+95 -91
View File
@@ -1,122 +1,126 @@
<template>
<div>
<!-- Barre search + tri + colonnes -->
<div class="flex items-center gap-2 mb-4">
<div class="relative flex-1 max-w-xs">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-x-4 gap-y-2.5 md:flex-wrap">
<div class="chip-row">
<button
v-for="t in tabs" :key="t.value"
@click="emit('update:activeTab', t.value)"
class="chip"
:class="{
'is-active': activeTab === t.value,
'border-err/30 text-err hover:text-err': t.tone === 'err' && t.count > 0 && activeTab !== t.value,
}"
:aria-pressed="activeTab === t.value"
>
{{ t.label }}<span
class="chip-count"
:class="{
'text-accent! font-bold': t.tone === 'accent' && t.count > 0 && activeTab !== t.value,
'text-err! font-bold': t.tone === 'err' && t.count > 0 && activeTab !== t.value,
}"
>{{ t.count }}</span>
</button>
</div>
<div class="flex items-center gap-2.5 flex-wrap">
<label class="w-full sm:w-60 h-[34px] pointer-coarse:h-10 rounded-full bg-card border border-border-light flex items-center gap-[9px] pl-3.5 pr-1.5 cursor-text focus-within:border-accent transition-colors">
<Search :size="15" :stroke-width="1.75" class="text-muted shrink-0" />
<input
:value="search"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
type="text" placeholder="Rechercher..."
class="settings-input pl-9 w-full py-1.5 text-sm"
@keydown.esc="emit('update:search', '')"
type="text" placeholder="Filtrer" aria-label="Filtrer les torrents"
class="flex-1 min-w-0 bg-transparent outline-none text-[13px] text-primary placeholder:text-muted"
/>
</div>
<!-- Tri -->
<div class="relative shrink-0" ref="sortRef">
<button @click="sortOpen = !sortOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="activeSort !== 'none' ? 'border-accent text-accent' : ''">
<ArrowUpDown :size="14" />
<span class="hidden md:inline text-xs">Trier</span>
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
<button v-if="search" @click="emit('update:search', '')" aria-label="Effacer le filtre" class="w-6 h-6 rounded-full flex items-center justify-center text-muted hover:text-primary">
<X :size="13" />
</button>
<div v-if="sortOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl">
<button v-for="s in sortOptions" :key="s.value" @click="handleSort(s.value)"
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'">
</label>
<label class="flex items-center gap-[9px] text-meta text-secondary cursor-pointer select-none">
Masquer les importés
<button
type="button" role="switch" :aria-checked="hideImported"
@click="emit('update:hideImported', !hideImported)"
class="switch w-9 h-5"
><span class="switch-knob w-3.5 h-3.5" :class="{ 'translate-x-4!': hideImported }" /></button>
</label>
<div ref="optionsRef" class="relative">
<button
@click="optionsOpen = !optionsOpen"
class="w-[34px] h-[34px] pointer-coarse:w-10 pointer-coarse:h-10 rounded-full border flex items-center justify-center transition-colors relative"
:class="optionsOpen || activeSort !== 'none' || seedingOnly ? 'border-accent/40 text-accent' : 'border-border-light text-secondary hover:text-primary hover:bg-hover'"
aria-label="Tri et affichage" aria-haspopup="menu" :aria-expanded="optionsOpen"
>
<SlidersHorizontal :size="15" />
</button>
<div v-if="optionsOpen" class="menu absolute top-full right-0 mt-1.5 w-60 z-20" role="menu">
<p class="tag-label px-3 pt-1.5 pb-1">Trier</p>
<button
v-for="s in sortOptions" :key="s.value"
role="menuitem"
@click="emit('sort', s.value)"
class="menu-item justify-between"
:class="{ 'text-primary! bg-hover': activeSort === s.value }"
>
{{ s.label }}
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
<ArrowUp v-if="activeSort === s.value && sortDir === 'asc'" :size="14" class="text-accent" />
<ArrowDown v-else-if="activeSort === s.value && sortDir === 'desc'" :size="14" class="text-accent" />
</button>
</div>
</div>
<!-- Colonnes -->
<div class="relative shrink-0" ref="colRef">
<button @click="colOpen = !colOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="colOpen ? 'border-accent text-accent' : ''">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="hidden md:inline text-xs">Colonnes</span>
</button>
<div v-if="colOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-52 flex flex-col gap-0.5 shadow-xl">
<p class="text-[10px] text-muted px-3 py-1">Colonnes affichées</p>
<label v-for="col in columnOptions" :key="col.key" class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs cursor-pointer hover:bg-hover transition-colors">
<input type="checkbox" :checked="columns[col.key]" @change="emit('toggle-col', col.key)" class="accent-accent" />
<div class="h-px bg-hover my-1" />
<p class="tag-label px-3 pt-1.5 pb-1">Afficher</p>
<label v-for="col in columnOptions" :key="col.key" class="menu-item cursor-pointer">
<input type="checkbox" :checked="columns[col.key]" @change="emit('toggle-col', col.key)" class="w-4 h-4 rounded" />
{{ col.label }}
</label>
<div class="h-px bg-hover my-1" />
<label class="menu-item cursor-pointer justify-between">
Masquer les états inconnus
<button
type="button" role="switch" :aria-checked="seedingOnly"
@click="emit('update:seedingOnly', !seedingOnly)"
class="switch w-9 h-5"
><span class="switch-knob w-3.5 h-3.5" :class="{ 'translate-x-4!': seedingOnly }" /></button>
</label>
</div>
</div>
</div>
<!-- Onglets + filtres -->
<div class="flex items-center justify-between mb-4">
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
<button @click="emit('update:activeTab', 'active')" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'active' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
En cours
<span v-if="activeCount > 0" class="ml-1.5 text-[10px] text-accent">{{ activeCount }}</span>
</button>
<button @click="emit('update:activeTab', 'done')" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'done' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
Terminés
<span v-if="doneCount > 0" class="ml-1.5 text-[10px] text-accent">{{ doneCount }}</span>
</button>
</div>
<div v-if="activeTab === 'done'" class="flex items-center gap-3">
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-muted">Seeds uniquement</span>
<button @click="emit('update:seedingOnly', !seedingOnly)" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="seedingOnly ? 'bg-green-500' : 'bg-border'">
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="seedingOnly ? 'translate-x-[18px]' : 'translate-x-0'" />
</button>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-muted">Masquer importés</span>
<button @click="emit('update:hideImported', !hideImported)" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="hideImported ? 'bg-accent' : 'bg-border'">
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'" />
</button>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Search, ArrowUpDown } from 'lucide-vue-next'
import { ArrowDown, ArrowUp, Search, SlidersHorizontal, X } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
const props = defineProps<{
search : string
activeSort : string
sortDir : 'asc' | 'desc'
activeTab : 'active' | 'done'
seedingOnly : boolean
hideImported: boolean
columns : Record<string, boolean>
activeCount : number
doneCount : number
export interface ActivityTab { value: string; label: string; count: number; tone?: 'accent' | 'err' }
defineProps<{
search : string
activeSort : string
sortDir : 'asc' | 'desc'
activeTab : string
seedingOnly : boolean
hideImported : boolean
columns : Record<string, boolean>
tabs : ActivityTab[]
columnOptions: { key: string; label: string }[]
sortOptions : { label: string; value: string }[]
}>()
const emit = defineEmits<{
'update:search' : [value: string]
'update:activeTab' : [value: 'active' | 'done']
'update:seedingOnly': [value: boolean]
'update:search' : [value: string]
'update:activeTab' : [value: string]
'update:seedingOnly' : [value: boolean]
'update:hideImported': [value: boolean]
'sort' : [value: string]
'toggle-col' : [key: string]
'sort' : [value: string]
'toggle-col' : [key: string]
}>()
const sortOpen = ref(false)
const colOpen = ref(false)
const sortRef = ref<HTMLElement | null>(null)
const colRef = ref<HTMLElement | null>(null)
const optionsOpen = ref(false)
const optionsRef = ref<HTMLElement | null>(null)
onClickOutside(sortRef, () => { sortOpen.value = false })
onClickOutside(colRef, () => { colOpen.value = false })
function handleSort(val: string) {
emit('sort', val)
sortOpen.value = false
}
onClickOutside(optionsRef, () => { optionsOpen.value = false })
</script>
+171 -124
View File
@@ -1,157 +1,204 @@
<template>
<div class="settings-card flex flex-col gap-3">
<article class="bg-card rounded-xl px-4 py-3 flex flex-col md:flex-row md:items-center gap-x-4 gap-y-3">
<!-- Nom + état -->
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium truncate">{{ torrent.name }}</p>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(torrent.state).class">{{ stateBadge(torrent.state).label }}</span>
<span v-if="columns.client" class="text-xs text-muted">{{ torrent.client_name }}</span>
<div class="flex items-center gap-4 flex-1 min-w-0">
<RouterLink v-if="torrent.serieId" :to="`/series/${torrent.serieId}`" class="shrink-0">
<img v-if="poster" :src="poster" alt="" class="w-[34px] h-[51px] object-cover rounded-[4px]" />
<span v-else class="w-[34px] h-[51px] rounded-[4px] bg-hover text-muted flex items-center justify-center"><Tv :size="16" :stroke-width="1.75" /></span>
</RouterLink>
<span v-else class="w-[34px] h-[51px] rounded-[4px] bg-hover text-muted flex items-center justify-center shrink-0" title="Série non reconnue">
<FileQuestion :size="16" :stroke-width="1.75" />
</span>
<div class="flex-1 min-w-0 flex flex-col gap-[7px]">
<div class="flex items-center gap-2.5 min-w-0">
<div class="relative group/eps min-w-0">
<p class="text-sm font-medium truncate cursor-default" :class="isImported ? 'text-secondary' : 'text-primary'" :title="torrent.name">
{{ torrent.serieName && !torrent.name.startsWith(torrent.serieName) ? torrent.serieName : torrent.name }}
</p>
<div v-if="episodesWithProgress.length > 0" class="absolute bottom-full left-0 mb-2 hidden group-hover/eps:block z-20 min-w-56 max-w-80">
<div class="menu p-3 gap-0">
<p class="text-meta text-primary font-medium truncate mb-1">{{ torrent.name }}</p>
<p class="tag-label mb-2">{{ plural(episodesWithProgress.length, 'épisode') }}</p>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<div v-for="ep in episodesWithProgress" :key="ep.episode_id" class="flex items-center justify-between gap-3 text-meta">
<span class="text-primary shrink-0 tabular-nums">S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}</span>
<span
v-if="ep.progress !== null"
class="pill h-5 px-2 text-[10.5px]"
:class="ep.progress >= 100 ? 'pill-ok' : ep.priority === 0 ? 'pill-muted' : 'pill-active'"
>
{{ ep.priority === 0 ? 'Exclu' : ep.progress >= 100 ? '✓' : `${ep.progress} %` }}
</span>
</div>
</div>
</div>
</div>
</div>
<span class="pill h-5 px-2 text-[11px] shrink-0" :class="[badge.class, { 'max-sm:hidden': torrent.state === 'downloading' }]">{{ badge.label }}</span>
<span v-if="clientLine" class="text-[11.5px] text-muted shrink-0 hidden sm:inline">{{ clientLine }}</span>
<span v-if="!isDone" class="ml-auto text-[13px] tabular-nums shrink-0" :class="torrent.state === 'error' ? 'text-err' : 'text-primary'">{{ clampedProgress }}&nbsp;%</span>
</div>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-semibold text-primary">{{ clampedProgress }}%</p>
<p v-if="columns.size" class="text-xs text-muted mt-0.5">{{ formatSize(torrent.downloaded) }} / {{ formatSize(torrent.size) }}</p>
</div>
</div>
<!-- Barre de progression -->
<div class="h-1 bg-border rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500"
:class="torrent.state === 'seeding' || torrent.state === 'unknown' ? 'bg-green-500' : torrent.state === 'error' ? 'bg-red-500' : torrent.state === 'paused' ? 'bg-muted' : 'bg-accent'"
:style="{ width: `${clampedProgress}%` }" />
</div>
<!-- Infos + actions -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 text-xs text-muted">
<span v-if="torrent.state === 'downloading'">↓ {{ formatSpeed(torrent.speed) }}</span>
<span v-if="torrent.state === 'downloading' && torrent.eta > 0">ETA {{ formatEta(torrent.eta) }}</span>
<template v-if="torrent.state === 'seeding'">
<span class="text-green-500">Complété</span>
<span v-if="columns.ratio" class="font-mono" :class="torrent.ratio >= 1 ? 'text-green-400' : torrent.ratio >= 0.5 ? 'text-yellow-500' : 'text-muted'">R {{ torrent.ratio?.toFixed(2) ?? '0.00' }}</span>
<span v-if="columns.uploaded">↑ {{ formatSize(torrent.uploaded ?? 0) }}</span>
<span v-if="columns.upspeed && torrent.upspeed > 0">{{ formatSpeed(torrent.upspeed) }}</span>
<template v-if="!isDone">
<div class="progress">
<div class="progress-bar" :class="{ 'bg-muted': torrent.state === 'paused', 'bg-err': torrent.state === 'error' }" :style="{ width: `${clampedProgress}%` }" />
</div>
<div class="flex items-center gap-x-4 gap-y-1 flex-wrap text-xs text-muted">
<span v-if="torrent.state === 'downloading'">{{ formatSpeed(torrent.speed ?? 0) }}</span>
<span v-if="torrent.state === 'downloading' && torrent.eta > 0 && formatDuration(torrent.eta) !== '∞'">reste {{ formatDuration(torrent.eta) }}</span>
<span v-if="torrent.state === 'error'" class="text-err">Le client signale une erreur sur ce torrent</span>
<span v-if="columns.size">{{ formatSize(torrent.downloaded) }} sur {{ formatSize(torrent.size) }}</span>
</div>
</template>
<span v-if="torrent.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
</div>
<div class="flex items-center gap-2">
<!-- Actions terminés -->
<template v-if="torrent.state === 'seeding' || torrent.state === 'unknown'">
<!-- Erreurs -->
<div v-if="torrent.errorFiles?.length > 0" class="relative group/err">
<span class="text-xs px-2 py-0.5 rounded border border-red-500/40 text-red-400 cursor-default">{{ torrent.errorFiles.length }} erreur{{ torrent.errorFiles.length > 1 ? 's' : '' }}</span>
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
<div class="bg-card border border-red-500/30 rounded-lg p-3 text-xs shadow-xl">
<p class="text-red-400 mb-2 font-medium">Fichiers en erreur</p>
<div v-for="e in torrent.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
<p class="text-primary truncate">{{ e.file }}</p>
<template v-else>
<div v-if="torrent.errorFiles?.length > 0" class="relative group/err flex items-center gap-[9px] text-meta text-err min-w-0 w-fit max-w-full">
<TriangleAlert :size="14" :stroke-width="2" class="shrink-0" />
<span class="truncate">{{ errorSummary }}</span>
<div class="absolute bottom-full left-0 mb-2 hidden group-hover/err:block z-20 w-96 max-w-[80vw]">
<div class="menu p-3 gap-0 border-err/30">
<p class="tag-label text-err mb-2">Fichiers en erreur</p>
<div v-for="e in torrent.errorFiles" :key="e.file" class="mb-1.5 last:mb-0 text-meta">
<p class="text-primary truncate" :title="e.file">{{ e.file }}</p>
<p class="text-muted">{{ e.error }}</p>
</div>
</div>
</div>
</div>
<!-- Badge import -->
<span class="text-xs px-2 py-0.5 rounded border"
:class="torrent.organizeState === 'done' ? 'border-green-500/40 text-green-500' : torrent.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' : 'border-border text-muted'">
{{ torrent.organizeState === 'done'
? `Importé (${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total})`
: torrent.organizeState === 'partial'
? `En cours (${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total})`
: 'Non importé' }}
</span>
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing" class="btn-secondary text-xs py-1">
{{ importing ? '...' : 'Importer' }}
</button>
</template>
<!-- Bouton supprimer -->
<div class="relative">
<button
@click="emit('toggle-confirm', torrent.hash)"
:disabled="deleting"
class="w-7 h-7 flex items-center justify-center rounded-lg border border-border text-muted hover:border-red-500/40 hover:text-red-400 transition-colors"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
</svg>
</button>
<div v-if="showConfirmDelete" class="absolute bottom-full right-0 mb-2 bg-card border border-border rounded-xl p-3 z-10 w-52 shadow-xl">
<p class="text-xs text-primary font-medium mb-2">
{{ tab === 'active' ? 'Annuler ce téléchargement ?' : 'Supprimer ce torrent ?' }}
</p>
<label class="flex items-center gap-2 text-xs text-muted mb-3 cursor-pointer">
<input type="checkbox" v-model="withFiles" class="accent-red-500" />
Supprimer aussi les fichiers
</label>
<div class="flex gap-1.5">
<button @click="emit('delete', torrent, withFiles)" :disabled="deleting" class="flex-1 text-xs py-1 px-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-colors">
{{ deleting ? '...' : 'Confirmer' }}
</button>
<button @click="emit('toggle-confirm', null)" class="flex-1 text-xs py-1 px-2 rounded-lg border border-border text-muted hover:text-primary transition-colors">
Annuler
</button>
</div>
<div v-else class="flex items-center gap-x-4 gap-y-1 flex-wrap text-xs text-muted">
<span>
<template v-if="torrent.organizeProgress?.total > 1">{{ torrent.organizeProgress.total }} fichiers · </template>{{ formatSize(torrent.size) }}
</span>
<span v-if="torrent.state === 'unknown'" class="text-accent">Le client n'indique pas l'état de ce torrent</span>
<span v-if="isImported && torrent.importedAt">importé {{ formatRelative(torrent.importedAt) }}</span>
<span v-else-if="!isImported && autoImportIn">import automatique {{ autoImportIn }}</span>
<span v-if="columns.uploaded && torrent.uploaded">Envoyé {{ formatSize(torrent.uploaded) }}</span>
<span v-if="columns.upspeed && torrent.upspeed > 0">Envoi {{ formatSpeed(torrent.upspeed) }}</span>
</div>
</div>
</template>
</div>
</div>
</div>
<div class="md:w-[260px] shrink-0 flex items-center justify-end gap-2.5 flex-wrap">
<template v-if="!isDone">
<span class="text-meta text-muted max-md:mr-auto">{{ torrent.serieName ? (autoImport ? 'Import automatique à la fin' : 'À importer une fois terminé') : 'Série non reconnue' }}</span>
</template>
<template v-else>
<span v-if="isImported" class="flex items-center gap-1.5 text-meta text-ok max-md:mr-auto">
<Check :size="14" :stroke-width="2.5" /> Importé {{ torrent.organizeProgress?.done }}/{{ torrent.organizeProgress?.total }}
</span>
<span v-else class="text-meta text-secondary max-md:mr-auto">
{{ torrent.organizeState === 'partial' ? `Importé ${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total}` : 'Non importé' }}
</span>
<button
v-if="!isImported"
@click="emit('import', torrent)"
:disabled="importing"
class="btn-sm min-w-[88px] max-md:min-w-[128px]"
:class="torrent.errorFiles?.length > 0 || torrent.organizeState === 'partial' ? 'btn-secondary' : 'btn-primary'"
>
<Loader v-if="importing" :size="13" class="animate-spin" />
{{ importing ? 'Import…' : torrent.errorFiles?.length > 0 ? 'Réessayer' : 'Importer' }}
</button>
</template>
<div class="relative">
<button
@click="emit('toggle-confirm', showConfirmDelete ? null : torrent.hash)"
:disabled="deleting"
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-err hover:border-err/30 transition-colors"
:title="isDone ? 'Supprimer le torrent' : 'Arrêter le téléchargement'"
:aria-label="isDone ? 'Supprimer le torrent' : 'Arrêter le téléchargement'"
>
<Trash2 :size="14" />
</button>
<div v-if="showConfirmDelete" class="menu absolute bottom-full right-0 mb-2 w-60 z-20 p-3 gap-3">
<p class="text-body text-primary font-medium">
{{ isDone ? 'Supprimer ce torrent du client ?' : 'Arrêter ce téléchargement ?' }}
</p>
<label class="flex items-center gap-2 text-meta text-secondary cursor-pointer">
<input type="checkbox" v-model="withFiles" class="w-4 h-4 rounded" />
Supprimer aussi les fichiers du disque
</label>
<div class="flex gap-2">
<button @click="emit('delete', torrent, withFiles)" :disabled="deleting" class="btn-danger btn-sm flex-1">
{{ deleting ? 'Suppression…' : isDone ? 'Supprimer' : 'Arrêter' }}
</button>
<button @click="emit('toggle-confirm', null)" class="btn-ghost btn-sm flex-1">{{ isDone ? 'Garder' : 'Continuer' }}</button>
</div>
</div>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router'
import { Check, FileQuestion, Loader, Trash2, TriangleAlert, Tv } from 'lucide-vue-next'
import { formatDuration, formatRelative, formatSize, formatSpeed, plural } from '@/utils/format'
const props = defineProps<{
torrent : any
tab : 'active' | 'done'
columns : Record<string, boolean>
importing : boolean
deleting : boolean
torrent : any
columns : Record<string, boolean>
importing : boolean
deleting : boolean
showConfirmDelete: boolean
poster? : string | null
autoImport? : boolean
autoImportIn? : string
}>()
const emit = defineEmits<{
import : [torrent: any]
delete : [torrent: any, withFiles: boolean]
import : [torrent: any]
delete : [torrent: any, withFiles: boolean]
'toggle-confirm': [hash: string | null]
}>()
const withFiles = ref(false)
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.torrent.progress ?? 0)))
const isDone = computed(() => props.torrent.state === 'seeding' || props.torrent.state === 'unknown')
const isImported = computed(() => isDone.value && props.torrent.organizeState === 'done')
function stateBadge(state: string) {
return ({
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
paused : { label: 'En pause', class: 'border-border text-muted' },
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
} as Record<string, any>)[state] ?? { label: 'Inconnu', class: 'border-border text-muted' }
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
function formatSpeed(bps: number) { return `${formatSize(bps)}/s` }
function formatEta(seconds: number): string {
if (seconds < 0 || seconds > 86400 * 7) return '∞'
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
return `${s}s`
}
const clientLine = computed(() => [
props.columns.client ? props.torrent.client_name : null,
isDone.value && props.columns.ratio ? `Ratio ${(props.torrent.ratio ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : null,
].filter(Boolean).join(' · '))
const errorSummary = computed(() => {
const errs: { file: string; error: string }[] = props.torrent.errorFiles ?? []
if (!errs.length) return ''
const name = errs[0]!.file.split(/[\\/]/).pop() ?? errs[0]!.file
const ep = name.match(/S\d+E(\d+)/i)
return `${ep ? `E${ep[1]}` : name} · ${errs[0]!.error}${errs.length > 1 ? ` (+${errs.length - 1})` : ''}`
})
const episodesWithProgress = computed(() => {
const eps: any[] = props.torrent.episodes ?? []
if (!eps.length) return []
return eps.map((ep: any) => {
const file = (props.torrent.files ?? []).find((f: any) => {
const basename = String(f.name ?? '').replace(/\\/g, '/').split('/').pop() ?? ''
return basename.toLowerCase() === ep.filename?.toLowerCase()
})
return {
...ep,
progress: file != null ? Math.round((file.progress ?? 0) * 100) : null,
priority: file?.priority ?? 1,
}
})
})
const badge = computed(() => ({
downloading: { label: 'Téléchargement', class: 'pill-active' },
seeding : { label: 'Terminé', class: 'pill-ok' },
paused : { label: 'En pause', class: 'pill-neutral' },
checking : { label: 'Vérification', class: 'pill-wait' },
error : { label: 'Erreur', class: 'pill-err' },
unknown : { label: 'État inconnu', class: 'pill-wait' },
} as Record<string, { label: string; class: string }>)[props.torrent.state] ?? { label: 'État inconnu', class: 'pill-muted' })
</script>
+106
View File
@@ -0,0 +1,106 @@
<template>
<header class="flex items-center gap-1 pl-4 pr-2 h-14 shrink-0 bg-main">
<RouterLink to="/dashboard" class="flex-1 min-w-0 flex items-center gap-2.5">
<FankarrLogo class="w-6 h-[25px] shrink-0" />
<span class="font-display text-xl font-bold tracking-[0.01em] text-primary">FanKarr</span>
</RouterLink>
<button
@click="librarySearch.open()"
aria-label="Rechercher une série"
class="w-11 h-11 rounded-full flex items-center justify-center text-secondary hover:text-primary transition-colors"
>
<Search :size="19" :stroke-width="1.75" />
</button>
<div ref="menuRef" class="relative">
<button
data-tour="help"
@click="open = !open"
aria-label="Menu du compte"
aria-haspopup="menu"
:aria-expanded="open"
class="relative w-11 h-11 rounded-full flex items-center justify-center"
>
<span class="w-[34px] h-[34px] rounded-full bg-card border border-border text-accent font-display text-base font-bold flex items-center justify-center">
{{ initial }}
</span>
<span v-if="auth.isAdmin && updateAvailable" class="absolute top-[7px] right-[7px] w-2 h-2 rounded-full bg-accent ring-2 ring-main" />
</button>
<div v-if="open" role="menu" class="menu absolute top-full right-0 mt-1 w-64 z-40" @keydown.esc="open = false">
<div class="flex flex-col gap-px px-3 pt-2 pb-2.5 mb-1 border-b border-hover">
<span class="text-body font-medium text-primary truncate">{{ auth.username }}</span>
<span class="text-meta text-muted truncate">{{ auth.isAdmin ? 'Administrateur' : 'Invité' }}<template v-if="currentVersion"> · {{ currentVersion }}</template></span>
</div>
<RouterLink v-if="auth.isAdmin" to="/settings" role="menuitem" class="menu-item" @click="open = false">
<Settings :size="15" class="shrink-0" /> Paramètres
</RouterLink>
<button role="menuitem" class="menu-item" @click="startTour">
<Compass :size="15" class="shrink-0" /> Visite guidée
</button>
<button v-if="auth.isAdmin" role="menuitem" class="menu-item" @click="openSetup">
<Wand :size="15" class="shrink-0" /> Assistant de configuration
</button>
<a
v-if="auth.isAdmin && updateAvailable"
:href="latestReleaseUrl"
target="_blank"
rel="noopener"
role="menuitem"
class="menu-item text-accent hover:text-accent"
@click="open = false"
>
<ExternalLink :size="15" class="shrink-0" /> Mise à jour {{ latestVersion }}
</a>
<div class="h-px bg-hover my-1" />
<button role="menuitem" class="menu-item text-err hover:text-err" @click="logout">
<LogOut :size="15" class="shrink-0" /> Se déconnecter
</button>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { onClickOutside } from '@vueuse/core'
import { Compass, ExternalLink, LogOut, Search, Settings, Wand } from 'lucide-vue-next'
import FankarrLogo from '@/components/FankarrLogo.vue'
import { useAuthStore } from '@/stores/auth'
import { useTourStore } from '@/stores/tour'
import { useLibrarySearch } from '@/composables/useLibrarySearch'
import { useUpdateCheck } from '@/composables/useUpdateCheck'
const router = useRouter()
const auth = useAuthStore()
const tour = useTourStore()
const librarySearch = useLibrarySearch()
const { currentVersion, latestVersion, latestReleaseUrl, updateAvailable } = useUpdateCheck()
const initial = computed(() => auth.username?.charAt(0).toUpperCase() ?? '?')
const open = ref(false)
const menuRef = ref<HTMLElement | null>(null)
onClickOutside(menuRef, () => { open.value = false })
function startTour() {
open.value = false
tour.start()
}
function openSetup() {
open.value = false
router.push('/setup')
}
async function logout() {
open.value = false
await auth.logout()
router.push('/auth')
}
</script>
+39
View File
@@ -0,0 +1,39 @@
<template>
<nav aria-label="Navigation principale" class="shrink-0 bg-sidebar border-t border-card pb-[env(safe-area-inset-bottom)]">
<div class="flex h-16">
<RouterLink
v-for="item in items"
:key="item.to"
:to="item.to ?? '/'"
:data-tour="item.tour"
:aria-current="isActive(item) ? 'page' : undefined"
class="flex-1 min-w-0 flex flex-col items-center justify-center gap-1 text-[11.5px] transition-colors"
:class="isActive(item) ? 'text-accent font-bold' : 'text-muted font-medium hover:text-secondary'"
>
<span class="relative">
<component :is="item.icon" :size="22" :stroke-width="1.75" />
<span
v-if="item.badge && item.badgeStyle === 'dot'"
class="absolute -top-0.5 -right-1 w-[7px] h-[7px] rounded-full bg-accent ring-2 ring-sidebar"
/>
<span
v-else-if="item.badge"
class="absolute -top-1.5 -right-2.5 min-w-[18px] h-[18px] px-1 rounded-full bg-accent text-on-accent text-[10.5px] font-bold tabular-nums flex items-center justify-center ring-2 ring-sidebar"
>{{ item.badge }}</span>
</span>
<span class="max-w-full px-1 truncate">{{ item.label }}</span>
</RouterLink>
</div>
</nav>
</template>
<script setup lang="ts">
import { RouterLink, useRoute } from 'vue-router'
import type { NavItem } from '@/types/nav'
defineProps<{ items: NavItem[] }>()
const route = useRoute()
const isActive = (item: NavItem) => !!item.to && route.path.startsWith(item.to)
</script>
+74 -94
View File
@@ -1,138 +1,118 @@
<template>
<aside class="flex flex-col h-full bg-sidebar border-r border-border">
<aside class="flex flex-col h-full bg-sidebar border-r border-card px-3.5 pt-[22px] pb-[18px]">
<!-- Logo + logout -->
<div class="flex items-center gap-2.5 px-4 py-3.5 border-b border-border min-h-[56px]">
<svg class="w-7 h-7 shrink-0" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
</svg>
<RouterLink to="/series" class="text-[15px] font-semibold text-primary tracking-wide flex-1 hover:text-accent transition-colors">
FanKarr
<div class="flex items-center gap-2 pl-2 pb-7">
<RouterLink to="/dashboard" class="flex-1 min-w-0 flex items-center gap-[11px]">
<FankarrLogo class="w-[30px] h-[31px] shrink-0" />
<span class="font-display text-[23px] font-bold tracking-[0.01em] text-primary">FanKarr</span>
</RouterLink>
<button
@click="logout"
title="Se déconnecter"
class="text-muted hover:text-red-400 transition-colors p-1 rounded-md hover:bg-hover"
>
<LogOut :size="15" />
</button>
<div ref="helpRef" class="relative">
<button
data-tour="help"
@click="helpOpen = !helpOpen"
aria-haspopup="menu"
:aria-expanded="helpOpen"
title="Aide"
class="w-8 h-8 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors"
>
<CircleQuestionMark :size="16" :stroke-width="1.75" />
</button>
<div
v-if="helpOpen"
role="menu"
class="menu absolute top-full left-0 mt-2 w-60 z-30"
@keydown.esc="helpOpen = false"
>
<button role="menuitem" @click="startTour" class="menu-item">
<Compass :size="15" class="shrink-0" /> Visite guidée
</button>
<button v-if="auth.isAdmin" role="menuitem" @click="openSetup" class="menu-item">
<Wand :size="15" class="shrink-0" /> Assistant de configuration
</button>
</div>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 flex flex-col gap-0.5">
<nav class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden flex flex-col gap-0.5">
<template v-for="(item, i) in items" :key="i">
<hr v-if="item.separator" class="border-border my-2 mx-1" />
<div v-if="item.separator" class="h-px bg-card mx-2 my-3.5 shrink-0" />
<SidebarNavItem v-else :item="item" />
</template>
</nav>
<!-- Nouvelle version disponible -->
<a
v-if="updateAvailable"
v-if="auth.isAdmin && updateAvailable"
:href="latestReleaseUrl"
target="_blank"
rel="noopener"
class="mx-2 mb-2 flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-accent/30 bg-accent/5 hover:bg-accent/10 transition-colors group"
class="mb-3 flex items-center gap-2.5 px-3 py-2.5 rounded-field bg-accent-muted hover:bg-accent/20 transition-colors group"
>
<div class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-xs font-medium text-accent">Mise à jour disponible</p>
<p class="text-[11px] text-muted truncate">{{ latestVersion }} est disponible</p>
</div>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
class="shrink-0 text-muted group-hover:text-accent transition-colors">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
<span class="flex-1 min-w-0">
<span class="block text-meta font-bold text-accent">Mise à jour disponible</span>
<span class="block text-[11.5px] text-muted truncate">Version {{ latestVersion }}</span>
</span>
<ExternalLink :size="13" class="shrink-0 text-muted group-hover:text-accent transition-colors" />
</a>
<!-- Version actuelle -->
<div class="px-3 pb-1 pt-0.5">
<p class="text-[10px] text-muted/50 font-mono">{{ currentVersion }}</p>
<div class="flex items-center gap-2 pl-2 pt-3.5 border-t border-card">
<span class="w-[34px] h-[34px] rounded-full bg-hover text-accent flex items-center justify-center font-display text-base font-bold shrink-0">
{{ initial }}
</span>
<span class="flex-1 min-w-0 flex flex-col gap-px">
<span class="text-[13px] font-medium text-primary truncate">{{ auth.username }}</span>
<span class="text-[11.5px] text-muted truncate">{{ auth.isAdmin ? 'Administrateur' : 'Invité' }}<template v-if="currentVersion"> · {{ currentVersion }}</template></span>
</span>
<button
@click="logout"
title="Se déconnecter"
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0 text-muted hover:text-err hover:bg-hover transition-colors"
>
<LogOut :size="16" :stroke-width="1.75" />
</button>
</div>
<!-- Thème -->
<ThemeSwitcher />
</aside>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { LogOut } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
import { CircleQuestionMark, Compass, ExternalLink, LogOut, Wand } from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
import { useTourStore } from '@/stores/tour'
import { useUpdateCheck } from '@/composables/useUpdateCheck'
import SidebarNavItem from './SidebarNavItem.vue'
import ThemeSwitcher from './ThemeSwitcher.vue'
import FankarrLogo from '@/components/FankarrLogo.vue'
import type { NavItem } from '@/types/nav'
defineProps<{ items: NavItem[] }>()
const router = useRouter()
const auth = useAuthStore()
const tour = useTourStore()
const currentVersion = ref(
import.meta.env.DEV ? 'dev' : ''
)
const latestVersion = ref('')
const latestReleaseUrl = ref('https://github.com/masutayunikon/FanKarr/releases/latest')
const updateAvailable = ref(false)
const initial = computed(() => auth.username?.charAt(0).toUpperCase() ?? '?')
const GITHUB_REPO = 'masutayunikon/FanKarr'
const helpOpen = ref(false)
const helpRef = ref<HTMLElement | null>(null)
onClickOutside(helpRef, () => { helpOpen.value = false })
function parseVersion(v: string): number[] {
return v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
function startTour() {
helpOpen.value = false
tour.start()
}
function isNewer(latest: string, current: string): boolean {
if (current === 'dev') return false
const l = parseVersion(latest)
const c = parseVersion(current)
for (let i = 0; i < Math.max(l.length, c.length); i++) {
const lv = l[i] ?? 0
const cv = c[i] ?? 0
if (lv > cv) return true
if (lv < cv) return false
}
return false
function openSetup() {
helpOpen.value = false
router.push('/setup')
}
async function checkForUpdates() {
try {
// Version locale
const vRes = await fetch('/api/version', { credentials: 'include' })
if (vRes.ok) {
const { version } = await vRes.json()
currentVersion.value = version
}
if (currentVersion.value === 'dev') return
// Dernière release GitHub
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
headers: { Accept: 'application/vnd.github.v3+json' },
})
if (!gRes.ok) return
const release = await gRes.json()
latestVersion.value = release.tag_name ?? ''
latestReleaseUrl.value = release.html_url ?? latestReleaseUrl.value
if (isNewer(latestVersion.value, currentVersion.value)) {
updateAvailable.value = true
}
} catch {}
}
const { currentVersion, latestVersion, latestReleaseUrl, updateAvailable } = useUpdateCheck()
async function logout() {
await auth.logout()
router.push('/auth')
}
onMounted(() => {
checkForUpdates()
// Revérifier toutes les 6h
setInterval(checkForUpdates, 6 * 60 * 60 * 1000)
})
</script>
</script>
+28 -88
View File
@@ -1,102 +1,42 @@
<template>
<div>
<RouterLink
:to="item.to ?? '/'"
:data-tour="item.tour"
class="group flex items-center gap-3 h-10 px-3 rounded-lg text-sm font-medium transition-colors"
:class="isActive ? 'bg-hover text-primary' : 'text-secondary hover:bg-hover/60 hover:text-primary'"
>
<component
:is="item.to ? RouterLink : 'button'"
:to="item.to ?? undefined"
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors duration-100 text-left group"
:class="isActive
? 'bg-active text-primary'
: 'text-secondary hover:bg-hover hover:text-primary'"
@click="item.children && !item.to && toggleOpen()"
:is="item.icon"
:size="18"
:stroke-width="1.75"
class="shrink-0 transition-colors"
:class="isActive ? 'text-accent' : 'text-muted group-hover:text-secondary'"
/>
<span class="flex-1 truncate">{{ item.label }}</span>
<span
v-if="item.badge && item.badgeStyle === 'dot'"
class="flex items-center gap-1.5 text-xs font-bold text-secondary"
>
<component
:is="iconComponent"
:size="17"
class="shrink-0 transition-colors"
:class="isActive ? 'text-accent' : 'text-muted group-hover:text-secondary'"
/>
<span class="flex-1 text-sm truncate">{{ item.label }}</span>
<!-- Badge animé (ex: downloads actifs) -->
<span
v-if="item.badge"
class="min-w-[18px] h-[18px] px-1 rounded-full bg-accent text-white text-[10px] font-medium flex items-center justify-center"
>
{{ item.badge }}
</span>
<!-- Chevron (seulement si pas de badge) -->
<ChevronRight
v-else-if="item.children"
:size="13"
class="shrink-0 text-muted transition-transform duration-200"
:class="isOpen ? 'rotate-90' : ''"
/>
</component>
<Transition name="slide">
<div v-if="item.children && isOpen" class="ml-[34px] mt-0.5 flex flex-col gap-0.5">
<RouterLink
v-for="child in item.children"
:key="child.to"
:to="child.to"
class="flex items-center gap-2 px-2.5 py-1.5 rounded-md transition-colors duration-100"
:class="route.path === child.to
? 'text-accent'
: 'text-muted hover:text-secondary'"
>
<span
class="w-1 h-1 rounded-full shrink-0 transition-colors"
:class="route.path === child.to ? 'bg-accent' : 'bg-current opacity-40'"
/>
<span class="text-xs">{{ child.label }}</span>
</RouterLink>
</div>
</Transition>
</div>
<span class="w-1.5 h-1.5 rounded-full bg-accent" />{{ item.badge }}
</span>
<span
v-else-if="item.badge"
class="min-w-[22px] h-5 px-[7px] rounded-full bg-accent-muted text-accent text-xs font-bold flex items-center justify-center"
>
{{ item.badge }}
</span>
</RouterLink>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { ChevronRight } from 'lucide-vue-next'
import * as Icons from 'lucide-vue-next'
import type { NavItem } from '@/types/nav'
const props = defineProps<{ item: NavItem }>()
const route = useRoute()
const iconComponent = computed(() => {
if (!props.item.icon) return Icons.Circle
return (Icons as Record<string, unknown>)[props.item.icon] ?? Icons.Circle
})
const isActive = computed(() =>
props.item.to
? route.path.startsWith(props.item.to)
: props.item.children?.some(c => route.path.startsWith(c.to))
)
const isOpen = ref(isActive.value)
watch(isActive, (val) => { if (val) isOpen.value = true })
function toggleOpen() {
isOpen.value = !isOpen.value
}
const isActive = computed(() => !!props.item.to && route.path.startsWith(props.item.to))
</script>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: max-height 0.2s ease, opacity 0.15s ease;
max-height: 300px;
overflow: hidden;
}
.slide-enter-from,
.slide-leave-to {
max-height: 0;
opacity: 0;
}
</style>
-45
View File
@@ -1,45 +0,0 @@
<template>
<div class="px-3 py-3 border-t border-border">
<p class="text-xs text-muted mb-2 uppercase tracking-widest">Thème</p>
<div class="flex gap-2">
<button
v-for="theme in themes"
:key="theme.id"
:title="theme.label"
@click="setTheme(theme.id)"
class="w-6 h-6 rounded-full border-2 transition-all duration-150 flex items-center justify-center"
:style="{ backgroundColor: theme.color }"
:class="current === theme.id
? 'border-primary scale-110'
: 'border-transparent opacity-50 hover:opacity-80'"
>
<span v-if="current === theme.id" class="block w-1.5 h-1.5 rounded-full bg-white/80" />
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const STORAGE_KEY = 'fankarr-theme'
const themes = [
{ id: 'indigo', label: 'Indigo Électrique', color: '#7B7EF8' },
{ id: 'crimson', label: 'Crimson Night', color: '#E74C3C' },
{ id: 'teal', label: 'Teal Froid', color: '#00CDBF' },
]
const current = ref('indigo')
function setTheme(id) {
current.value = id
document.documentElement.setAttribute('data-theme', id)
localStorage.setItem(STORAGE_KEY, id)
}
onMounted(() => {
const saved = localStorage.getItem(STORAGE_KEY) ?? 'indigo'
setTheme(saved)
})
</script>
@@ -0,0 +1,31 @@
<template>
<Teleport to="body">
<div class="modal-backdrop" @click.self="emit('close')">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="reject-title">
<div class="flex flex-col gap-1">
<h2 id="reject-title" class="card-title">Refuser la demande</h2>
<p class="text-meta text-muted truncate">{{ serieName }}</p>
</div>
<div class="flex flex-col gap-[7px]">
<label for="reject-message" class="field-label">Motif (facultatif)</label>
<input id="reject-message" ref="inputRef" v-model="message" type="text" class="field" placeholder="Visible par l'invité" @keyup.enter="emit('confirm', message)" />
</div>
<div class="flex gap-2.5 justify-end">
<button @click="emit('close')" class="btn-ghost">Annuler</button>
<button @click="emit('confirm', message)" class="btn-danger">Refuser</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
defineProps<{ serieName: string }>()
const emit = defineEmits<{ close: []; confirm: [message: string] }>()
const message = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
onMounted(() => inputRef.value?.focus())
</script>
+86 -50
View File
@@ -1,74 +1,110 @@
<template>
<div class="relative">
<div v-if="serie.fanart_image" class="absolute inset-0 h-64 overflow-hidden pointer-events-none">
<section class="relative">
<!-- overflow-hidden ici et pas sur la section, qui couperait les menus -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<img
:src="serie.fanart_image"
class="w-full h-full object-cover opacity-15"
@error="(e) => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'"
v-if="serie.poster_image"
:src="serie.poster_image"
alt=""
class="absolute inset-0 w-full h-full object-cover blur-[40px] saturate-[1.25] scale-[1.4] opacity-50"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-main" />
<div class="absolute inset-0 bg-linear-100 from-main/97 via-main/86 via-52% to-main/40" />
<div class="dot-grid [--dot-grid-angle:100deg] [--dot-grid-fade:60%]" />
</div>
<div class="relative px-4 md:px-8 pt-6 pb-10 flex 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">
<div class="relative grid grid-cols-[96px_minmax(0,1fr)] gap-x-4 gap-y-4 sm:flex sm:items-start sm:gap-7 px-4 md:px-10 pt-4 pb-5 sm:py-6">
<div class="shrink-0 w-[96px] sm:w-[152px] sm:mt-[22px]">
<img
v-if="serie.poster_image"
:src="serie.poster_image"
:alt="serie.title"
class="w-[96px] h-[144px] sm:w-[152px] sm:h-[228px] object-cover rounded-lg shadow-[0_22px_44px_rgb(0_0_0/0.5),0_0_0_1px_rgb(239_233_221/0.08)]"
/>
<div v-else class="w-[96px] h-[144px] sm:w-[152px] sm:h-[228px] rounded-lg bg-card flex items-center justify-center text-muted">
<Tv :size="28" />
</div>
</div>
<!-- Infos -->
<div class="flex flex-col gap-3 justify-end min-w-0">
<RouterLink to="/series" class="inline-flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-fit">
<ArrowLeft :size="13" />
Séries
</RouterLink>
<div class="contents sm:flex sm:flex-1 sm:min-w-0 sm:flex-col sm:gap-2.5">
<div class="min-w-0 flex flex-col gap-2.5">
<RouterLink to="/series" class="inline-flex items-center gap-[7px] min-h-7 text-meta text-secondary hover:text-primary transition-colors w-fit">
<ChevronLeft :size="14" :stroke-width="2" />
Médiathèque
</RouterLink>
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ serie.title }}</h1>
<h1 class="font-display text-[26px] sm:text-[42px] leading-[1.1] font-extrabold text-primary">{{ serie.title }}</h1>
<!-- Métadonnées -->
<div class="flex flex-wrap items-center gap-2">
<span v-if="serie.year" class="text-sm text-muted">{{ serie.year }}</span>
<span
v-if="serie.status"
class="text-xs px-2 py-0.5 rounded-full border"
:class="serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/15 text-green-400 border-green-500/20'
: 'bg-hover text-muted border-border'"
>{{ serie.status }}</span>
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<span v-if="serie.year" class="text-body text-secondary">{{ serie.year }}</span>
<span
v-if="serie.status"
class="pill h-[22px] px-2.5"
:class="serie.status.toLowerCase() === 'continuing' ? 'pill-ok' : 'pill-neutral'"
>{{ statusLabel(serie.status) }}</span>
<span v-if="episodeCount" class="text-body text-secondary">
<template v-if="seasonCount">{{ plural(seasonCount, 'saison') }} · </template>{{ plural(episodeCount, 'épisode') }}
</span>
<span v-if="watched" class="pill pill-wait h-[22px] px-2.5">
<Rss :size="11" :stroke-width="2.5" /> Surveillée
</span>
<a
v-if="serie.wiki"
:href="serie.wiki"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-meta text-secondary hover:text-accent transition-colors"
>
<ExternalLink :size="12" /> Wiki
</a>
</div>
</div>
<p
v-if="serie.plot"
@click="expanded = !expanded"
class="text-sm text-secondary leading-relaxed max-w-2xl cursor-pointer select-none transition-all"
:class="expanded ? '' : 'line-clamp-3'"
>
{{ serie.plot }}
</p>
<div class="col-span-2 min-w-0 flex flex-col gap-2.5 empty:hidden">
<p
v-if="serie.plot"
@click="expanded = !expanded"
class="text-body leading-[1.6] text-secondary max-w-[640px] text-pretty cursor-pointer select-none"
:class="expanded ? '' : 'line-clamp-3'"
:title="expanded ? 'Réduire' : 'Lire la suite'"
>
{{ serie.plot }}
</p>
<!-- 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 v-if="episodeCount" class="flex items-center gap-3.5 mt-0.5 flex-wrap">
<div class="w-[260px] max-w-full h-1 rounded-[2px] bg-hover overflow-hidden">
<div
class="h-full rounded-[2px] transition-[width] duration-500"
:class="organizedCount >= episodeCount ? 'bg-ok' : 'bg-accent'"
:style="{ width: `${Math.round(organizedCount / episodeCount * 100)}%` }"
/>
</div>
<span class="text-meta text-secondary">{{ plural(organizedCount, `épisode ${organizedLabel}`, `épisodes ${organizedLabel}s`) }} sur {{ episodeCount }}</span>
</div>
<div v-if="$slots.actions" class="flex items-center gap-2.5 mt-1.5 max-sm:flex-nowrap flex-wrap">
<slot name="actions" />
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { ArrowLeft, Tv, ExternalLink } from 'lucide-vue-next'
import { ChevronLeft, ExternalLink, Rss, Tv } from 'lucide-vue-next'
import { statusLabel } from '@/utils/series'
import { plural } from '@/utils/format'
defineProps<{
serie : any
seasonCount : number
episodeCount : number
organizedCount: number
organizedLabel: string
watched? : boolean
}>()
defineProps<{ serie: any }>()
const expanded = ref(false)
</script>
+390 -350
View File
@@ -1,304 +1,310 @@
<template>
<div class="bg-card border border-border rounded-xl">
<!-- Header saison -->
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
<div class="flex items-center gap-3">
<button class="text-muted hover:text-primary transition-colors" @click="emit('toggle', season.id)">
<ChevronUp :size="15" class="transition-transform duration-200" :class="collapsed ? 'rotate-180' : ''" />
</button>
<div>
<h2 class="text-sm font-semibold text-primary">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted font-normal ml-1">
— {{ season.title }}
</span>
</h2>
<p class="text-xs text-muted mt-0.5">
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
<template v-if="availableCount > 0"> · {{ availableCount }}/{{ season.episodes.length }} dispo</template>
<span
v-if="season.organized_state !== 'none'"
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px]"
:class="season.organized_state === 'complete' ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-500'"
>
{{ season.organized_state === 'complete'
? `✓ ${season.organized_count} importé${season.organized_count > 1 ? 's' : ''}`
: `${season.organized_count}/${availableCount || season.episodes.length} importés` }}
</span>
</p>
</div>
<section class="bg-card rounded-card">
<div class="flex items-center gap-3.5 px-4 sm:px-5 py-3.5 flex-wrap" :class="{ 'border-b border-hover': !collapsed }">
<button
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 -ml-1.5 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors shrink-0"
:aria-expanded="!collapsed"
:aria-label="collapsed ? 'Déplier la saison' : 'Replier la saison'"
@click="emit('toggle', season.id)"
>
<ChevronUp :size="16" :stroke-width="2" class="transition-transform duration-200" :class="collapsed ? 'rotate-180' : ''" />
</button>
<div class="flex-1 min-w-[160px] flex flex-col gap-[3px] cursor-pointer" @click="emit('toggle', season.id)">
<h2 class="font-display text-[17px] font-bold text-primary">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="font-sans text-sm font-normal text-muted">· {{ season.title }}</span>
</h2>
<p class="text-meta text-muted">{{ seasonMeta }}</p>
</div>
<!-- Boutons saison -->
<div class="flex items-center gap-2">
<!-- Corbeille saison -->
<button
v-if="season.organized_count > 0"
@click.stop="openUnimportSeasonModal"
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20"
title="Désimporter la saison"
>
<Trash2 :size="14" />
</button>
<!-- Pas de pack saison : bouton "Saison" (dropdown si plusieurs packs via intégrales) -->
<template v-if="season.torrents.length === 0 && hasDownloadable">
<div v-if="uniquePackOptions.length > 1" class="relative" @click.stop>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
@click="seasonMenuOpen = !seasonMenuOpen"
>
<component :is="downloadIcon" />
Saison ▾
</button>
<div v-if="seasonMenuOpen" class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-56 shadow-xl flex flex-col gap-0.5">
<button
v-for="opt in uniquePackOptions"
:key="opt.infohash"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
@click="emit('downloadSeason', season, opt.infohash); seasonMenuOpen = false"
>
<component :is="downloadIcon" />
{{ opt.label }}
</button>
</div>
</div>
<div class="flex items-center gap-2.5 flex-wrap">
<!-- ── Mode demande ── -->
<template v-if="requestMode">
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok">
<Check :size="14" :stroke-width="2.5" /> Saison complète
</span>
<span v-else-if="isSeasonRequested" class="flex items-center gap-[7px] text-meta text-accent">
<Clock3 :size="14" :stroke-width="2" /> {{ requestStatus === 'approved' ? 'Demandée · bientôt là' : 'Demandée · en attente de réponse' }}
</span>
<button
v-else
@click="emit('downloadSeason', season)"
:disabled="downloadingSeason"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border bg-accent-muted text-accent border-accent/20 hover:bg-accent/20 transition"
@click.stop="emit('requestSeason', season.season_number)"
class="btn-secondary btn-sm"
>
<component :is="downloadingSeason ? loaderIcon : downloadIcon" />
{{ downloadingSeason ? 'Envoi…' : 'Saison' }}
<Clock3 :size="14" :stroke-width="2" />
{{ missingCount === season.episodes.length ? 'Demander la saison' : missingCount > 1 ? `Demander ${missingCount} épisodes` : "Demander l'épisode manquant" }}
</button>
</template>
<!-- Pack saison unique -->
<button
v-if="season.torrents.length === 1"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="canDownloadSeason
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
: 'text-muted border-border cursor-not-allowed opacity-50'"
:disabled="!canDownloadSeason"
@click="canDownloadSeason && emit('download', `season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<component :is="seasonBtnIcon" />
{{ seasonBtnLabel }}
</button>
<!-- ── Mode téléchargement (admin) ── -->
<template v-else>
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok">
<Check :size="14" :stroke-width="2.5" /> Saison complète
</span>
<span v-else-if="season.organized_state === 'partial'" class="text-meta text-secondary">
Partielle {{ season.organized_count }}/{{ availableCount || season.episodes.length }}
</span>
<template v-if="season.torrents.length === 0 && hasDownloadable">
<div v-if="uniquePackOptions.length > 1" class="relative" @click.stop>
<button class="btn-secondary btn-sm" @click="seasonMenuOpen = !seasonMenuOpen" aria-haspopup="menu" :aria-expanded="seasonMenuOpen">
<Download :size="14" />
Télécharger {{ downloadableLabel }}
<ChevronDown :size="12" :stroke-width="2.5" />
</button>
<div v-if="seasonMenuOpen" class="menu absolute right-0 top-full mt-1.5 w-72 max-w-[90vw] z-20" role="menu">
<button
v-for="opt in uniquePackOptions"
:key="opt.infohash"
role="menuitem"
class="menu-item"
@click="emit('downloadSeason', season, opt.infohash); seasonMenuOpen = false"
>
<Download :size="14" class="shrink-0" />
<span class="truncate">{{ opt.label }}</span>
</button>
</div>
</div>
<button
v-else
@click="emit('downloadSeason', season)"
:disabled="downloadingSeason"
class="btn-secondary btn-sm"
>
<Loader v-if="downloadingSeason" :size="14" class="animate-spin" />
<Download v-else :size="14" />
{{ downloadingSeason ? 'Envoi…' : `Télécharger ${downloadableLabel}` }}
</button>
</template>
<!-- Dropdown packs saison multiples -->
<div v-if="season.torrents.length > 1" class="relative" @click.stop>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
@click="seasonMenuOpen = !seasonMenuOpen"
v-if="season.torrents.length === 1 && season.organized_state !== 'complete'"
class="btn-secondary btn-sm"
:disabled="!canDownloadSeason"
@click="canDownloadSeason && emit('download', `season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<component :is="downloadIcon" />
Saison ▾
<component :is="seasonBtnIcon" />
{{ seasonBtnLabel }}
</button>
<div v-if="seasonMenuOpen" class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
<button
v-for="(t, i) in season.torrents"
:key="i"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
:class="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
:disabled="isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)"
@click="!isDownloaded(`season-${season.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `season-${season.id}-${i}`, t.torrent_url, t.magnet), seasonMenuOpen = false)"
>
<component :is="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
{{ groupLabels(season.torrents)[i] }}
<div v-if="season.torrents.length > 1 && season.organized_state !== 'complete'" class="relative" @click.stop>
<button class="btn-secondary btn-sm" @click="seasonMenuOpen = !seasonMenuOpen" aria-haspopup="menu" :aria-expanded="seasonMenuOpen">
<Download :size="14" />
Télécharger la saison
<ChevronDown :size="12" :stroke-width="2.5" />
</button>
</div>
</div>
</div>
</div>
<!-- Progression saison -->
<div v-if="seasonProgress && seasonProgress.progress < 100" class="px-5 pb-3 -mt-1">
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${seasonProgress.progress}%` }" />
</div>
<p class="text-[11px] text-muted mt-1">{{ seasonProgress.progress }}%</p>
</div>
<!-- Épisodes -->
<div v-if="!collapsed" class="divide-y divide-border/50">
<div
v-for="ep in season.episodes"
:key="ep.id"
class="px-5 py-3 hover:bg-hover/50 transition-colors last:rounded-b-xl"
:class="{ 'opacity-40': !ep.available && !ep.organized }"
>
<div class="flex items-center gap-3">
<!-- Thumbnail -->
<div class="shrink-0 w-20 aspect-video rounded-md overflow-hidden bg-shell hidden sm:flex items-center justify-center text-muted text-xs font-mono">
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
</div>
<!-- Infos épisode -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-xs text-muted shrink-0 font-mono">
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
</span>
<span class="text-sm text-primary truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
<span
v-if="ep.fankai === false || ep.torrent?.fankai === false"
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-purple-500/10 text-purple-400 border-purple-500/20"
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
>Hors Fankai</span>
</div>
<div class="flex items-center gap-2 mt-0.5 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 téléchargement épisode (uniquement si non organisé) -->
<div v-if="!ep.organized" class="relative shrink-0" @click.stop>
<button
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
:class="epBtnClass(ep)"
:disabled="epState(ep) !== 'idle'"
@click="handleEpBtnClick(ep)"
>
<component :is="epStateIcon(ep)" />
</button>
<!-- Dropdown quand plusieurs options de torrent -->
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1"
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
<div v-if="seasonMenuOpen" class="menu absolute right-0 top-full mt-1.5 w-72 max-w-[90vw] z-20" role="menu">
<button
v-for="(t, i) in ep.torrents"
v-for="(t, i) in (season.torrents as any[])"
:key="i"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
:class="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
:disabled="isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)"
@click="!isDownloaded(`ep-${ep.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `ep-${ep.id}-${i}`, t.torrent_url, t.magnet, t.file_index ?? null, t.file_path ?? null, t.infohash ?? null), epOptionsOpen = null)"
role="menuitem"
:title="t.raw ?? t.torrent_name ?? ''"
class="menu-item"
:disabled="isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)"
@click="!isDownloaded(`season-${season.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `season-${season.id}-${i}`, t.torrent_url, t.magnet), seasonMenuOpen = false)"
>
<component :is="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
{{ groupLabels(ep.torrents)[i] }}
<component :is="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
<span class="truncate">{{ groupLabels(season.torrents)[i] }}</span>
</button>
</div>
</div>
<!-- Actions épisode importé -->
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
<!-- Badge rename cliquable -->
<button
v-if="epNeedsRename(ep)"
@click.stop="emit('renameEpisode', ep, season)"
:disabled="epActionLoading[ep.id]"
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-yellow-500/10 text-yellow-400 border-yellow-500/20 hover:bg-yellow-500/20 transition-colors"
:title="`Actuel : ${organizedByEpisode[String(ep.id)]?.dest_filename}\nAttendu : ${epExpectedName(ep)}`"
>Rename</button>
<!-- Corbeille épisode -->
<button
v-if="season.organized_count > 0"
@click.stop="openUnimportSeasonModal"
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-err/10 transition-colors"
title="Retirer la saison de la médiathèque"
aria-label="Retirer la saison de la médiathèque"
>
<Trash2 :size="15" />
</button>
</template>
</div>
</div>
<div v-if="!collapsed">
<div
v-for="ep in season.episodes"
:key="ep.id"
class="px-4 sm:px-5 py-2 border-t border-hover first:border-t-0 hover:bg-hover/30 transition-colors last:rounded-b-card"
>
<div class="flex items-center gap-3 sm:gap-3.5">
<div
class="shrink-0 w-[64px] h-[36px] sm:w-[88px] sm:h-[50px] rounded-[4px] overflow-hidden bg-main flex items-center justify-center text-muted text-xs"
:class="{ 'opacity-50': !requestMode && !ep.available && !ep.organized }"
>
<img v-if="ep.thumb_image" :src="ep.thumb_image" alt="" class="w-full h-full object-cover" loading="lazy" />
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
</div>
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<div class="flex items-center gap-2.5 min-w-0">
<span class="w-[26px] shrink-0 text-[11.5px] text-muted tabular-nums max-sm:hidden">
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
</span>
<span class="text-[14.5px] font-medium truncate" :class="!requestMode && !ep.available && !ep.organized ? 'text-muted' : 'text-primary'">
{{ ep.title || `Épisode ${ep.episode_number}` }}
</span>
<span
v-if="ep.fankai === false || ep.torrent?.fankai === false"
class="shrink-0 h-[19px] px-[7px] rounded-[4px] border border-border text-[10.5px] font-bold text-muted flex items-center"
title="Ce fichier n'est pas une version officielle Fankai"
>Hors Fankai</span>
</div>
<span v-if="episodeMeta(ep)" class="sm:pl-9 text-meta text-muted truncate" :title="episodeMeta(ep)">{{ episodeMeta(ep) }}</span>
</div>
<!-- ── État et actions, mode demande ── -->
<template v-if="requestMode">
<span v-if="ep.organized" class="shrink-0 flex items-center gap-[7px] text-meta text-ok">
<Check :size="14" :stroke-width="2.5" /> <span class="max-sm:sr-only">Disponible</span>
</span>
<span v-else-if="isEpisodeRequested(ep.id)" class="shrink-0 flex items-center gap-[7px] text-meta text-accent">
<Clock3 :size="14" :stroke-width="2" /> <span class="max-sm:sr-only">{{ requestStatus === 'approved' ? 'Demandé · bientôt là' : 'Demandé' }}</span>
</span>
<div v-else class="relative shrink-0" @click.stop>
<button class="btn-secondary btn-sm pointer-fine:h-[30px] max-sm:w-10 max-sm:px-0" @click="handleEpRequestClick(ep)" title="Demander l'épisode" aria-label="Demander l'épisode">
<Plus :size="13" :stroke-width="2" /> <span class="max-sm:hidden">Demander</span>
<ChevronDown v-if="ep.torrents && ep.torrents.length > 1" :size="12" :stroke-width="2.5" />
</button>
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1" class="menu absolute right-0 bottom-full mb-1.5 w-80 max-w-[90vw] z-20" role="menu">
<p class="tag-label px-3 pt-1.5 pb-1">Choisir la version</p>
<button
v-for="(t, i) in (ep.torrents as any[])"
:key="i"
role="menuitem"
:title="t.raw ?? t.torrent_name ?? ''"
class="menu-item items-start"
@click="emit('requestEpisode', season.season_number, ep.id, t); epOptionsOpen = null"
>
<Clock3 :size="14" class="shrink-0 mt-0.5" />
<span class="whitespace-normal break-words">{{ t.raw ?? t.torrent_name ?? `Option ${i + 1}` }}</span>
</button>
</div>
</div>
</template>
<!-- ── État et actions, admin ── -->
<template v-else>
<template v-if="ep.organized">
<button
v-if="organizedByEpisode[String(ep.id)] && epNeedsRename(ep)"
@click.stop="emit('renameEpisode', ep, season)"
:disabled="epActionLoading[ep.id]"
class="pill pill-wait h-[26px] px-2.5 shrink-0 hover:bg-accent-muted transition-colors"
:title="`Le fichier ne porte pas le nom attendu.\nActuel : ${organizedByEpisode[String(ep.id)]?.dest_filename}\nAttendu : ${epExpectedName(ep)}`"
>
<Loader v-if="epActionLoading[ep.id]" :size="12" class="animate-spin" />
<PencilLine v-else :size="12" :stroke-width="2.25" />
<span class="max-sm:hidden">Renommer</span>
</button>
<span class="shrink-0 flex items-center gap-[7px] text-meta text-ok">
<Check :size="14" :stroke-width="2.5" /> <span class="max-sm:sr-only">Importé</span>
</span>
</template>
<span v-else-if="epState(ep) === 'loading'" class="shrink-0 flex items-center gap-[7px] text-meta text-accent">
<Clock3 :size="14" :stroke-width="2" />
<template v-if="epProgress(ep) && epProgress(ep)!.progress > 0 && epProgress(ep)!.progress < 100"><span class="max-sm:hidden">Téléchargement · </span>{{ epProgress(ep)!.progress }}&nbsp;%</template>
<span v-else class="max-sm:sr-only">En file d'attente</span>
</span>
<span v-else-if="epState(ep) === 'unavailable'" class="shrink-0 text-meta text-muted">Sans torrent</span>
<div v-else class="relative shrink-0" @click.stop>
<button class="btn-secondary btn-sm pointer-fine:h-[30px] max-sm:w-10 max-sm:px-0" @click="handleEpBtnClick(ep)" title="Télécharger l'épisode" aria-label="Télécharger l'épisode">
<Download :size="13" /> <span class="max-sm:hidden">Télécharger</span>
<ChevronDown v-if="ep.torrents && ep.torrents.length > 1" :size="12" :stroke-width="2.5" />
</button>
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1" class="menu absolute right-0 bottom-full mb-1.5 w-72 max-w-[90vw] z-20" role="menu">
<button
v-for="(t, i) in (ep.torrents as any[])"
:key="i"
role="menuitem"
:title="t.raw ?? t.torrent_name ?? ''"
class="menu-item"
:disabled="isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)"
@click="!isDownloaded(`ep-${ep.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `ep-${ep.id}-${i}`, t.torrent_url, t.magnet, t.file_index ?? null, t.file_path ?? null, t.infohash ?? null), epOptionsOpen = null)"
>
<component :is="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
<span class="truncate">{{ groupLabels(ep.torrents)[i] }}</span>
</button>
</div>
</div>
<button
v-if="ep.organized && organizedByEpisode[String(ep.id)]"
@click.stop="openUnimportModal(ep, season)"
:disabled="epActionLoading[ep.id]"
class="w-6 h-6 flex items-center justify-center rounded text-red-400 hover:bg-red-500/10 transition-colors"
title="Désimporter l'épisode"
class="w-[30px] h-[30px] pointer-coarse:w-10 pointer-coarse:h-10 shrink-0 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-err/10 transition-colors"
title="Retirer l'épisode de la médiathèque"
aria-label="Retirer l'épisode de la médiathèque"
>
<Trash2 :size="12" />
<Trash2 :size="14" />
</button>
</template>
<!-- Chevron synopsis (à droite, visible si l'épisode a un plot) -->
<button
v-if="ep.plot"
@click.stop="togglePlot(ep.id)"
class="w-6 h-6 flex items-center justify-center rounded transition-colors shrink-0"
:class="plotOpen === ep.id ? 'text-accent' : 'text-muted hover:text-primary'"
class="w-[30px] h-[30px] pointer-coarse:w-10 pointer-coarse:h-10 shrink-0 rounded-full flex items-center justify-center transition-colors"
:class="plotOpen === ep.id ? 'text-accent' : 'text-muted hover:text-primary hover:bg-hover'"
:aria-expanded="plotOpen === ep.id"
title="Voir le synopsis"
>
<ChevronUp :size="14" class="transition-transform duration-200" :class="plotOpen === ep.id ? '' : 'rotate-180'" />
<ChevronDown :size="14" class="transition-transform duration-200" :class="plotOpen === ep.id ? 'rotate-180' : ''" />
</button>
</div>
<!-- Barre progression épisode -->
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="mt-1.5 sm:pl-[92px]">
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${epProgress(ep)!.progress}%` }" />
</div>
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="progress mt-2 sm:ml-[138px]">
<div class="progress-bar" :style="{ width: `${epProgress(ep)!.progress}%` }" />
</div>
<!-- Synopsis -->
<div v-if="plotOpen === ep.id && ep.plot" class="mt-2 sm:pl-[92px]">
<p class="text-xs text-muted leading-relaxed" style="white-space: pre-line">{{ parseEpChap(ep.plot).cleanPlot }}</p>
</div>
<p v-if="plotOpen === ep.id && ep.plot" class="mt-2 mb-1 sm:ml-[138px] text-meta text-secondary leading-relaxed whitespace-pre-line max-w-[760px]">
{{ parseEpChap(ep.plot).cleanPlot }}
</p>
</div>
</div>
</div>
</section>
<!-- Modal désimport épisode -->
<Teleport to="body">
<div v-if="unimportModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportModal = false">
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter l'épisode</h3>
<p class="text-xs text-muted mb-4">L'épisode sera retiré de la bibliothèque.</p>
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
<input type="checkbox" v-model="deleteFileOnUnimport" class="accent-red-500" />
<span class="text-xs text-muted">Supprimer le fichier du disque</span>
<div v-if="unimportModal" class="modal-backdrop" @click.self="unimportModal = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-ep-title">
<div class="flex flex-col gap-1">
<h3 id="unimport-ep-title" class="card-title">Retirer l'épisode de la médiathèque</h3>
<p class="text-meta text-muted">L'épisode sera retiré de la médiathèque. Le fichier reste sur le disque, sauf si vous cochez la case.</p>
</div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" v-model="deleteFileOnUnimport" class="w-4 h-4 rounded" />
<span class="text-body text-secondary">Supprimer le fichier du disque</span>
</label>
<div class="flex gap-2 justify-end">
<button @click="unimportModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
<button @click="confirmUnimport" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
{{ deleteFileOnUnimport ? 'Supprimer' : 'Désimporter' }}
<div class="flex gap-2.5 justify-end">
<button @click="unimportModal = false" class="btn-ghost">Annuler</button>
<button @click="confirmUnimport" class="btn-danger">
{{ deleteFileOnUnimport ? 'Supprimer' : 'Retirer' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Modal désimport saison -->
<Teleport to="body">
<div v-if="unimportSeasonModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportSeasonModal = false">
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter la saison</h3>
<p class="text-xs text-muted mb-4">Tous les épisodes importés de cette saison seront retirés de la bibliothèque.</p>
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
<input type="checkbox" v-model="deleteSeasonFiles" class="accent-red-500" />
<span class="text-xs text-muted">Supprimer les fichiers du disque</span>
<div v-if="unimportSeasonModal" class="modal-backdrop" @click.self="unimportSeasonModal = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-season-title">
<div class="flex flex-col gap-1">
<h3 id="unimport-season-title" class="card-title">Retirer la saison de la médiathèque</h3>
<p class="text-meta text-muted">Les épisodes importés de cette saison seront retirés de la médiathèque. Les fichiers restent sur le disque, sauf si vous cochez la case.</p>
</div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" v-model="deleteSeasonFiles" class="w-4 h-4 rounded" />
<span class="text-body text-secondary">Supprimer les fichiers du disque</span>
</label>
<div class="flex gap-2 justify-end">
<button @click="unimportSeasonModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
<button @click="confirmUnimportSeason" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
{{ deleteSeasonFiles ? 'Supprimer' : 'Désimporter' }}
<div class="flex gap-2.5 justify-end">
<button @click="unimportSeasonModal = false" class="btn-ghost">Annuler</button>
<button @click="confirmUnimportSeason" class="btn-danger">
{{ deleteSeasonFiles ? 'Supprimer' : 'Retirer' }}
</button>
</div>
</div>
@@ -308,9 +314,10 @@
<script setup lang="ts">
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
import { ChevronUp, Download, Loader, Check, X, Trash2 } from 'lucide-vue-next'
import { ChevronDown, ChevronUp, Clock3, Download, Loader, Check, PencilLine, Plus, X, Trash2 } from 'lucide-vue-next'
import { plural } from '@/utils/format'
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[] }
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number; priority?: number }[] }
const props = defineProps<{
season : any
@@ -322,6 +329,10 @@ const props = defineProps<{
epActionLoading : Record<number, boolean>
downloadingSeason ?: boolean
nfoSupport ?: boolean
requestMode ?: boolean // true = boutons demande au lieu de téléchargement
requestedSeasons ?: number[] // saisons déjà demandées par l'utilisateur pour cette série
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par l'utilisateur pour cette série
requestStatus ?: string // statut de la demande en cours de l'utilisateur
}>()
const emit = defineEmits<{
@@ -331,8 +342,19 @@ const emit = defineEmits<{
renameEpisode : [ep: any, season: any]
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
unimportSeason : [season: any, deleteFiles: boolean]
// Mode demande
requestSeason : [seasonNumber: number]
requestEpisode : [seasonNumber: number, episodeId: number, torrent?: any]
}>()
const isSeasonRequested = computed(() =>
props.requestedSeasons?.includes(props.season.season_number) ?? false
)
function isEpisodeRequested(episodeId: number): boolean {
return isSeasonRequested.value || (props.requestedEpisodes?.includes(episodeId) ?? false)
}
const epOptionsOpen = ref<number | null>(null)
const plotOpen = ref<number | null>(null)
const seasonMenuOpen = ref(false)
@@ -341,11 +363,10 @@ const deleteFileOnUnimport = ref(false)
const unimportEpTarget = ref<{ ep: any; season: any } | null>(null)
const unimportSeasonModal = ref(false)
const deleteSeasonFiles = ref(false)
const downloadIcon = h(Download, { size: 12 })
const loaderIcon = h(Loader, { size: 12, class: 'animate-spin' })
const checkIcon = h(Check, { size: 12 })
const downloadIcon = h(Download, { size: 14, class: 'shrink-0' })
const checkIcon = h(Check, { size: 14, class: 'shrink-0' })
// ── Helpers hash / progress ────────────────────────────────────
// ── Hash et progression ──
function extractHash(torrent: any): string | null {
// Préférer l'infohash direct (présent même si le magnet est absent)
if (torrent?.infohash) return torrent.infohash.toLowerCase()
@@ -360,27 +381,15 @@ function torrentProgress(hash: string | null | undefined): ActiveTorrent | null
function isDownloading(key: string) { return props.downloading.includes(key) }
function isDownloaded(key: string) { return props.downloaded.includes(key) }
function isAlreadyQueued(torrent: any): boolean {
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas :
// le pack peut être actif sans que CE fichier spécifique soit en téléchargement.
// On s'appuie uniquement sur downloading/downloaded pour ces cas.
// Fichier d'un pack : le hash ne suffit pas, on se fie à downloading/downloaded
if (torrent?.file_index != null) return false
const hash = extractHash(torrent)
if (!hash) return false
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
// ── Computed saison ────────────────────────────────────────────
// ── État de la saison ──
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
const seasonProgress = computed(() => {
if (props.season.torrents && props.season.torrents.length > 1) {
for (const t of props.season.torrents) {
const prog = torrentProgress(extractHash(t))
if (prog) return prog
}
return null
}
return torrentProgress(extractHash(props.season.torrent))
})
const hasDownloadable = computed(() =>
props.season.episodes.some((ep: any) =>
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)
@@ -390,22 +399,55 @@ const canDownloadSeason = computed(() =>
!!props.season.torrent && !isDownloading(`season-${props.season.id}`) && !isDownloaded(`season-${props.season.id}`) && !isAlreadyQueued(props.season.torrent) && props.season.organized_state !== 'complete'
)
const seasonBtnLabel = computed(() => {
if (props.season.organized_state === 'complete') return 'Importé'
if (isAlreadyQueued(props.season.torrent)) return 'Déjà ajouté'
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyé'
return 'Saison entière'
if (props.season.organized_state === 'complete') return 'Importée'
if (isAlreadyQueued(props.season.torrent)) return 'Déjà dans le client'
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyée au client'
return 'Télécharger la saison'
})
const seasonBtnIcon = computed(() => {
if (props.season.organized_state === 'complete' || isDownloaded(`season-${props.season.id}`) || isAlreadyQueued(props.season.torrent)) return h(Check, { size: 12 })
if (isDownloading(`season-${props.season.id}`)) return h(Loader, { size: 12, class: 'animate-spin' })
if (!props.season.torrent) return h(X, { size: 12 })
return h(Download, { size: 12 })
if (props.season.organized_state === 'complete' || isDownloaded(`season-${props.season.id}`) || isAlreadyQueued(props.season.torrent)) return h(Check, { size: 14 })
if (isDownloading(`season-${props.season.id}`)) return h(Loader, { size: 14, class: 'animate-spin' })
if (!props.season.torrent) return h(X, { size: 14 })
return h(Download, { size: 14 })
})
// ── État épisode ───────────────────────────────────────────────
// ── Résumés de saison ──
const queuedCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized && epState(ep) === 'loading').length)
const missingCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized).length)
const downloadableCount = computed(() =>
props.season.episodes.filter((ep: any) => ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)).length
)
const downloadableLabel = computed(() => {
if (downloadableCount.value === props.season.episodes.length) return 'la saison'
return downloadableCount.value > 1 ? `${downloadableCount.value} épisodes` : "l'épisode"
})
// Vérifie si n'importe quelle clé de téléchargement pour cet épisode est marquée done/downloading
// (couvre ep-{id} pour torrent unique ET ep-{id}-{i} pour multi-torrent)
// Admin : « 12 épisodes · 12 disponibles · 5 importés · 1 en file » ; invité : ce qui est déjà visible
const seasonMeta = computed(() => {
const total = props.season.episodes.length
const parts = [plural(total, 'épisode')]
if (props.requestMode) {
const ready = props.season.organized_count ?? 0
parts.push(ready === 0 ? 'aucun disponible' : ready >= total ? 'tous disponibles' : plural(ready, 'disponible'))
return parts.join(' · ')
}
if (availableCount.value > 0) parts.push(plural(availableCount.value, 'disponible'))
if (props.season.organized_count > 0) parts.push(plural(props.season.organized_count, 'importé'))
if (queuedCount.value > 0) parts.push(`${queuedCount.value} en file`)
return parts.join(' · ')
})
// Date · durée · langue · versions · plages d'épisodes et de chapitres, en texte
function episodeMeta(ep: any): string {
const { episodes, chapters } = parseEpChap(ep.plot)
const sources = ep.torrents?.length > 1 ? `${ep.torrents.length} versions` : ''
return [ep.aired && formatDate(ep.aired), ep.duration && formatDuration(ep.duration), epLang(ep), sources, episodes, chapters]
.filter(Boolean).join(' · ')
}
// ── État de l'épisode ──
// Clés ep-{id} (torrent unique) et ep-{id}-{i} (plusieurs torrents)
function epAnyDownloaded(ep: any): boolean {
if (isDownloaded(`ep-${ep.id}`)) return true
const count = ep.torrents?.length ?? 0
@@ -438,15 +480,12 @@ function epProgress(ep: any): ActiveTorrent | null {
const sessionDl = isDownloaded(key0) || isDownloaded(keyI)
if (active.files && active.files.length > 0) {
const file = active.files.find(f => f.index === t.file_index)
// Avec données par fichier on se fie à la progression réelle du fichier précis.
// Si le fichier est à 100% sans session active → déjà téléchargé hors session
// (ex : désimporté puis re-consulté) → ne pas afficher le spinner.
if (file != null && (sessionDl || (file.progress > 0 && file.progress < 1))) {
// Fichier à 100 % hors session (ex. retiré puis revu) : pas d'indicateur
if (file != null && file.priority !== 0 && (sessionDl || file.progress < 1)) {
return { ...active, progress: Math.round(file.progress * 100) }
}
if (!sessionDl) continue
} else {
// Pas de données par fichier : ignorer si pas de session active
if (!sessionDl) continue
return { ...active, progress: active.state === 'seeding' ? 100 : active.progress }
}
@@ -460,42 +499,33 @@ function epProgress(ep: any): ActiveTorrent | null {
function handleEpBtnClick(ep: any) {
if (ep.organized) return
if (ep.torrents && ep.torrents.length > 1) {
// Plusieurs options → toggle dropdown
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
return
}
// Option unique → téléchargement direct
if (ep.torrent && !isAlreadyQueued(ep.torrent)) {
emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet, ep.torrent.file_index ?? null, ep.torrent.file_path ?? null, ep.torrent.infohash ?? null)
}
}
function handleEpRequestClick(ep: any) {
if (isEpisodeRequested(ep.id)) return
if (ep.torrents && ep.torrents.length > 1) {
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
return
}
emit('requestEpisode', props.season.season_number, ep.id, ep.torrent ?? undefined)
}
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
// Vert uniquement quand importé dans la bibliothèque
if (ep.organized) return 'done'
// Spinner si : envoyé au client, déjà en cours dans le client, ou fichier en cours/terminé
if (epAnyDownloaded(ep) || epAnyDownloading(ep) || isAlreadyQueued(ep.torrent)) return 'loading'
const prog = epProgress(ep)
if (prog) return 'loading'
if (!ep.torrent || !ep.available) return 'unavailable'
return 'idle'
}
function epStateIcon(ep: any) {
const state = epState(ep)
if (state === 'done') return h(Check, { size: 14 })
if (state === 'loading') return h(Loader, { size: 14, class: 'animate-spin' })
if (state === 'unavailable') return h(X, { size: 14 })
return h(Download, { size: 14 })
}
function epBtnClass(ep: any): string {
const state = epState(ep)
if (state === 'done') return 'border-green-500/30 text-green-500 bg-green-500/10 cursor-default'
if (state === 'loading') return 'border-accent/30 text-accent bg-accent-muted cursor-default'
if (state === 'unavailable') return 'border-border text-muted opacity-40 cursor-not-allowed'
return 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'
}
// ── Rename helpers ─────────────────────────────────────────────
// ── Renommage ──
function epExpectedName(ep: any): string {
const entry = props.organizedByEpisode[String(ep.id)]
if (!entry) return ''
@@ -503,9 +533,6 @@ function epExpectedName(ep: any): string {
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
@@ -519,48 +546,58 @@ function epExpectedName(ep: any): string {
function epNeedsRename(ep: any): boolean {
const entry = props.organizedByEpisode[String(ep.id)]
if (!entry) return false
const expected = epExpectedName(ep)
return !!expected && expected !== entry.dest_filename
return entry.needs_rename === true
}
// ── Extraction épisodes / chapitres depuis le synopsis ─────────
// Cache léger pour éviter de recalculer pour chaque accès template (badges + synopsis)
// ── Épisodes et chapitres cités dans le synopsis ──
// Cache : le synopsis est analysé plusieurs fois par rendu
const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>()
/**
* Extrait les plages d'épisodes et 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é.
*/
// Extrait les plages d'épisodes et de chapitres, et retire du synopsis les lignes qui les citent
function _parseEpChap(plot: string | null | undefined): {
episodes: string | null
chapters: string | null
season: string | null
chapters: string |null
cleanPlot: string
} {
const text = plot ?? ''
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
const epMatch = text.match(
/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?(?:\s+de\s+la\s+saison\s+(\d+))?/i
)
const seasonMatch = text.match(/saison\s+(\d+)/i)
const season = epMatch?.[3] ?? seasonMatch?.[1] ?? null
const chapMatch = text.match(
/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i
)
const episodes = epMatch
? (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
? `${season ? `S${season} · ` : ''}${
epMatch[2]
? `Ép. ${epMatch[1]}–${epMatch[2]}`
: `Ép. ${epMatch[1]}`
}`
: null
const chapters = chapMatch
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
: null
// Retirer les lignes contenant les références d'épisodes ou chapitres
let cleanPlot = text
if (episodes || chapters) {
if (episodes || chapters || season) {
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()
.split(/\r?\n/)
.filter(line => !/(?:[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+|saison\s+\d+)/i.test(line))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
return { episodes, chapters, cleanPlot }
return { episodes, season, chapters, cleanPlot }
}
function parseEpChap(plot: string | null | undefined) {
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
@@ -569,7 +606,7 @@ function parseEpChap(plot: string | null | undefined) {
return result
}
// ── Détection langue ───────────────────────────────────────────
// ── Langue ──
function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
const sources: string[] = [
ep.formatted_name ?? '',
@@ -585,9 +622,7 @@ function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
return null
}
// ── Labels de groupe ───────────────────────────────────────────
// Si tous les torrent_name sont présents ET distincts → on les utilise.
// Sinon → titre Nyaa brut (raw), plus informatif quand les noms se ressemblent.
// ── Libellés des versions ──
function labelFromRaw(raw: string, index: number): string {
if (!raw) return `Option ${index + 1}`
return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
@@ -598,39 +633,44 @@ function groupLabels(torrents: any[]): string[] {
const allPresent = names.every(n => n !== null && String(n).trim() !== '')
const allUnique = allPresent && new Set(names).size === names.length
if (allUnique) return names as string[]
// Fallback : titre Nyaa brut
return torrents.map((t: any, i: number) => labelFromRaw(t.raw ?? '', i))
}
// Packs parents distincts couvrant cette saison (quand pas de pack saison explicite)
// Permet le dropdown "Saison ▾" quand une saison est couverte par plusieurs intégrales
// Packs couvrant ≥ 2 épisodes, proposés si la saison n'a pas de pack dédié
const uniquePackOptions = computed(() => {
if (props.season.torrents?.length) return []
const seen = new Set<string>()
const raw: { infohash: string; torrent: any }[] = []
const hashCount = new Map<string, number>()
const hashTorrent = new Map<string, any>()
for (const ep of props.season.episodes) {
if (!ep.available) continue
for (const t of (ep.torrents ?? [])) {
const hash = t.infohash ?? extractHash(t)
if (!hash || seen.has(hash)) continue
seen.add(hash)
raw.push({ infohash: hash, torrent: t })
if (!hash) continue
hashCount.set(hash, (hashCount.get(hash) ?? 0) + 1)
if (!hashTorrent.has(hash)) hashTorrent.set(hash, t)
}
}
if (raw.length <= 1) return []
const labels = groupLabels(raw.map(r => r.torrent))
return raw.map((r, i) => ({ infohash: r.infohash, label: labels[i] }))
const packs = [...hashCount.entries()]
.filter(([, count]) => count > 1)
.map(([hash]) => ({ infohash: hash, torrent: hashTorrent.get(hash)! }))
if (packs.length <= 1) return []
const labels = groupLabels(packs.map(r => r.torrent))
return packs.map((r, i) => ({ infohash: r.infohash, label: labels[i] }))
})
function formatDate(d: string): string {
if (!d) return ''
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
}
// 4 h 10, 24 min
function formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return ''
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return m > 0 ? `${h}h ${m}min` : `${h}h`
if (h > 0) return m > 0 ? `${h} h ${String(m).padStart(2, '0')}` : `${h} h`
return `${m} min`
}
+140 -110
View File
@@ -1,141 +1,140 @@
<template>
<div>
<!-- Toolbar -->
<div class="shrink-0 px-4 md:px-6 py-3 border-b border-border flex flex-col gap-2">
<div data-tour="series-toolbar" class="flex flex-col gap-[18px]">
<!-- Ligne 1 : recherche + filtres -->
<div class="flex items-center gap-2">
<header class="flex items-center justify-between gap-x-4 gap-y-3 flex-wrap min-h-11">
<div class="flex items-baseline gap-3.5">
<h1 class="page-title tracking-[0.01em]">Médiathèque</h1>
<span class="text-[13px] text-muted">{{ plural(count, 'série') }}</span>
</div>
<div class="relative flex-1">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
<div class="flex items-center gap-2.5 w-full md:w-auto">
<label class="flex-1 md:flex-none md:w-[320px] h-10 rounded-full bg-card border border-border-light flex items-center gap-2.5 pl-4 pr-2 cursor-text focus-within:border-accent transition-colors">
<Search :size="16" :stroke-width="1.75" class="text-muted shrink-0" />
<input
data-library-search
:value="search"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
@keydown.esc="emit('update:search', '')"
type="text"
placeholder="Rechercher…"
class="settings-input pl-9 w-full py-1.5 text-sm"
placeholder="Rechercher une série"
aria-label="Rechercher une série"
class="flex-1 min-w-0 bg-transparent outline-none text-sm text-primary placeholder:text-muted"
/>
</div>
<button v-if="search" @click="emit('update:search', '')" aria-label="Effacer la recherche" class="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors">
<X :size="14" />
</button>
</label>
<!-- Filtres -->
<div class="relative shrink-0" ref="filterRef">
<button
v-if="selectable"
data-tour="series-select"
@click="emit('update:selecting', !selecting)"
class="h-10 px-4 rounded-full border text-body font-medium flex items-center gap-2 shrink-0 transition-colors"
:class="selecting ? 'border-accent text-accent bg-accent-muted' : 'border-border-light text-secondary hover:text-primary hover:bg-hover'"
:title="selecting ? 'Quitter la sélection' : 'Sélectionner plusieurs séries'"
>
<SquareCheck :size="16" :stroke-width="1.75" />
<span class="hidden sm:inline">Sélectionner</span>
</button>
</div>
</header>
<div ref="rowRef" class="relative">
<div class="chip-row md:justify-between md:gap-x-4 md:gap-y-2.5" @scroll="moreOpen = false; sortOpen = false">
<div class="flex items-center gap-1.5 md:flex-wrap md:shrink md:min-w-0">
<button
@click="filterOpen = !filterOpen"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 relative"
:class="hasActiveFilter ? 'border-accent text-accent' : ''"
title="Filtres"
v-for="f in filters" :key="f.value"
@click="emit('update:activeFilter', f.value)"
class="chip shrink-0"
:class="{ 'is-active': activeFilter === f.value }"
:aria-pressed="activeFilter === f.value"
>
<SlidersHorizontal :size="15" />
<span class="hidden md:inline text-xs">Filtres</span>
<ChevronDown class="hidden md:inline transition-transform" :size="12" :class="filterOpen ? 'rotate-180' : ''" />
<span v-if="hasActiveFilter" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
{{ f.label }}<span class="chip-count" :class="{ 'text-accent! font-bold': f.attention && f.count > 0 && activeFilter !== f.value }">{{ f.count }}</span>
</button>
<div
v-if="filterOpen"
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-3 z-20 w-52 flex flex-col gap-3 shadow-xl"
<button
v-if="moreFilters.length"
ref="moreBtnRef"
@click="toggleMore"
class="chip shrink-0 pr-3"
:class="{ 'is-active': activeMore }"
aria-haspopup="menu"
:aria-expanded="moreOpen"
>
<div>
<p class="text-xs text-muted mb-1.5">Disponibilité</p>
<div class="flex flex-col gap-1">
<button
v-for="f in filtersDisponibilite" :key="f.value"
@click="emit('update:activeFilter', f.value)"
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
{{ f.label }}
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
</button>
</div>
</div>
<div class="border-t border-border pt-3">
<p class="text-xs text-muted mb-1.5">État import</p>
<div class="flex flex-col gap-1">
<button
v-for="f in filtersImport" :key="f.value"
@click="emit('update:activeFilter', f.value)"
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full shrink-0" :style="{ background: f.color }" />
{{ f.label }}
</div>
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
</button>
</div>
</div>
{{ activeMore ? activeMore.label : 'Plus' }}<span v-if="activeMore" class="chip-count">{{ activeMore.count }}</span>
<ChevronDown :size="14" class="transition-transform" :class="{ 'rotate-180': moreOpen }" />
</button>
</div>
<div class="flex items-center gap-2">
<button ref="sortBtnRef" @click="toggleSort" class="chip shrink-0 pr-3" aria-haspopup="menu" :aria-expanded="sortOpen">
{{ sortOptions.find(s => s.value === activeSort)?.label }}
<ChevronDown :size="14" class="transition-transform" :class="{ 'rotate-180': sortOpen }" />
</button>
<!-- Sur mobile, la grille garde deux colonnes -->
<div class="segmented max-sm:hidden" role="group" aria-label="Taille des affiches">
<button
v-if="hasActiveFilter"
@click="emit('update:activeFilter', 'all')"
class="text-xs text-muted hover:text-primary transition-colors text-left pt-1 border-t border-border"
v-for="(_, size) in posterSizes" :key="size"
@click="emit('update:posterSize', size)"
class="segmented-item w-[34px] justify-center px-0 text-[11.5px] font-bold"
:class="posterSize === size ? 'bg-hover text-primary' : 'text-muted'"
:aria-pressed="posterSize === size"
:title="`Taille des affiches : ${size}`"
>
Effacer les filtres
{{ size }}
</button>
</div>
</div>
</div>
<!-- Ligne 2 : tri + taille + count -->
<div class="flex items-center gap-2 flex-wrap">
<div class="flex items-center gap-1">
<button
v-for="s in sortOptions" :key="s.value"
@click="emit('update:activeSort', s.value)"
class="px-2.5 py-1 text-xs rounded-lg border transition-colors"
:class="activeSort === s.value
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ s.label }}
</button>
</div>
<div class="flex items-center gap-1 border-l border-border pl-2 ml-auto">
<button
v-for="(_, size) in posterSizes" :key="size"
@click="emit('update:posterSize', size)"
class="w-7 h-7 text-[10px] rounded-md border transition-colors flex items-center justify-center"
:class="posterSize === size
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ size }}
</button>
</div>
<span class="text-xs text-muted">{{ count }} série{{ count > 1 ? 's' : '' }}</span>
<div v-if="moreOpen" ref="moreMenuRef" role="menu" class="menu absolute top-full mt-1.5 w-56 z-20" :style="{ left: `${menuLeft}px` }">
<button
v-for="f in moreFilters" :key="f.value"
role="menuitem"
@click="emit('update:activeFilter', activeFilter === f.value ? 'all' : f.value); moreOpen = false"
class="menu-item justify-between"
:class="{ 'text-primary! bg-hover': activeFilter === f.value }"
>
{{ f.label }}<span class="text-meta text-muted">{{ f.count }}</span>
</button>
</div>
</div>
<!-- Légende barres -->
<div class="shrink-0 px-4 md:px-6 py-2 flex items-center gap-4 border-b border-border">
<div v-for="l in legend" :key="l.label" class="flex items-center gap-1.5">
<span class="w-3 h-1.5 rounded-full" :style="{ background: l.color }" />
<span class="text-[11px] text-muted">{{ l.label }}</span>
<div v-if="sortOpen" ref="sortMenuRef" role="menu" class="menu absolute top-full mt-1.5 w-44 z-20" :style="{ left: `${menuLeft}px` }">
<button
v-for="s in sortOptions" :key="s.value"
role="menuitem"
@click="emit('update:activeSort', s.value); sortOpen = false"
class="menu-item justify-between"
:class="{ 'text-primary! bg-hover': activeSort === s.value }"
>
{{ s.label }}<Check v-if="activeSort === s.value" :size="14" class="text-accent" />
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
import { ref, computed } from 'vue'
import { Check, ChevronDown, Search, SquareCheck, X } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
import { plural } from '@/utils/format'
defineProps<{
search : string
activeFilter : string
activeSort : string
posterSize : string
posterSizes : Record<string, string>
count : number
hasActiveFilter : boolean
filtersDisponibilite: { label: string; value: string }[]
filtersImport : { label: string; value: string; color: string }[]
sortOptions : { label: string; value: string }[]
legend : { label: string; color: string }[]
export interface LibraryFilter { value: string; label: string; count: number; attention?: boolean }
const props = defineProps<{
search : string
activeFilter : string
activeSort : string
posterSize : string
posterSizes : Record<string, string>
count : number
filters : LibraryFilter[]
moreFilters : LibraryFilter[]
sortOptions : { label: string; value: string }[]
selectable? : boolean
selecting? : boolean
}>()
const emit = defineEmits<{
@@ -143,10 +142,41 @@ const emit = defineEmits<{
'update:activeFilter': [value: string]
'update:activeSort' : [value: string]
'update:posterSize' : [value: string]
'update:selecting' : [value: boolean]
}>()
const filterOpen = ref(false)
const filterRef = ref<HTMLElement | null>(null)
const activeMore = computed(() => props.moreFilters.find(f => f.value === props.activeFilter) ?? null)
onClickOutside(filterRef, () => { filterOpen.value = false })
const moreOpen = ref(false)
const sortOpen = ref(false)
const rowRef = ref<HTMLElement | null>(null)
const moreBtnRef = ref<HTMLElement | null>(null)
const sortBtnRef = ref<HTMLElement | null>(null)
const moreMenuRef = ref<HTMLElement | null>(null)
const sortMenuRef = ref<HTMLElement | null>(null)
const menuLeft = ref(0)
onClickOutside(moreMenuRef, () => { moreOpen.value = false }, { ignore: [moreBtnRef] })
onClickOutside(sortMenuRef, () => { sortOpen.value = false }, { ignore: [sortBtnRef] })
// Menu sous son bouton, aligné à droite s'il déborde de la ligne
function placeMenu(btn: HTMLElement | null, width: number) {
if (!btn || !rowRef.value) return
const row = rowRef.value.getBoundingClientRect()
const b = btn.getBoundingClientRect()
const left = b.left - row.left + width > row.width ? b.right - row.left - width : b.left - row.left
menuLeft.value = Math.max(0, left)
}
function toggleMore() {
sortOpen.value = false
moreOpen.value = !moreOpen.value
if (moreOpen.value) placeMenu(moreBtnRef.value, 224)
}
function toggleSort() {
moreOpen.value = false
sortOpen.value = !sortOpen.value
if (sortOpen.value) placeMenu(sortBtnRef.value, 176)
}
</script>
@@ -0,0 +1,113 @@
<template>
<div class="flex flex-col gap-4">
<div class="grid md:grid-cols-2 gap-4">
<div class="flex flex-col gap-[7px]">
<label for="jellyfin-url" class="field-label">Adresse du serveur</label>
<input id="jellyfin-url" v-model="jellyfinUrl" type="url" class="field" placeholder="http://jellyfin:8096" />
<p class="text-xs text-muted">Adresse joignable depuis FanKarr (réseau local ou Docker).</p>
</div>
<div class="flex flex-col gap-[7px]">
<label for="jellyfin-token" class="field-label">Clé API administrateur</label>
<input id="jellyfin-token" v-model="jellyfinAdminToken" type="password" class="field" autocomplete="off"
:placeholder="hasToken ? '•••••••••••••••••••• (configurée)' : 'Collez la clé API'" />
<p class="text-xs text-muted">Dans Jellyfin : Tableau de bord › Clés API › Créer une clé.</p>
</div>
</div>
<div class="flex items-center gap-2.5 flex-wrap">
<button @click="save" :disabled="saving" class="btn-primary pointer-fine:h-[38px]">
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
</button>
<button @click="testConnection" :disabled="testing" class="btn-secondary pointer-fine:h-[38px]">
{{ testing ? 'Test…' : 'Tester la connexion' }}
</button>
<span v-if="testResult" class="pill" :class="testResult.ok ? 'pill-ok' : 'pill-err'">
<Check v-if="testResult.ok" :size="12" :stroke-width="2.5" />
<TriangleAlert v-else :size="12" :stroke-width="2.25" />
{{ testResult.ok ? `Connecté · Jellyfin ${testResult.version}` : testResult.error }}
</span>
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
<span v-if="saveError" class="text-meta text-err">{{ saveError }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Check, TriangleAlert } from 'lucide-vue-next'
interface JellyfinTestResult { ok: boolean; version?: string; error?: string }
const emit = defineEmits<{
change: [state: { jellyfinUrl: string; hasToken: boolean; test: JellyfinTestResult | null }]
}>()
const jellyfinUrl = ref('')
const jellyfinAdminToken = ref('')
const hasToken = ref(false)
const saving = ref(false)
const saved = ref(false)
const saveError = ref<string | null>(null)
const testing = ref(false)
const testResult = ref<JellyfinTestResult | null>(null)
function notify() {
emit('change', { jellyfinUrl: jellyfinUrl.value, hasToken: hasToken.value, test: testResult.value })
}
onMounted(async () => {
const res = await fetch('/api/jellyfin/settings', { credentials: 'include' })
if (res.ok) {
const d = await res.json()
jellyfinUrl.value = d.jellyfinUrl ?? ''
hasToken.value = d.hasToken ?? false
notify()
}
})
async function save() {
saving.value = true
saved.value = false
saveError.value = null
const body: Record<string, string> = { jellyfinUrl: jellyfinUrl.value }
if (jellyfinAdminToken.value) body.jellyfinAdminToken = jellyfinAdminToken.value
const res = await fetch('/api/jellyfin/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(body),
}).catch(() => null)
if (!res?.ok) {
const d = await res?.json().catch(() => ({}))
saveError.value = d?.error ?? 'Impossible d\'enregistrer les réglages Jellyfin.'
} else {
const d = await res.json()
hasToken.value = d.hasToken
jellyfinAdminToken.value = ''
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
notify()
// Le test du serveur n'utilise que les valeurs enregistrées
if (jellyfinUrl.value && hasToken.value) await testConnection()
}
saving.value = false
}
async function testConnection() {
testing.value = true
testResult.value = null
try {
const res = await fetch('/api/jellyfin/test', {
method: 'POST',
credentials: 'include',
body: JSON.stringify({
jellyfinUrl,
jellyfinAdminToken
})
})
testResult.value = await res.json()
} catch {
testResult.value = { ok: false, error: 'Impossible de contacter le serveur' }
}
testing.value = false
notify()
}
</script>
+64 -70
View File
@@ -1,83 +1,75 @@
<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">
<div class="modal-backdrop" @click.self="emit('close')">
<div class="bg-card border border-border rounded-card w-full max-w-lg flex flex-col max-h-[85vh] overflow-y-auto shadow-[0_24px_60px_rgb(0_0_0/0.55)]" role="dialog" aria-modal="true" aria-labelledby="plex-title">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div class="flex items-center justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
<div>
<h3 class="text-sm font-semibold text-primary">Configuration Plex</h3>
<p class="text-xs text-muted mt-0.5">
<h3 id="plex-title" class="font-display text-xl font-bold text-primary">Configuration Plex</h3>
<p class="text-meta text-muted mt-0.5">
{{ plexStep === 'auth' ? 'Connectez votre compte Plex' :
plexStep === 'server' ? 'Choisissez votre serveur Plex' :
plexStep === 'library' ? 'Créez la bibliothèque Fankai' :
plexStep === 'done' ? 'Configuration terminée' :
'Erreur' }}
'' }}
</p>
</div>
<button @click="emit('close')" class="text-muted hover:text-primary text-lg leading-none">✕</button>
<button @click="emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div>
<!-- Étape 1 : Auth -->
<!-- Étape 1 : connexion -->
<div v-if="plexStep === 'auth'" class="flex flex-col gap-4 px-6 py-5">
<div v-if="!plexAuthMethod" class="flex flex-col gap-3">
<button
@click="startOAuth" :disabled="plexLoading"
class="flex items-center gap-3 p-4 rounded-lg border border-border hover:border-accent hover:bg-accent-muted transition-colors text-left"
class="flex items-center gap-3 p-4 rounded-field border border-border-light hover:border-accent/40 hover:bg-accent-muted transition-colors text-left"
>
<div class="w-8 h-8 rounded-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 class="w-8 h-8 rounded-full bg-accent-muted text-accent flex items-center justify-center shrink-0">
<Clock3 :size="16" />
</div>
<div>
<p class="text-sm font-medium text-primary">{{ plexLoading ? 'Ouverture…' : 'Connexion via Plex' }}</p>
<p class="text-xs text-muted">Google, Apple, ou compte Plex</p>
<p class="text-meta text-muted">Google, Apple ou compte Plex</p>
</div>
</button>
<button
@click="plexAuthMethod = 'credentials'"
class="flex items-center gap-3 p-4 rounded-lg border border-border hover:border-secondary transition-colors text-left"
class="flex items-center gap-3 p-4 rounded-field border border-border-light hover:bg-hover transition-colors text-left"
>
<div class="w-8 h-8 rounded-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 class="w-8 h-8 rounded-full bg-hover text-secondary flex items-center justify-center shrink-0">
<User :size="16" />
</div>
<div>
<p class="text-sm font-medium text-primary">Email / Mot de passe</p>
<p class="text-xs text-muted">Connexion directe avec vos identifiants</p>
<p class="text-sm font-medium text-primary">E-mail et mot de passe</p>
<p class="text-meta text-muted">Sans passer par la page de connexion Plex</p>
</div>
</button>
</div>
<!-- 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>
<p class="text-meta text-muted mt-1">Connectez-vous dans l'onglet Plex qui vient de s'ouvrir. Rien ne s'est ouvert ? Autorisez les fenêtres pop-up.</p>
</div>
<button @click="cancelOAuth" class="text-xs text-muted hover:text-primary transition-colors">Annuler</button>
<button @click="cancelOAuth" class="btn-ghost btn-sm">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" />
<label class="field-label mb-1 block">Adresse e-mail Plex</label>
<input v-model="plexForm.username" type="email" class="field w-full" placeholder="email@example.com" />
</div>
<div>
<label class="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" />
<label class="field-label mb-1 block">Mot de passe</label>
<input v-model="plexForm.password" type="password" class="field w-full" placeholder="••••••••" @keyup.enter="plexConnect" />
</div>
<div v-if="plexNeeds2FA">
<label class="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" />
<label class="field-label mb-1 block">Code de validation (6 chiffres)</label>
<input v-model="plexForm.code" type="text" class="field w-full" placeholder="123456" maxlength="6" @keyup.enter="plexConnect" />
</div>
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
<div class="flex gap-2">
<button @click="plexConnect" :disabled="plexLoading || !plexForm.username || !plexForm.password" class="btn-primary">
{{ plexLoading ? 'Connexion…' : 'Se connecter' }}
@@ -86,26 +78,26 @@
</div>
</template>
<p v-if="plexError && !plexAuthMethod" class="text-xs text-red-400">{{ plexError }}</p>
<p v-if="plexError && !plexAuthMethod" class="text-meta text-err">{{ plexError }}</p>
<button v-if="!plexAuthMethod" @click="emit('close')" class="btn-secondary self-start">Annuler</button>
</div>
<!-- Étape 2 : Choix serveur -->
<!-- Étape 2 : choix du 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>
<p class="text-meta text-muted">{{ plural(plexServers.length, 'serveur trouvé', 'serveurs trouvés') }}</p>
<div
v-for="(server, i) in plexServers" :key="i"
@click="selectServer(server)"
class="flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors"
:class="plexSelectedServer === server ? 'border-accent bg-accent-muted' : 'border-border hover:border-secondary'"
class="flex items-center justify-between p-3 rounded-field border cursor-pointer transition-colors"
:class="plexSelectedServer === server ? 'border-accent/50 bg-accent-muted' : 'border-border-light hover:bg-hover'"
>
<div>
<p class="text-sm text-primary font-medium">{{ server.name }}</p>
<p class="text-xs text-muted">{{ server.owned ? 'Propriétaire' : 'Partagé' }} · {{ server.connections.length }} connexion{{ server.connections.length > 1 ? 's' : '' }}</p>
<p class="text-meta text-muted">{{ server.owned ? 'Votre serveur' : 'Partagé avec vous' }}</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>
<Check v-if="plexSelectedServer === server" :size="15" :stroke-width="2.5" class="text-accent" />
</div>
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
<div class="flex gap-2 mt-1">
<button @click="plexConnectServer" :disabled="!plexSelectedServer || plexLoading" class="btn-primary">
{{ plexLoading ? 'Connexion…' : 'Continuer' }}
@@ -117,15 +109,15 @@
<!-- É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" />
<label class="field-label mb-1 block">Nom de la bibliothèque</label>
<input v-model="plexForm.libraryName" type="text" class="field w-full" placeholder="Fankai" />
</div>
<div>
<label class="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" />
<label class="field-label mb-1 block">Chemin de la médiathèque</label>
<p class="text-xs text-muted mb-1.5">Le dossier de la médiathèque tel que Plex le voit (il peut différer du chemin FanKarr).</p>
<input v-model="plexForm.libraryPath" type="text" class="field w-full" placeholder="/data/media/fankai" />
</div>
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
<div class="flex gap-2">
<button @click="plexSetup" :disabled="plexLoading || !plexForm.libraryName || !plexForm.libraryPath" class="btn-primary">
{{ plexLoading ? 'Création…' : 'Créer la bibliothèque' }}
@@ -139,26 +131,26 @@
<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>
:class="s.ok ? 'bg-ok/15 text-ok' : 'bg-err/15 text-err'">
<Check v-if="s.ok" :size="11" :stroke-width="3" />
<X v-else :size="11" :stroke-width="3" />
</div>
<p class="text-xs" :class="s.ok ? 'text-primary' : 'text-red-400'">{{ s.message }}</p>
<p class="text-meta" :class="s.ok ? 'text-primary' : 'text-err'">{{ 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>
<div v-if="plexManualSetup" class="bg-accent/5 border border-accent/30 rounded-field p-3">
<p class="text-meta text-accent font-bold mb-2">Configuration manuelle de l'agent requise (Plex &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>
<li>Dans <span class="text-primary">Metadata Agent</span>, créez un agent qui utilise ce fournisseur</li>
<li>Dans le menu ⋯ de votre bibliothèque, <span class="text-primary">Manage Library › Edit › Advanced › Agent</span> : sélectionnez l'agent Fankai</li>
<li>Actualisez les métadonnées</li>
</ol>
</div>
<button @click="emit('close')" class="btn-primary">Fermer</button>
<button @click="emit('close')" class="btn-primary self-end">Fermer</button>
</div>
</div>
@@ -168,6 +160,8 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { Check, Clock3, User, X } from 'lucide-vue-next'
import { plural } from '@/utils/format'
const props = defineProps<{ mediaPath: string }>()
const emit = defineEmits<{ close: [] }>()
@@ -194,21 +188,21 @@ const plexForm = ref({
libraryPath: props.mediaPath,
})
// ── OAuth ─────────────────────────────────────────────────────
// ── Connexion via Plex ──
async function startOAuth() {
plexError.value = ''
plexLoading.value = true
try {
const res = await fetch('/api/plex/oauth/start', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { plexError.value = data.error ?? 'Erreur OAuth'; return }
if (!res.ok) { plexError.value = data.error ?? "Impossible d'ouvrir la connexion Plex."; return }
plexOAuthPinId.value = String(data.pinId)
plexAuthMethod.value = 'oauth'
window.open(data.authUrl, '_blank')
let attempts = 0
plexOAuthTimer = setInterval(async () => {
attempts++
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai expiré — réessayez'; return }
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai dépassé. Réessayez.'; return }
try {
const r = await fetch(`/api/plex/oauth/poll/${plexOAuthPinId.value}`, { credentials: 'include' })
const d = await r.json()
@@ -222,7 +216,7 @@ async function startOAuth() {
} catch {}
}, 2000)
} catch {
plexError.value = "Impossible de démarrer l'authentification"
plexError.value = "Impossible d'ouvrir la connexion Plex."
} finally {
plexLoading.value = false
}
@@ -234,7 +228,7 @@ function cancelOAuth() {
if (plexAuthMethod.value === 'oauth') plexAuthMethod.value = null
}
// ── Credentials ───────────────────────────────────────────────
// ── Identifiants ──
async function plexConnect() {
plexError.value = ''
plexLoading.value = true
@@ -245,8 +239,8 @@ async function plexConnect() {
})
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'
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez le code de validation en deux étapes.' }
else plexError.value = data.error ?? 'Impossible de se connecter à Plex. Vérifiez vos identifiants.'
return
}
plexToken.value = data.token
@@ -254,7 +248,7 @@ async function plexConnect() {
if (data.servers.length === 1) { plexSelectedServer.value = data.servers[0]; await plexConnectServer() }
else plexStep.value = 'server'
} catch {
plexError.value = 'Impossible de contacter le serveur'
plexError.value = 'Impossible de contacter le serveur.'
} finally {
plexLoading.value = false
}
@@ -270,11 +264,11 @@ async function plexConnectServer() {
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')
if (!plexServerUrl.value) throw new Error('Ce serveur Plex ne propose aucune adresse de connexion.')
plexForm.value.libraryPath = props.mediaPath
plexStep.value = 'library'
} catch (err) {
plexError.value = err instanceof Error ? err.message : 'Erreur'
plexError.value = err instanceof Error ? err.message : 'Impossible de se connecter à ce serveur.'
} finally {
plexLoading.value = false
}
@@ -292,9 +286,9 @@ async function plexSetup() {
plexSteps.value = data.steps ?? []
plexManualSetup.value = data.manualSetup ?? false
plexStep.value = 'done'
if (!data.ok) plexError.value = data.error ?? 'Erreur lors du setup'
if (!data.ok) plexError.value = data.error ?? 'Impossible de configurer Plex.'
} catch {
plexError.value = 'Impossible de contacter le serveur'
plexError.value = 'Impossible de contacter le serveur.'
} finally {
plexLoading.value = false
}
@@ -0,0 +1,20 @@
<template>
<section class="card flex flex-col gap-3.5">
<div v-if="title || $slots.actions" class="flex items-start justify-between gap-4 flex-wrap">
<div class="flex flex-col gap-0.5 min-w-0 flex-1 basis-[280px]">
<h3 class="card-title">{{ title }}</h3>
<p v-if="description || $slots.description" class="text-meta text-muted">
<slot name="description">{{ description }}</slot>
</p>
</div>
<div v-if="$slots.actions" class="flex items-center gap-2.5 flex-wrap shrink-0">
<slot name="actions" />
</div>
</div>
<slot />
</section>
</template>
<script setup lang="ts">
defineProps<{ title?: string; description?: string }>()
</script>
+11 -11
View File
@@ -1,18 +1,18 @@
<template>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm text-primary">{{ label }}</p>
<p class="text-xs text-muted mt-0.5">{{ description }}</p>
<div class="flex items-center justify-between gap-5">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<p class="text-sm font-medium text-primary">{{ label }}</p>
<p v-if="description" class="text-meta text-muted">{{ description }}</p>
</div>
<button
type="button"
role="switch"
:aria-checked="modelValue"
:aria-label="label"
@click="$emit('update:modelValue', !modelValue)"
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200 mt-0.5"
:class="modelValue ? 'bg-accent' : 'bg-border'"
class="switch"
>
<span
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="modelValue ? 'translate-x-[18px]' : 'translate-x-0'"
/>
<span class="switch-knob" />
</button>
</div>
</template>
@@ -20,4 +20,4 @@
<script setup lang="ts">
defineProps<{ modelValue: boolean; label: string; description?: string }>()
defineEmits(['update:modelValue'])
</script>
</script>
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more