Compare commits

..
426 Commits
Author SHA1 Message Date
Quentin Robert 5466028ae2 Merge pull request #114 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.3
2026-05-08 02:00:44 +02:00
Quentin Robert ea265cfd9e chore(main): release 3.14.3 2026-05-08 02:00:12 +02:00
MasutaYunikon 0e3e5fe912 Merge remote-tracking branch 'origin/main' 2026-05-08 01:59:48 +02:00
MasutaYunikon 6d8ea36260 fix: build binaries node version 2026-05-08 01:59:26 +02:00
github-actions[bot] ea1fb49a37 chore: update version.txt to v3.14.2 2026-05-07 23:55:36 +00:00
Quentin Robert 244fa4ae43 Merge pull request #113 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.2
2026-05-08 01:55:06 +02:00
Quentin Robert 5cfb449c3c chore(main): release 3.14.2 2026-05-08 01:54:21 +02:00
MasutaYunikon 3c9b9f5344 Merge remote-tracking branch 'origin/main' 2026-05-08 01:53:57 +02:00
MasutaYunikon 9ebaca92f1 fix: build binaries node version 2026-05-08 01:53:46 +02:00
github-actions[bot] 494266953c chore: update version.txt to v3.14.1 2026-05-07 23:46:45 +00:00
Quentin Robert 583097cd16 Merge pull request #112 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.1
2026-05-08 01:46:20 +02:00
Quentin Robert cb1d1759e3 chore(main): release 3.14.1 2026-05-08 01:45:59 +02:00
MasutaYunikon 563ffb2bd3 Merge remote-tracking branch 'origin/main' 2026-05-08 01:45:26 +02:00
MasutaYunikon 25ab373539 fix: eslint build 2026-05-08 01:45:16 +02:00
github-actions[bot] ed0acdda22 chore: update version.txt to v3.14.0 2026-05-07 17:24:23 +00:00
Quentin Robert a84337c928 Merge pull request #111 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.0
2026-05-07 19:23:50 +02:00
Quentin Robert cface2944e chore(main): release 3.14.0 2026-05-07 15:30:06 +02:00
MasutaYunikon 1e92934f53 fix: renaming dans le managements des series 2026-05-07 15:29:29 +02:00
MasutaYunikon bf3f30356e fix: renaming dans le managements des series 2026-05-07 15:21:27 +02:00
MasutaYunikon bfabf9b793 fix: rtorrent priority default to 1 2026-05-07 15:08:30 +02:00
MasutaYunikon 1f50f9ae72 fix: on ne vois plus le telechargement de fichier qui ont une priorité basse (ne pas télécharger) 2026-05-07 15:07:17 +02:00
MasutaYunikon 7da5a8d4e8 fix: cookie 2026-05-07 14:57:30 +02:00
MasutaYunikon 2b3fd79a92 fix: ajout de log pour le cookie 2026-05-07 14:55:08 +02:00
MasutaYunikon aa8b500f57 feat(qbittorrent): support cookie name (≥5.2.0) 2026-05-07 14:51:55 +02:00
MasutaYunikon 702847a5c2 feat(qbittorrent): support API key authentication (≥5.2.0) 2026-05-07 13:50:18 +02:00
MasutaYunikon 5cc1aa29fe Merge remote-tracking branch 'origin/main' 2026-05-07 01:19:00 +02:00
MasutaYunikon 105cc05bdf fix: le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode 2026-05-07 01:18:50 +02:00
github-actions[bot] 50b8b2992b chore: update version.txt to v3.13.2 2026-05-06 22:43:17 +00:00
Quentin Robert f4c9713815 Merge pull request #110 from Masutayunikon/release-please--branches--main
chore(main): release 3.13.2
2026-05-07 00:42:54 +02:00
Quentin Robert 7154dc87d2 chore(main): release 3.13.2 2026-05-07 00:42:35 +02:00
MasutaYunikon 2c9bef8883 Merge remote-tracking branch 'origin/main' 2026-05-07 00:41:59 +02:00
MasutaYunikon 0d5d44a484 fix: le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode 2026-05-07 00:41:50 +02:00
github-actions[bot] d5953a948c chore: update version.txt to v3.13.1 2026-05-06 21:37:40 +00:00
Quentin Robert 132198a3a9 Merge pull request #109 from Masutayunikon/release-please--branches--main
chore(main): release 3.13.1
2026-05-06 23:37:13 +02:00
Quentin Robert add5ec5a3e chore(main): release 3.13.1 2026-05-06 23:35:45 +02:00
MasutaYunikon 260fd10bf0 Merge branch 'main' of github.com:Masutayunikon/FanKarr 2026-05-06 23:34:53 +02:00
MasutaYunikon 95d2b270dc fix: ajout du champ wiki dans l'api 2026-05-06 23:33:35 +02:00
github-actions[bot] 1aa6c57e58 chore: update version.txt to v3.13.0 2026-05-06 21:29:35 +00:00
Quentin Robert bde377dd37 Merge pull request #108 from Masutayunikon/release-please--branches--main
chore(main): release 3.13.0
2026-05-06 23:29:06 +02:00
Quentin Robert 834dc12b05 chore(main): release 3.13.0 2026-05-06 23:13:38 +02:00
MasutaYunikon 35655d7784 fix: progression 2026-05-06 23:13:03 +02:00
MasutaYunikon 434da54ede fix(qbittorrent): FanKarr télécharge le .torrent et l'uploade directement à qBit
qBittorrent (Docker/runtipi) n'a pas forcément accès à nyaa.si depuis son réseau.
FanKarr télécharge lui-même le fichier .torrent et l'envoie en upload direct à qBit,
qui n'a donc plus besoin d'accès internet pour les métadonnées.
2026-05-06 23:00:12 +02:00
MasutaYunikon d299b76bd3 fix: ajout de log 2026-05-06 22:57:10 +02:00
MasutaYunikon 9f90b6ab6c fix(qbittorrent): pause uniquement pour les .torrent, pas pour les magnets
Les fichiers .torrent contiennent les métadonnées → pause/stopped OK, fichiers disponibles immédiatement.
Les magnets récupèrent les métadonnées via les pairs → sans pause pour permettre la connexion.
2026-05-06 22:47:26 +02:00
MasutaYunikon 2a77a38655 feat(torrent-clients): ajout du driver Real-Debrid
feat(organize): vérification de complétion par fichier avant d'importer
- Récupération de la progression par fichier (index + name + progress 0–1) dans qBittorrent, Transmission et rTorrent
- Ajout de getFiles() sur le driver qBittorrent et interface TorrentClientDriver
- Le worker filtre les fichiers incomplets (< 100%) avant move/copy/hardlink

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

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

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

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

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

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

feat: ajout du support multifile sur utorrent

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

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

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

feat: ajout de badge quand le fichier est introuvable

feat: suppression des series supprimé

feat: bouton tous télécharger sur les saisons et series
2026-04-14 19:05:11 +02:00
MasutaYunikon 5e79699ed9 fix: suppression de dest_filename du fichier d'organisation qui est faux et ne sert à rien 2026-04-14 18:37:54 +02:00
MasutaYunikon e41edd2f5e fix: ajout de la page management dans le layout 2026-04-14 17:40:27 +02:00
MasutaYunikon b881fdd557 fix: cast string 2026-04-14 17:36:43 +02:00
MasutaYunikon e648a81245 Merge remote-tracking branch 'origin/main' 2026-04-14 17:29:10 +02:00
MasutaYunikon 699c130edd feat: ajout de la possibilité de renommer des imports dans le bon format 2026-04-14 17:29:01 +02:00
github-actions[bot] acf802e7e1 chore: update version.txt to v3.8.1 2026-04-11 11:35:22 +00:00
MasutaYunikon f40e8c37e7 Merge pull request #85 from Masutayunikon/release-please--branches--main
chore(main): release 3.8.1
2026-04-11 13:35:04 +02:00
MasutaYunikon 2d2043d7ae chore(main): release 3.8.1 2026-04-11 13:34:39 +02:00
MasutaYunikon 788878ca6c Merge remote-tracking branch 'origin/main' 2026-04-11 13:34:12 +02:00
MasutaYunikon f96565a301 fix: la version dev se met uniquement en dev 2026-04-11 13:34:04 +02:00
github-actions[bot] 462ec87e89 chore: update version.txt to v3.8.0 2026-04-11 11:27:30 +00:00
MasutaYunikon e4b1971532 Merge pull request #84 from Masutayunikon/release-please--branches--main
chore(main): release 3.8.0
2026-04-11 13:27:12 +02:00
MasutaYunikon 8a888930e3 chore(main): release 3.8.0 2026-04-11 13:25:29 +02:00
MasutaYunikon 963436aa4a Merge branch 'main' of github.com:Masutayunikon/FanKarr 2026-04-11 13:24:53 +02:00
MasutaYunikon a3a1088c14 feat: force release for test 2026-04-11 13:24:43 +02:00
github-actions[bot] 590c310553 chore: update version.txt to v3.7.1 2026-04-11 11:18:21 +00:00
MasutaYunikon 6dd282e6ca Merge pull request #83 from Masutayunikon/release-please--branches--main
chore(main): release 3.7.1
2026-04-11 13:18:00 +02:00
MasutaYunikon 6150b4aea3 chore(main): release 3.7.1 2026-04-11 13:16:02 +02:00
MasutaYunikon c41f8c76cf Merge remote-tracking branch 'origin/main' 2026-04-11 13:15:36 +02:00
MasutaYunikon f593288b73 fix: ajout de la copy dans le worker 2026-04-11 13:13:19 +02:00
github-actions[bot] a4528aeb0c chore: update version.txt to v3.7.0 2026-04-11 11:11:15 +00:00
MasutaYunikon 0453ce44fb Merge pull request #82 from Masutayunikon/release-please--branches--main
chore(main): release 3.7.0
2026-04-11 13:10:52 +02:00
MasutaYunikon 1a6b08757b chore(main): release 3.7.0 2026-04-11 13:03:29 +02:00
MasutaYunikon 8c2d61de06 fix: type 2026-04-11 13:02:55 +02:00
MasutaYunikon 87fd964374 fix: type 2026-04-11 13:01:49 +02:00
MasutaYunikon 271fc14386 fix: settings 2026-04-11 12:59:24 +02:00
MasutaYunikon 176dfed0af fix: formatage 2026-04-11 12:58:24 +02:00
MasutaYunikon 34057638ca fix: formatage 2026-04-11 12:56:19 +02:00
MasutaYunikon bb29f02000 fix: ajout de l'option de suppresion dans l'interface settings.ts 2026-04-11 12:53:19 +02:00
MasutaYunikon 392f736925 feat: ajout du bouton copie
feat: ajout de l'option supprimé le torrent si l'option déplacer est activé

feat: ajout de la suppression des torrents depuis l'interface
2026-04-11 12:51:31 +02:00
MasutaYunikon 73b401f3e7 fix: l'import a le meme comportement que radarr/sonarr en ouvrant l'import dans le dossier de la serie attendu directement 2026-04-11 12:40:28 +02:00
MasutaYunikon f7c21f5fd1 feat: edit d'episodes importer dans le menu d'importation 2026-04-11 12:33:25 +02:00
MasutaYunikon 6078aac714 fix: vue des series importer 2026-04-11 12:21:48 +02:00
MasutaYunikon c61bd05f75 fix: vue des series importer 2026-04-11 12:19:46 +02:00
MasutaYunikon 89f225b980 fix: les series sans torrents sont bien organiser si importé manuellement 2026-04-11 12:15:35 +02:00
MasutaYunikon 66b0313ae9 fix: les series sans torrents sont bien organiser si importé manuellement 2026-04-11 12:12:31 +02:00
MasutaYunikon e02650d1cc fix: les series sans torrent et non ajoutées manuellement sont bien grisé 2026-04-11 12:10:03 +02:00
MasutaYunikon 45bf17c48f fix: les series sans torrent et non ajoutées manuellement sont bien grisé 2026-04-11 11:56:43 +02:00
MasutaYunikon 854839b16a fix: ajout des series non disponible pour pouvoir les add manuellement 2026-04-11 11:43:31 +02:00
MasutaYunikon 08c4122d5e fix: log 2026-04-10 21:06:55 +02:00
MasutaYunikon ef23a7119e fix: ajout de logs lors de non match 2026-04-10 21:03:57 +02:00
MasutaYunikon 21bcb2ba23 fix: organisation utilise l'extension du fichier au lieu de tester si ça existe 2026-04-10 21:00:24 +02:00
MasutaYunikon c94ff7da3f feat: ajout d'une "notification" dans la navbar si une nouvelle versions est disponible
feat: activation de l'import manuel
2026-04-10 18:05:47 +02:00
MasutaYunikon 71961eb998 feat: ajout du ratio, upload speed, etc dans les activités 2026-04-10 17:50:00 +02:00
MasutaYunikon 9b4dc4f782 feat: ajout d'une hashmap pour matcher les titres sur synologie et ajouter les hash de torrent manuellement 2026-04-10 17:37:49 +02:00
MasutaYunikon bb6d094383 Merge remote-tracking branch 'origin/main' 2026-04-10 12:46:05 +02:00
MasutaYunikon ea7ed597de fix: changer le noms de build arm64 et windows legacy pour une meilleur comprehension 2026-04-10 12:45:56 +02:00
MasutaYunikon ba736a6fe1 Merge pull request #81 from Masutayunikon/release-please--branches--main
chore(main): release 3.6.1
2026-04-10 12:39:53 +02:00
MasutaYunikon ceb18cef3c chore(main): release 3.6.1 2026-04-10 12:38:59 +02:00
MasutaYunikon 327cd66dcc Merge remote-tracking branch 'origin/main' 2026-04-10 12:38:30 +02:00
MasutaYunikon 31779d8f7e fix: windows baseline 2026-04-10 12:38:21 +02:00
MasutaYunikon d426662fd6 Merge pull request #80 from Masutayunikon/release-please--branches--main
chore(main): release 3.6.0
2026-04-10 12:21:44 +02:00
MasutaYunikon bf0285378d chore(main): release 3.6.0 2026-04-10 12:17:42 +02:00
MasutaYunikon bde760e8b4 Merge remote-tracking branch 'origin/main' 2026-04-10 12:17:14 +02:00
MasutaYunikon a358ee2d83 feat: ajout d'un exe pour les cpu non avx2 2026-04-10 12:17:06 +02:00
MasutaYunikon 559ce91954 Merge pull request #79 from Masutayunikon/release-please--branches--main
chore(main): release 3.5.0
2026-04-09 22:48:03 +02:00
MasutaYunikon b5bd8b9b48 chore(main): release 3.5.0 2026-04-09 22:47:19 +02:00
MasutaYunikon 9576ec9b23 fix: import manuelle desactivé pour le moment 2026-04-09 22:46:49 +02:00
MasutaYunikon a582991c15 feat: manual import 2026-04-09 22:36:15 +02:00
MasutaYunikon 2d0abab222 fix: utorrent path for import 2026-04-09 18:34:36 +02:00
MasutaYunikon 8ec323dc95 fix: remove log for mapping
fix: unlink before move

fix: l'organiseur prend les fichiers renommer maintenant

feat: ajout d'information dans le fichier d'organisation pour l'organisation manuel plus tard

fix: ajout d'une verif lors de l'écriture de la config sur les items dans body

fix: ne sauvegarde plus le mots de passe "fake" quand on edit un client

feat: ajout du driver rtorrent

fix: empeche un login failed de se repeter et de se faire bloquer par les clients
2026-04-09 18:30:34 +02:00
MasutaYunikon 630374def7 fix: remove log for mapping
fix: unlink before move
2026-04-09 16:04:19 +02:00
MasutaYunikon 0e09c85efd feat: add uttorent driver
fix: organiser take correct name
2026-04-09 15:59:55 +02:00
MasutaYunikon 89c0db5f36 fix: add log for candidate path 2026-04-08 22:07:19 +02:00
MasutaYunikon debf61f714 fix: polling des torrents à 60s
feat: ajout d'un bouton refresh
2026-04-08 19:39:31 +02:00
MasutaYunikon 0c53c1d32b Merge remote-tracking branch 'origin/main' 2026-04-08 19:25:28 +02:00
MasutaYunikon d563ed4c68 feat: ajout des parametres dossier de téléchargements pour les clients torrent
feat: ajout de synology downloader comme client

fix: augmentation du header size 8kb -> 32kb

feat: Ajout des mapping sur les clients

feat: auto update des fichiers nfo
2026-04-08 19:25:19 +02:00
MasutaYunikon 3b39a5a8c4 Merge pull request #78 from Masutayunikon/release-please--branches--main
chore(main): release 3.4.0
2026-04-08 15:33:40 +02:00
MasutaYunikon 50144c70dc chore(main): release 3.4.0 2026-04-08 15:14:07 +02:00
MasutaYunikon 482743bf3a fix: typo 2026-04-08 15:13:35 +02:00
MasutaYunikon f3a36af629 fix: le driver de Transmission n'est plus basé sur android
fix: les torrent inconnue en états seront mis dans la catégorie terminée
feat: ajout d'un retour visuel si on a un path par defaut sur /
fix: utilisation de nfo_filename au lieu de original_filename
2026-04-08 15:07:59 +02:00
MasutaYunikon bc960ba585 Merge pull request #77 from Nackophilz/main
feat: Ajout du support pour l'édition/mise à jour des clients torrent
2026-04-08 14:55:17 +02:00
ElPouki e648764715 Merge branch 'Masutayunikon:main' into main 2026-04-08 14:34:04 +02:00
ElPouki 15fac6c472 feat: Ajout du support pour l'édition/mise à jour des clients torrent
Nouveau helper updateClient et un endpoint PUT /api/torrent-clients/:uuid qui enregistre les modifications, journalise les mises à jour et renvoie le client.
Mise à jour de l'UI des paramètres pour ajouter un bouton "Modifier" et réutiliser la modale d'ajout de client pour les éditions (afin d'assurer le suivi de l'uuid ), bascule méthode PUT pour les requêtes de mise à jour, et actualisation de la liste des clients ainsi que des notifications en cas de modification réussie.
2026-04-08 14:33:48 +02:00
MasutaYunikon 61591020be Merge pull request #76 from Nackophilz/main
Fix : Saison 00 et double slash chemin
2026-04-08 14:21:07 +02:00
ElPouki f92b3c5fb9 Fix : Saison 00 et double slash chemin
Organize worker :  Traiter la saison 0 comme "Spéciaux" au lieu de "Saison 0".

FolderPicker : Ajouter un bouton "accueil" pour naviguer vers la racine, nettoyer le chemin sélectionné en supprimant les slashes de fin (avec un repli sur "/" pour la racine), et fiabiliser joinPath pour éviter les doubles slashes lorsque la base se termine par un slash
2026-04-08 14:12:01 +02:00
MasutaYunikon a39a98615b Merge pull request #69 from Masutayunikon/release-please--branches--main
chore(main): release 3.3.3
2026-04-08 10:04:19 +02:00
MasutaYunikon 2a3defdb34 chore(main): release 3.3.3 2026-04-07 20:07:35 +02:00
MasutaYunikon 733f8790ca fix: path 2026-04-07 20:07:03 +02:00
MasutaYunikon 4a9bbd9402 Merge remote-tracking branch 'origin/main' 2026-04-07 19:53:19 +02:00
MasutaYunikon 1562ca32f9 fix: MediaManagementView.vue api call is now in mounted 2026-04-07 19:53:10 +02:00
MasutaYunikon 7b780e4fa1 Merge pull request #68 from Masutayunikon/release-please--branches--main
chore(main): release 3.3.2
2026-04-07 19:42:14 +02:00
MasutaYunikon 444b30ef3d chore(main): release 3.3.2 2026-04-07 19:41:28 +02:00
MasutaYunikon 42b46496d1 Merge remote-tracking branch 'origin/main' 2026-04-07 19:40:57 +02:00
MasutaYunikon f092da4108 fix: use fetch instead of useFetch 2026-04-07 19:40:49 +02:00
MasutaYunikon 8e635c1d28 Merge pull request #67 from Masutayunikon/release-please--branches--main
chore(main): release 3.3.1
2026-04-07 19:24:53 +02:00
MasutaYunikon 3b8b7a0454 chore(main): release 3.3.1 2026-04-07 19:24:23 +02:00
MasutaYunikon 9ef1516b0b Merge remote-tracking branch 'origin/main' 2026-04-07 19:23:57 +02:00
MasutaYunikon 1af210058c fix: add driver register for transmission 2026-04-07 19:23:49 +02:00
MasutaYunikon 6caad83081 Merge pull request #66 from Masutayunikon/release-please--branches--main
chore(main): release 3.3.0
2026-04-07 19:05:43 +02:00
MasutaYunikon 520bfcfae1 chore(main): release 3.3.0 2026-04-07 19:05:07 +02:00
MasutaYunikon 2e5bc8953a Merge remote-tracking branch 'origin/main' 2026-04-07 19:04:34 +02:00
MasutaYunikon f304562f01 feat: add transmission client
feat: set defaultPath for any system
2026-04-07 19:04:26 +02:00
MasutaYunikon c9690d9a1b Merge pull request #65 from Masutayunikon/release-please--branches--main
chore(main): release 3.2.1
2026-04-07 16:18:47 +02:00
MasutaYunikon 5daface279 chore(main): release 3.2.1 2026-04-07 16:17:52 +02:00
MasutaYunikon 5687aaa5d9 Merge remote-tracking branch 'origin/main' 2026-04-07 16:17:22 +02:00
MasutaYunikon ba67817ea0 fix: bun target android 2026-04-07 16:17:13 +02:00
MasutaYunikon e0452dbd0c Merge pull request #64 from Masutayunikon/release-please--branches--main
chore(main): release 3.2.0
2026-04-07 16:14:55 +02:00
MasutaYunikon c376061333 chore(main): release 3.2.0 2026-04-07 16:14:30 +02:00
MasutaYunikon bb02ea6cf8 fix: android build name 2026-04-07 16:13:57 +02:00
MasutaYunikon 887beae614 Merge remote-tracking branch 'origin/main' 2026-04-07 16:11:07 +02:00
MasutaYunikon 1290d5c925 feat: add build for arm64
feat: add developer settings on settings and dashboard debug
2026-04-07 16:10:57 +02:00
MasutaYunikon dffb340250 Merge pull request #63 from Masutayunikon/release-please--branches--main
chore(main): release 3.1.1
2026-03-28 10:45:16 +01:00
MasutaYunikon 059298f537 chore(main): release 3.1.1 2026-03-28 10:44:47 +01:00
MasutaYunikon 0ba2144f2f Merge remote-tracking branch 'origin/main' 2026-03-28 10:44:27 +01:00
MasutaYunikon 83a2fb9860 fix: remove plex settings 2026-03-28 10:44:13 +01:00
MasutaYunikon 5445fadf94 Merge remote-tracking branch 'origin/main' 2026-03-28 10:36:26 +01:00
MasutaYunikon 6d110b86dd fix: torrent for multiple seasons is now working 2026-03-28 10:36:16 +01:00
MasutaYunikon 2ef6d4738a Merge pull request #62 from Masutayunikon/release-please--branches--main
chore(main): release 3.1.0
2026-03-27 22:42:34 +01:00
MasutaYunikon 65f7d4e109 chore(main): release 3.1.0 2026-03-27 22:40:50 +01:00
MasutaYunikon 6166c370ef Merge remote-tracking branch 'origin/main' 2026-03-27 22:40:28 +01:00
MasutaYunikon ad1cade6bf feat: use original_name and formated_name from scraper to bypass timeout 2026-03-27 22:40:21 +01:00
MasutaYunikon 803519062a Merge pull request #61 from Masutayunikon/release-please--branches--main
chore(main): release 3.0.0
2026-03-27 07:03:30 +01:00
MasutaYunikon 6246c44994 chore(main): release 3.0.0 2026-03-27 07:03:00 +01:00
MasutaYunikon 9780aee40d feat!: refonte complète du frontend et amélioration des logs serveur
BREAKING CHANGE: nouvelle architecture frontend avec layout sidebar, système de thèmes et routes restructurées

### Frontend
- nouveau layout global `AppLayout.vue` avec sidebar de navigation inspirée Radarr/Sonarr
- sidebar configurable via un objet `NavItem[]` avec support parents/enfants cliquables
- 3 thèmes CSS (Indigo Électrique, Crimson Night, Teal Froid) persistés via localStorage
- switcher de thème en bas de sidebar avec pastilles colorées
- logo FanKarr cliquable, bouton déconnexion discret dans le header sidebar
- badge animé sur "Activité" affichant le nombre de téléchargements en cours
- migration complète vers Tailwind v4 avec variables CSS `@theme`
- classes utilitaires globales : `btn-primary`, `btn-secondary`, `settings-card`, `settings-input`

### Vues
- `SeriesView` : barre colorée en bas des affiches (vert/bleu/orange), dropdown filtres, boutons taille S/M/L/XL persistés
- `SerieView` : bouton état unique par épisode (↓/⟳/✓/✗), badges simplifiés, toast via `useToast` global
- `ActivityView` : onglets pill, stats, toggle "masquer importés", polling silencieux
- `DashboardView` : stats catalogue/importées/en cours/erreurs, torrents actifs, derniers imports
- `SettingsView` découpée en 3 vues indépendantes : `DownloadClientView`, `MediaManagementView`, `CatalogueView`
- `LogsView` déplacée dans `src/views/settings/`
- `AuthView` : migré au système de thème

### Composants
- `SidebarNavItem` : support badge, chevron masqué si badge présent
- `SettingsToggle` : composant réutilisable `v-model`
- `FolderPicker` : migré au thème, `Teleport` intégré
- `ToastContainer` : déplacé en bas droite, groupement des toasts similaires avec badge count

### Router
- `AppLayout` comme layout racine pour toutes les routes protégées
- `/auth` reste hors layout
- redirections `/` → `/series`, `/downloads` → `/activity`
- `SettingsLayout` avec titre dynamique par route
- scroll géré sur `<main id="main-scroll">` dans `AppLayout`

### Stores & composables
- nouveau store `downloads` partagé pour le badge sidebar
- `usePosterSize` : taille des affiches persistée en localStorage
- `useToast` : groupement des toasts du même type, timer reset au lieu d'empilement

### Serveur
- logs structurés via `logger` dans `auth.ts`, `organize.ts`, `organize-worker.ts`, `torrent-clients/index.ts`, `qbittorrent.ts`, `index.ts`
- suppression des `console.log/error/warn` en faveur de `logger.info/warn/error/debug`
- messages worker routés vers `logger` via `organize.ts`
- logs `debug` pour les événements verbeux (healthcheck, skip, worker terminé)
2026-03-27 07:02:31 +01:00
MasutaYunikon e5e8baf1fd Merge remote-tracking branch 'origin/main' 2026-03-26 07:06:02 +01:00
MasutaYunikon 79e73b603f feat: hors-fankai badge and fix series datetime and typo 2026-03-26 07:05:53 +01:00
MasutaYunikon 49c3a09129 Merge pull request #60 from Masutayunikon/release-please--branches--main
chore(main): release 2.4.1
2026-03-24 22:01:53 +01:00
MasutaYunikon 4a24b7fa0f chore(main): release 2.4.1 2026-03-24 22:01:36 +01:00
MasutaYunikon 80aef10230 Merge remote-tracking branch 'origin/main' 2026-03-24 22:00:58 +01:00
MasutaYunikon 92055caf4d fix: add usePlexTitles to return function 2026-03-24 22:00:47 +01:00
MasutaYunikon d8e284b0de Merge pull request #59 from Masutayunikon/release-please--branches--main
chore(main): release 2.4.0
2026-03-24 21:55:31 +01:00
MasutaYunikon 0b98269695 chore(main): release 2.4.0 2026-03-24 19:19:44 +01:00
MasutaYunikon 402946b462 Merge remote-tracking branch 'origin/main' 2026-03-24 19:19:03 +01:00
MasutaYunikon c374ceb0a7 fix(organize): sanitisation des caractères spéciaux dans les noms de dossiers
Le titre "Kaguya-sama : Love is War Henshū" contenait un ":" qui causait
une troncation du nom de dossier sur certains systèmes de fichiers.
sanitizeDirName() remplace ":" par " -" et supprime les autres caractères
interdits (<>"/\|?*).

feat(settings): option titres optimisés Plex (title_for_plex)

Nouveau toggle "Noms de dossiers" dans les paramètres. Quand activé,
utilise le champ title_for_plex de l'API Fankai pour les noms de dossiers
au lieu du titre brut — évite les caractères spéciaux incompatibles avec
Plex et les systèmes de fichiers Windows/NTFS.

enrichSeriesDataWithOriginalFilenames fetche maintenant /series/{id} pour
injecter title_for_plex dans chaque serieData avant de passer au worker.

fix(organize): retry sur les erreurs réseau dans enrichSeriesDataWithOriginalFilenames

2 tentatives avec 500ms d'attente entre les deux pour les erreurs réseau
ponctuelles sur /seasons/{id}/episodes — évite que formatted_name soit null
à cause d'un timeout isolé au démarrage.

fix(catalogue): activeTorrents filtre désormais uniquement state=downloading

Les torrents en seeding étaient inclus dans activeTorrents, forçant
computeSerieDownloadState à retourner "downloading" même pour les séries
entièrement importées. Les badges affichaient "EN COURS" au lieu de "IMPORTÉ".
2026-03-24 19:18:51 +01:00
MasutaYunikon 6f21b0a5cd Merge pull request #58 from Masutayunikon/release-please--branches--main
chore(main): release 2.3.3
2026-03-24 03:41:25 +01:00
MasutaYunikon 1c36bb7688 chore(main): release 2.3.3 2026-03-24 03:39:35 +01:00
MasutaYunikon 82a9d51731 Merge remote-tracking branch 'origin/main' 2026-03-24 03:39:03 +01:00
MasutaYunikon ac03aa3c11 fix: recuperation des episodes avec un delai 2026-03-24 03:38:54 +01:00
MasutaYunikon d8173a6d72 Merge pull request #57 from Masutayunikon/release-please--branches--main
chore(main): release 2.3.2
2026-03-24 02:55:28 +01:00
MasutaYunikon 60c9ffa14c chore(main): release 2.3.2 2026-03-24 02:54:52 +01:00
MasutaYunikon 0751c2edcc Merge remote-tracking branch 'origin/main' 2026-03-24 02:54:20 +01:00
MasutaYunikon 7e050a699d fix: badge catalogue sur les series importé 2026-03-24 02:54:12 +01:00
MasutaYunikon 9badada4a3 Merge pull request #56 from Masutayunikon/release-please--branches--main
chore(main): release 2.3.1
2026-03-24 02:43:17 +01:00
MasutaYunikon 5e881272a1 chore(main): release 2.3.1 2026-03-24 02:42:59 +01:00
MasutaYunikon a229a1eb3b Merge remote-tracking branch 'origin/main' 2026-03-24 02:42:27 +01:00
MasutaYunikon b621adc6f6 fix: add readsettings import 2026-03-24 02:42:19 +01:00
MasutaYunikon 7d3bc413ef Merge pull request #55 from Masutayunikon/release-please--branches--main
chore(main): release 2.3.0
2026-03-24 02:36:52 +01:00
MasutaYunikon 6f9b7892b1 chore(main): release 2.3.0 2026-03-24 02:36:06 +01:00
MasutaYunikon 2332b544e6 fix: add config directory to .gitignore 2026-03-24 02:35:34 +01:00
MasutaYunikon 3b091b7aa8 Merge remote-tracking branch 'origin/main' 2026-03-24 02:32:56 +01:00
MasutaYunikon e1e430a968 feat(settings): ajout du toggle import automatique (activé par défaut)
Nouveau champ autoImport dans Settings — quand désactivé, le worker ne se
lance jamais automatiquement. Activé par défaut pour conserver le comportement
existant. Le toggle est exposé dans SettingsView avec une description claire.

feat(organize): nommage des fichiers selon original_filename ou formatted_name

Selon l'option NFO :
- nfoSupport activé → original_filename (correspond aux .nfo GitLab)
- nfoSupport désactivé → formatted_name de l'API Fankai (format Jellyfin/Plex
  standardisé, ex: Serie.S01E01.MULTI.1080p.x264-FANKAI.mkv)

formatted_name est maintenant injecté dans enrichSeriesDataWithOriginalFilenames
en même temps que original_filename.

feat(catalogue): tri du catalogue avec 4 options

- A→Z (défaut, localeCompare français)
- Z→A
- Récents (par année décroissante)
- Importés (fichiers importés en premier)

feat(catalogue): badge IMPORTÉ et refresh automatique post-import

- Renommage ORGANISÉ → IMPORTÉ sur les affiches et dans les toasts
- Le catalogue se rafraîchit automatiquement quand fetchOrganizeNotifs
  détecte de nouvelles notifications d'import

feat(settings): textes génériques sans mention de logiciels spécifiques

- "Dossier Complétion" → "Dossier Téléchargements"
- "Dossier Jellyfin" → "Médiathèque Fankai"
- "Mode Organisation" → "Mode Import"
- "Organisation" → "Import" partout dans l'interface

feat(downloads): bouton Organiser tout renommé Importer tout

Itère sur tous les torrents seeding non importés et les envoie
séquentiellement à /api/organize avec feedback toast de progression.
2026-03-24 02:32:47 +01:00
MasutaYunikon 2d49125e09 Merge pull request #54 from Masutayunikon/release-please--branches--main
chore(main): release 2.2.0
2026-03-23 15:38:51 +01:00
MasutaYunikon 721047c95f chore(main): release 2.2.0 2026-03-23 15:38:03 +01:00
MasutaYunikon 74662de2b6 Merge remote-tracking branch 'origin/main' 2026-03-23 15:37:23 +01:00
MasutaYunikon 84ef0333f3 feat: add organize all button
fix: organizer reuse the wrong name for gitlab path
2026-03-23 15:37:07 +01:00
MasutaYunikon c6624843b9 Merge pull request #53 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.3
2026-03-23 06:09:59 +01:00
MasutaYunikon 8bd30913cd chore(main): release 2.1.3 2026-03-23 06:09:36 +01:00
MasutaYunikon d7eb932ed3 Merge remote-tracking branch 'origin/main' 2026-03-23 06:09:04 +01:00
MasutaYunikon 4594dc5189 fix: use magnet before torrent 2026-03-23 06:08:48 +01:00
MasutaYunikon 894a621e85 Merge pull request #52 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.2
2026-03-23 05:38:16 +01:00
MasutaYunikon 23dbf86e70 chore(main): release 2.1.2 2026-03-23 05:38:02 +01:00
MasutaYunikon d86138ef1c Merge remote-tracking branch 'origin/main' 2026-03-23 05:37:32 +01:00
MasutaYunikon 4aa6f99676 fix: path now work on every device 2026-03-23 05:37:23 +01:00
MasutaYunikon 10432e0f99 Merge pull request #51 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.1
2026-03-23 05:32:19 +01:00
MasutaYunikon 4f69f8bf92 chore(main): release 2.1.1 2026-03-23 05:32:01 +01:00
MasutaYunikon 1511dd3ca3 Merge remote-tracking branch 'origin/main' 2026-03-23 05:31:29 +01:00
MasutaYunikon 23967a81d6 fix: torrent path to hash 2026-03-23 05:31:20 +01:00
MasutaYunikon 21ff025376 Merge pull request #50 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.0
2026-03-23 05:00:49 +01:00
MasutaYunikon 9ba19f9539 chore(main): release 2.1.0 2026-03-23 05:00:34 +01:00
MasutaYunikon 215d802b12 fix: remove logo
Release-As: 2.1.0
2026-03-23 05:00:05 +01:00
MasutaYunikon 21dccbe5d9 Merge remote-tracking branch 'origin/main'
Release-As: 2.1.0
2026-03-23 04:56:45 +01:00
MasutaYunikon 329150f1c4 fix(organize): migration vers paths { infohash, path } pour le matching fichiers
BREAKING CHANGE: le format paths[] dans series/{id}.json passe de string[]
à { infohash: string, path: string }[] — nécessite une mise à jour du scraper.

fix(organize): buildFileMap matche par infohash au lieu d'index ou titre

L'ancien système utilisait soit un index positionnel fragile, soit le titre
du torrent comme clé de matching — les deux cassaient si l'utilisateur
renommait le torrent dans qBittorrent ou si un pack intégrale ne couvrait
pas tous les épisodes. Désormais chaque path est lié à un infohash précis.

fix(organize): source fichier via save_path qBittorrent + fullPath relatif

path.join(savePath, fullPath) utilise le save_path retourné par qBittorrent
au lieu de deviner le nom du dossier depuis le titre du torrent. Résiste
aux renommages de dossier côté client.

fix(organize): scanMediaPath adapté au nouveau format paths[]

L'index filename → infohash est maintenant construit directement depuis
ep.paths[].infohash sans passer par la hiérarchie torrents série/saison/épisode.
2026-03-23 04:52:33 +01:00
MasutaYunikon 17f7234b49 Merge pull request #48 from Masutayunikon/release-please--branches--main
chore(main): release 2.0.0
2026-03-23 04:05:16 +01:00
MasutaYunikon 8e529d3845 chore(main): release 2.0.0 2026-03-23 04:04:52 +01:00
MasutaYunikon a3f3539a27 Merge remote-tracking branch 'origin/main' 2026-03-23 04:04:23 +01:00
MasutaYunikon d5c37e8f2c feat!: migration vers nouvelle architecture API et scraper par série
BREAKING CHANGE: suppression de torrent_final.json au profit d'un système
de fichiers par série (series/{id}.json) avec cache mémoire GitHub 6h.
Version majeure — nécessite un nouveau scraper et une mise à jour complète.

feat(api): cache mémoire GitHub TTL 6h avec invalidation via /api/update

Plus de fichiers locaux dans /config pour les torrents. Les données sont
fetchées depuis GitHub (available.json + series/{id}.json) et mises en
cache 6h. /api/update vide le cache pour forcer le rechargement.

feat(api): adaptation complète à la nouvelle structure API Fankai

- normalizeSerie/Season/Episode pour aplatir images.poster → poster_image
- Proxy wsrv.nl pour redimensionner les images (poster 300px, fanart 1280px,
  thumbnail 400px) et réduire le temps de chargement
- duration en secondes → affichage en minutes dans SerieView

feat(organize): refonte complète sans torrent_final.json

- organize-worker.ts reçoit seriesData via message au lieu de lire un fichier
- buildFileMap construit filename→{season_number,original_filename} depuis paths[]
- enrichSeriesDataWithOriginalFilenames fetche /seasons/{id}/episodes avant
  de passer les données au worker pour le renommage correct des fichiers
- Suppression de SERIE_TITLE_GITLAB_MAP (Kaï→Kai résolu côté API)
- scanMediaPath adapté : plus de torrentsPath, prend seriesData en paramètre

feat(api): matching torrents par hiérarchie série/saison/épisode

- Torrents niveau série → pack_integrale
- Torrents niveau saison → pack_saison (indexé par season_number)
- Torrents niveau épisode → episode
- Matching via paths[torrentIdx] pour résoudre episode↔fichier

feat(series-view): badge "Hors Fankai" sur épisodes de torrents manuels
feat(series-view): tooltip raw name sur boutons intégrale via :title
feat(scraper): extraction nom bencode dans raw pour torrents locaux
fix(series): boutons saison Yu-Gi-Oh via fallback season_number
fix(catalogue): état download basé sur épisodes organisés vs torrents bruts

Release-As: 2.0.0
2026-03-23 04:04:12 +01:00
MasutaYunikon a5f5dfb0da Merge pull request #47 from Masutayunikon/release-please--branches--main
chore(main): release 1.14.0
2026-03-21 08:01:19 +01:00
MasutaYunikon 08531c80f3 chore(main): release 1.14.0 2026-03-21 07:59:16 +01:00
MasutaYunikon 96ce207027 Merge remote-tracking branch 'origin/main' 2026-03-21 07:58:54 +01:00
MasutaYunikon 13f9a27af8 fix(series): résolution boutons de téléchargement saisons Yu-Gi-Oh et séries similaires
Les boutons "Saison entière" n'apparaissaient pas pour les séries dont les
season_id du scraper ne correspondent pas aux season_id de l'API Fankai.

- Ajouter seasonTorrentMapBySn indexé par season_number en fallback
- Utiliser season_number comme clé de secours dans enrichedSeasons

fix(catalogue): état "partiel" incorrect quand l'intégrale est organisée

Le badge partial s'affichait même quand tous les épisodes étaient organisés
via un pack_integrale, car les torrents d'épisodes séparés faisaient baisser
le ratio. La logique est maintenant basée sur les episode_id uniques.

feat(series-view): badge "Hors Fankai" sur les épisodes de torrents manuels

Les épisodes issus de MANUAL_TORRENTS (ex: MHA Film 4) sont maintenant
marqués d'un badge violet "Hors Fankai" avec tooltip explicatif.

- Propager manual: true du torrent dans le TorrentRef de l'épisode
- Afficher le badge dans SerieView.vue si ep.torrent?.manual

feat(series-view): tooltip avec le nom du torrent sur les boutons intégrale

Quand plusieurs intégrales existent pour une série, survoler le bouton
affiche le raw name du torrent via :title="t.raw".

feat(scraper): extraction du nom bencode pour les torrents locaux

Le champ raw des LOCAL_TORRENTS est maintenant renseigné depuis info.name
extrait directement du fichier .torrent via bencode, au lieu d'utiliser
la clé du dictionnaire LOCAL_TORRENTS.

- fetch_torrent_files_from_local retourne maintenant (files, infohash, name)
- torrent["raw"] est mis à jour avec le vrai nom bencode
2026-03-21 07:58:45 +01:00
MasutaYunikon d0d40c02b5 Merge pull request #46 from Masutayunikon/release-please--branches--main
chore(main): release 1.13.0
2026-03-21 04:21:36 +01:00
MasutaYunikon 0e541a19ba chore(main): release 1.13.0 2026-03-21 04:21:09 +01:00
MasutaYunikon 1d71c40a2b Merge pull request #45 from Masutayunikon/release-please--branches--main
chore(main): release 1.12.2
2026-03-21 04:20:11 +01:00
MasutaYunikon adc3e91122 chore(main): release 1.12.2 2026-03-21 04:18:31 +01:00
MasutaYunikon b260208701 Merge remote-tracking branch 'origin/main' 2026-03-21 04:18:09 +01:00
MasutaYunikon 3dd517c37f fix: use season number from resolved episodes in priority 2026-03-21 04:17:59 +01:00
MasutaYunikon 93397bee77 Merge pull request #44 from Masutayunikon/release-please--branches--main
chore(main): release 1.12.1
2026-03-21 04:00:35 +01:00
MasutaYunikon 3f72c01911 chore(main): release 1.12.1 2026-03-21 03:59:32 +01:00
MasutaYunikon 77e6a2e38c Merge remote-tracking branch 'origin/main' 2026-03-21 03:59:09 +01:00
MasutaYunikon bef99183aa fix: path 2026-03-21 03:58:55 +01:00
MasutaYunikon 7b84229510 Merge pull request #43 from Masutayunikon/release-please--branches--main
chore(main): release 1.12.0
2026-03-21 03:45:31 +01:00
MasutaYunikon 23c46a621e chore(main): release 1.12.0 2026-03-21 03:45:10 +01:00
MasutaYunikon 4dc7255bb0 Merge remote-tracking branch 'origin/main' 2026-03-21 03:44:46 +01:00
MasutaYunikon 180513c98a feat: now rename episodes with original_filename from api 2026-03-21 03:44:37 +01:00
MasutaYunikon 00894bc069 Merge pull request #42 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.10
2026-03-21 03:18:52 +01:00
MasutaYunikon a1b128640d chore(main): release 1.11.10 2026-03-21 03:18:32 +01:00
MasutaYunikon 8bec4c62fa fix: improve torrent organization and cleanup logic 2026-03-21 03:18:09 +01:00
MasutaYunikon a7ba6a54a3 Merge remote-tracking branch 'origin/main' 2026-03-21 02:14:03 +01:00
MasutaYunikon 33d0b9a52d fix: season 0 not clonficting anymore 2026-03-21 02:13:52 +01:00
MasutaYunikon 46546d0178 Merge pull request #41 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.9
2026-03-21 00:09:13 +01:00
MasutaYunikon 756ced0001 chore(main): release 1.11.9 2026-03-20 22:03:02 +01:00
MasutaYunikon 2addb39ba3 Merge remote-tracking branch 'origin/main' 2026-03-20 22:02:40 +01:00
MasutaYunikon 232ede4292 fix: season 0 resolved is no longer skipped in the worker 2026-03-20 22:02:17 +01:00
MasutaYunikon f161e84a1d Merge pull request #40 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.8
2026-03-20 20:31:54 +01:00
MasutaYunikon 4d4a8029f6 chore(main): release 1.11.8 2026-03-20 20:31:00 +01:00
MasutaYunikon 2a1dcddd90 Merge remote-tracking branch 'origin/main' 2026-03-20 20:30:33 +01:00
MasutaYunikon 4d319f97bc fix: config path /config/config to /config 2026-03-20 20:30:23 +01:00
MasutaYunikon 3372d4cb40 Merge pull request #39 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.7
2026-03-20 20:18:44 +01:00
MasutaYunikon eb22bb669f chore(main): release 1.11.7 2026-03-20 20:18:19 +01:00
MasutaYunikon 0d64a66977 Merge remote-tracking branch 'origin/main' 2026-03-20 20:17:54 +01:00
MasutaYunikon b0e81166e1 fix: worker path 2026-03-20 20:17:46 +01:00
MasutaYunikon 79d7aa7485 Merge pull request #38 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.6
2026-03-20 17:30:18 +01:00
MasutaYunikon af995a84cb chore(main): release 1.11.6 2026-03-20 17:29:09 +01:00
MasutaYunikon 5a71bdca3c Merge remote-tracking branch 'origin/main' 2026-03-20 17:28:46 +01:00
MasutaYunikon 77d918fbe0 fix: path 2026-03-20 17:28:09 +01:00
MasutaYunikon a0b255338d Merge pull request #37 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.5
2026-03-20 17:01:44 +01:00
MasutaYunikon e0b09c144f chore(main): release 1.11.5 2026-03-20 17:00:37 +01:00
MasutaYunikon 418d09b07f Merge remote-tracking branch 'origin/main' 2026-03-20 17:00:07 +01:00
MasutaYunikon 4c7936b019 fix: export _isBunBinary 2026-03-20 16:59:58 +01:00
MasutaYunikon 77f42b5b3e Merge pull request #36 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.4
2026-03-20 16:57:17 +01:00
MasutaYunikon bd69f7a730 chore(main): release 1.11.4 2026-03-20 16:55:47 +01:00
MasutaYunikon 3ce6523248 Merge remote-tracking branch 'origin/main' 2026-03-20 16:55:16 +01:00
MasutaYunikon 2efed9dcfc fix: add config.ts with config variable (BASE_DIR...) 2026-03-20 16:55:03 +01:00
MasutaYunikon 88e2a0bc38 Merge pull request #35 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.3
2026-03-20 16:08:46 +01:00
MasutaYunikon 338e3f8547 chore(main): release 1.11.3 2026-03-20 15:41:14 +01:00
MasutaYunikon d1ccc902da Merge remote-tracking branch 'origin/main' 2026-03-20 15:40:40 +01:00
MasutaYunikon 5e54e24b60 fix: entrypoint.sh config directory 2026-03-20 15:40:28 +01:00
MasutaYunikon aae2f592b5 Merge pull request #34 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.2
2026-03-20 02:11:37 +01:00
MasutaYunikon bd94e6a460 chore(main): release 1.11.2 2026-03-20 02:11:01 +01:00
MasutaYunikon cbe3e1a786 Merge remote-tracking branch 'origin/main' 2026-03-20 02:10:38 +01:00
MasutaYunikon 7a0917e053 fix: port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve
- Port par défaut 3001 → 9898
- Volume /app/data → /config (entrypoint, docker-compose, README)
- Normalisation titres Kaï/Kai dans index.ts (catalogue + déduplication + serie detail)
- Fix pagination GitLab NFO (per_page=100 → loop multi-pages)
- Fix _is_bonus_filename avec word boundaries (\b) — évite faux positifs "national"→"ona"
- Fix dossier Bonus/Specials exclus : récupère flottants (7,5/19,5) et S00Exx → saison 0
- _EP_FLOAT_PATTERN supporte virgules (7,5) en plus des points (7.5)
- Ajout strict_season dans find_episode/resolve_ep — évite collision ep1 S1 vs ep1 S2 (JoJo Parties)
- Ajout SEASON_EP_OFFSET pour Hokuto No Ken (saison 02 torrent → +16 offset API)
- Fallback non-strict si strict_season échoue (Hokuto ep 5-16 S1)
- Fix collect_covered_episodes : fallback torrent.serie_id si absent dans resolved_episodes
- Fix s6_verify : get_all_specials + compteur S00 séparé, déduplication Kaï/Kai, format OK/TOTAL
- Ajout MANUAL_TORRENTS dans s4 (MHA Film 4 You're Next URL Nyaa)
- Injection torrents locaux manquants dans pipeline (Reborn! Kaï via fichier local)
- Fix s5 : résolution directe torrents manuels season_number=0
- Fix s2 : pack_saga_slash et pack_partie_slash → pack_integrale, lookahead (?<!/) sur episode_num_tiret_apres
- Fix s5 skip condition : re-résoudre si resolved_count < files_count (Fairy Tail)
- README : note hardlinks volumes multiples, port, chemins config
2026-03-20 02:10:27 +01:00
MasutaYunikon 8ac4f7a65b Merge pull request #33 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.1
2026-03-14 00:59:14 +01:00
MasutaYunikon 453cd47e85 chore(main): release 1.11.1 2026-03-14 00:58:57 +01:00
MasutaYunikon d5679ec529 Merge remote-tracking branch 'origin/main' 2026-03-14 00:58:37 +01:00
MasutaYunikon 2f0be502c3 fix: worker now work on multiple github files pages 2026-03-14 00:58:29 +01:00
MasutaYunikon ec971915b5 Merge pull request #32 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.0
2026-03-13 19:03:34 +01:00
MasutaYunikon bb71b241d1 chore(main): release 1.11.0 2026-03-13 19:03:16 +01:00
MasutaYunikon 7aa01755ec Merge remote-tracking branch 'origin/main' 2026-03-13 19:02:46 +01:00
MasutaYunikon 8ce0ca1f24 feat: fix worker for multiple series in integral (one piece fix ?) 2026-03-13 19:02:38 +01:00
MasutaYunikon 620aac6b38 Merge pull request #31 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.3
2026-03-13 04:35:00 +01:00
MasutaYunikon e99e413e4e chore(main): release 1.10.3 2026-03-13 04:34:21 +01:00
MasutaYunikon fb578cf9f9 Merge remote-tracking branch 'origin/main' 2026-03-13 04:34:00 +01:00
MasutaYunikon 78e3055344 fix: worker path 2026-03-13 04:33:51 +01:00
MasutaYunikon 15f5847c97 Merge pull request #30 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.2
2026-03-13 04:25:54 +01:00
MasutaYunikon 8736b58d8c chore(main): release 1.10.2 2026-03-13 04:25:39 +01:00
MasutaYunikon 12e8e2f93a Merge remote-tracking branch 'origin/main' 2026-03-13 04:25:17 +01:00
MasutaYunikon ed2306bb70 fix: add target node 2026-03-13 04:25:09 +01:00
MasutaYunikon b3fd57c2c8 Merge pull request #29 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.1
2026-03-13 04:22:24 +01:00
MasutaYunikon c2da12409a chore(main): release 1.10.1 2026-03-13 04:22:06 +01:00
MasutaYunikon 178a57c444 Merge remote-tracking branch 'origin/main' 2026-03-13 04:21:42 +01:00
MasutaYunikon 1afe13c555 fix: add target node 2026-03-13 04:21:34 +01:00
MasutaYunikon c8963cc4cf Merge pull request #28 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.0
2026-03-13 04:19:01 +01:00
MasutaYunikon 391b793e39 chore(main): release 1.10.0 2026-03-13 04:18:23 +01:00
MasutaYunikon 9a1519e6cd Merge pull request #27 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.5
2026-03-13 04:17:27 +01:00
MasutaYunikon a12237af68 chore(main): release 1.9.5 2026-03-13 04:16:50 +01:00
MasutaYunikon d304aa6a26 Merge remote-tracking branch 'origin/main' 2026-03-13 04:16:29 +01:00
MasutaYunikon 27092b3968 fix: worker outside the exe 2026-03-13 04:16:07 +01:00
MasutaYunikon c1c36018b1 Merge pull request #26 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.4
2026-03-13 04:12:38 +01:00
MasutaYunikon b462ec717b chore(main): release 1.9.4 2026-03-13 04:11:56 +01:00
MasutaYunikon 65518861ac Merge remote-tracking branch 'origin/main' 2026-03-13 04:11:36 +01:00
MasutaYunikon ed75280e70 fix: worker outside the exe 2026-03-13 04:11:27 +01:00
MasutaYunikon 9f53a9fef5 Merge pull request #25 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.3
2026-03-13 04:02:39 +01:00
MasutaYunikon 3e5dc8d1d4 chore(main): release 1.9.3 2026-03-13 04:02:06 +01:00
MasutaYunikon bcb7f656a1 Merge remote-tracking branch 'origin/main' 2026-03-13 04:01:43 +01:00
MasutaYunikon 372359d040 fix: use bcryptjs for compilation 2026-03-13 04:01:34 +01:00
MasutaYunikon 63847c583a Merge pull request #24 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.2
2026-03-13 03:52:42 +01:00
MasutaYunikon e90f85af70 chore(main): release 1.9.2 2026-03-13 03:52:30 +01:00
MasutaYunikon 0c683d4e37 Merge remote-tracking branch 'origin/main' 2026-03-13 03:52:10 +01:00
MasutaYunikon 16b28004cf fix: workflow directory 2026-03-13 03:52:02 +01:00
MasutaYunikon 558f4e2a1b Merge pull request #23 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.1
2026-03-13 03:47:53 +01:00
MasutaYunikon 352c18029f chore(main): release 1.9.1 2026-03-13 03:47:33 +01:00
MasutaYunikon 465d3dba8b fix: workflow directory 2026-03-13 03:47:09 +01:00
MasutaYunikon 7b82d60c69 Merge remote-tracking branch 'origin/main' 2026-03-13 03:43:01 +01:00
MasutaYunikon af03aecf0d fix: workflow permmisions 2026-03-13 03:42:50 +01:00
MasutaYunikon f878298cef Merge pull request #22 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.0
2026-03-13 03:39:58 +01:00
MasutaYunikon fc5dd1d36d chore(main): release 1.9.0 2026-03-13 03:39:04 +01:00
MasutaYunikon 1b73e7d0a3 Merge remote-tracking branch 'origin/main' 2026-03-13 03:38:43 +01:00
MasutaYunikon 8030bf14f9 feat: add windows / linux / macos binaries on build 2026-03-13 03:38:34 +01:00
MasutaYunikon 908a8b6076 Merge pull request #21 from Masutayunikon/release-please--branches--main
chore(main): release 1.8.2
2026-03-13 02:52:27 +01:00
MasutaYunikon 0d2708ca09 chore(main): release 1.8.2 2026-03-13 02:52:12 +01:00
MasutaYunikon fb7e443c23 Merge remote-tracking branch 'origin/main' 2026-03-13 02:51:50 +01:00
MasutaYunikon 8fab68a0e4 fix: season fallback 2026-03-13 02:51:41 +01:00
MasutaYunikon 0c06941452 Merge pull request #20 from Masutayunikon/release-please--branches--main
chore(main): release 1.8.1
2026-03-13 02:34:56 +01:00
MasutaYunikon 8b8c5cc930 chore(main): release 1.8.1 2026-03-13 02:20:57 +01:00
MasutaYunikon 1dc01a101f Merge remote-tracking branch 'origin/main' 2026-03-13 02:20:36 +01:00
MasutaYunikon 054dcb915a fix: nfo settings and add json file when not existing 2026-03-13 02:20:26 +01:00
90 changed files with 11044 additions and 2940 deletions

No files matched your search

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

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+128
View File
@@ -0,0 +1,128 @@
name: Build Binaries
on:
release:
types: [created]
workflow_dispatch:
jobs:
build-frontend:
name: Build Frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Vue frontend
run: pnpm build
- name: Upload frontend dist
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: dist/client/
retention-days: 1
build-binaries:
name: Build ${{ matrix.target }}
needs: build-frontend
runs-on: ${{ matrix.os }}
permissions:
contents: write
strategy:
matrix:
include:
- target: windows-x64
os: windows-latest
bun-target: bun-windows-x64
binary: binaries/fankarr.exe
archive: fankarr-windows-x64.zip
- target: windows-x64-legacy
os: ubuntu-latest
bun-target: bun-windows-x64-baseline
binary: binaries/fankarr.exe
archive: fankarr-windows-x64-legacy.zip
- target: linux-x64
os: ubuntu-latest
bun-target: bun-linux-x64
binary: binaries/fankarr
archive: fankarr-linux-x64.zip
- target: linux-arm64
os: ubuntu-latest
bun-target: bun-linux-arm64
binary: binaries/fankarr
archive: fankarr-linux-arm64.zip
- target: macos
os: macos-latest
bun-target: bun-darwin-arm64
binary: binaries/fankarr
archive: fankarr-macos.zip
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: pnpm/action-setup@v3
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: public/
- name: Build binary (Unix)
if: matrix.target != 'windows-x64'
run: |
mkdir -p binaries
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
- name: Build binary (Windows)
if: matrix.target == 'windows-x64'
run: |
New-Item -ItemType Directory -Force -Path binaries
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
shell: pwsh
- name: Build worker
run: bun build server/organize-worker.ts --target node --outfile organize-worker.js
- name: Package archive (Windows)
if: matrix.target == 'windows-x64' || matrix.target == 'windows-x64-legacy'
run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public, version.txt -DestinationPath ${{ matrix.archive }}
shell: pwsh
- name: Package archive (Unix)
if: matrix.target != 'windows-x64' && matrix.target != 'windows-x64-legacy'
run: |
zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/ version.txt
- name: Upload archive to Release
uses: softprops/action-gh-release@v2
with:
files: ${{ matrix.archive }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+21 -1
View File
@@ -9,8 +9,28 @@ name: release-please
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.FINED_TOKEN }}
release-type: simple
release-type: simple
# Quand une release est créée, on écrit version.txt et on le committe
- uses: actions/checkout@v4
if: ${{ steps.release.outputs.release_created }}
with:
token: ${{ secrets.FINED_TOKEN }}
- name: Write version.txt
if: ${{ steps.release.outputs.release_created }}
run: |
echo "${{ steps.release.outputs.tag_name }}" > version.txt
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add version.txt
git commit -m "chore: update version.txt to ${{ steps.release.outputs.tag_name }}"
git push
+5
View File
@@ -1,3 +1,6 @@
# Claude Code
.claude/
# Logs
logs
*.log
@@ -7,10 +10,12 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
config
node_modules
dist
dist-ssr
*.local
binaries
# Editor directories and files
.vscode/*
View File
Whitespace-only changes.
+864
View File
@@ -1,5 +1,869 @@
# Changelog
## [3.14.3](https://github.com/Masutayunikon/FanKarr/compare/v3.14.2...v3.14.3) (2026-05-08)
### Bug Fixes
* build binaries node version ([6d8ea36](https://github.com/Masutayunikon/FanKarr/commit/6d8ea36260cb76e0592bbee3ce5450d2424b9209))
## [3.14.2](https://github.com/Masutayunikon/FanKarr/compare/v3.14.1...v3.14.2) (2026-05-07)
### Bug Fixes
* build binaries node version ([9ebaca9](https://github.com/Masutayunikon/FanKarr/commit/9ebaca92f18213fd6a79bf55f22cfb8358c4f7e0))
## [3.14.1](https://github.com/Masutayunikon/FanKarr/compare/v3.14.0...v3.14.1) (2026-05-07)
### Bug Fixes
* eslint build ([25ab373](https://github.com/Masutayunikon/FanKarr/commit/25ab373539c20942202e82cf678e5b7577ff0027))
## [3.14.0](https://github.com/Masutayunikon/FanKarr/compare/v3.13.2...v3.14.0) (2026-05-07)
### Features
* **qbittorrent:** support API key authentication (≥5.2.0) ([702847a](https://github.com/Masutayunikon/FanKarr/commit/702847a5c2eb5956f54a8475fefc80a08fca70f1))
* **qbittorrent:** support cookie name (≥5.2.0) ([aa8b500](https://github.com/Masutayunikon/FanKarr/commit/aa8b500f57efb97aae10eb0bd8e254388a8aff42))
### Bug Fixes
* ajout de log pour le cookie ([2b3fd79](https://github.com/Masutayunikon/FanKarr/commit/2b3fd79a92bbc7d7ed2142b7a23e03799efe0a28))
* cookie ([7da5a8d](https://github.com/Masutayunikon/FanKarr/commit/7da5a8d4e853f7e92770f20352478a76ee91642d))
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([105cc05](https://github.com/Masutayunikon/FanKarr/commit/105cc05bdf38127089869ea201165ac1d3479275))
* on ne vois plus le telechargement de fichier qui ont une priorité basse (ne pas télécharger) ([1f50f9a](https://github.com/Masutayunikon/FanKarr/commit/1f50f9ae72ec9958bfb92b2e6936c699e19272f3))
* renaming dans le managements des series ([1e92934](https://github.com/Masutayunikon/FanKarr/commit/1e92934f531c82b9da90c8597ec08cd162b5a9ee))
* renaming dans le managements des series ([bf3f303](https://github.com/Masutayunikon/FanKarr/commit/bf3f30356e086d310b7213b267356bc20976589c))
* rtorrent priority default to 1 ([bfabf9b](https://github.com/Masutayunikon/FanKarr/commit/bfabf9b7937f055579add331cd918a13f05084bf))
## [3.13.2](https://github.com/Masutayunikon/FanKarr/compare/v3.13.1...v3.13.2) (2026-05-06)
### Bug Fixes
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([0d5d44a](https://github.com/Masutayunikon/FanKarr/commit/0d5d44a4840df27d8b23bde9935d12c664d5ad6c))
## [3.13.1](https://github.com/Masutayunikon/FanKarr/compare/v3.13.0...v3.13.1) (2026-05-06)
### Bug Fixes
* ajout du champ wiki dans l'api ([95d2b27](https://github.com/Masutayunikon/FanKarr/commit/95d2b270dcb3052b65f5918ec770799b43316cd6))
## [3.13.0](https://github.com/Masutayunikon/FanKarr/compare/v3.12.1...v3.13.0) (2026-05-06)
### Features
* ajout de la mise a jour des id's ([39463bf](https://github.com/Masutayunikon/FanKarr/commit/39463bfbcb74c7241420504ba51bd91108df20e4))
* ajout de la prise en charges des codex ([83437de](https://github.com/Masutayunikon/FanKarr/commit/83437def32618c8a5b9ca4364db992762b9359f8))
* **organize:** vérification de complétion par fichier avant d'importer ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **serie:** badges épisodes et chapitres extraits du synopsis ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **serie:** lien wiki dans le header de la série si serie.wiki est défini ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **torrent-clients:** ajout du driver Real-Debrid ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
### Bug Fixes
* ajout de log ([d299b76](https://github.com/Masutayunikon/FanKarr/commit/d299b76bd3fc8a8411e71f9f05318901df8372ef))
* **organize:** retry EBUSY sur unlink — 5 tentatives × 500 ms (Windows) ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **organize:** suppression des entrées orphelines lors de la migration d'IDs ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* progression ([35655d7](https://github.com/Masutayunikon/FanKarr/commit/35655d7784025c9b2d7f992b24181d62b8488130))
* **qbittorrent:** FanKarr télécharge le .torrent et l'uploade directement à qBit ([434da54](https://github.com/Masutayunikon/FanKarr/commit/434da54ede8ece06c1fbcf4d66fe6ecdf87557a5))
* **qbittorrent:** pause uniquement pour les .torrent, pas pour les magnets ([9f90b6a](https://github.com/Masutayunikon/FanKarr/commit/9f90b6ab6c0aa97e33244d827370492abcfe9a13))
* **qbittorrent:** suppression de paused/stopped à l'ajout du torrent ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **serie:** header mobile — items-start + aspect-[2/3] sur le poster ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* utilisation de formated_name depuis les paths ([39463bf](https://github.com/Masutayunikon/FanKarr/commit/39463bfbcb74c7241420504ba51bd91108df20e4))
## [3.12.1](https://github.com/Masutayunikon/FanKarr/compare/v3.12.0...v3.12.1) (2026-04-29)
### Bug Fixes
* **UI/UX:** ajout d'un modal de confirmation lors de l'activation des nfo ([8b98b0e](https://github.com/Masutayunikon/FanKarr/commit/8b98b0edf926ba6b542501afdf524050d72f043b))
## [3.12.0](https://github.com/Masutayunikon/FanKarr/compare/v3.11.5...v3.12.0) (2026-04-29)
### Features
* désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission ([2446622](https://github.com/Masutayunikon/FanKarr/commit/24466228ef5305c36574f58fb2590aee8ee86e13))
* désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission ([fcb1b88](https://github.com/Masutayunikon/FanKarr/commit/fcb1b88870bebb1bf0b4e484b2462e56ab3975fc))
* désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission ([3fb4119](https://github.com/Masutayunikon/FanKarr/commit/3fb4119c79908c1023f3cbf9999b524eb1bc267e))
### Bug Fixes
* qbitorrent ([9834023](https://github.com/Masutayunikon/FanKarr/commit/98340235226394c4583159f36cce74e316eedf26))
* qbitorrent header ([f5094c6](https://github.com/Masutayunikon/FanKarr/commit/f5094c68c6834f816b0b6d019e5d5637400e16ab))
* spinner infinie apres supression ([39d2f99](https://github.com/Masutayunikon/FanKarr/commit/39d2f996aecb9f07dcd8c1ec7911090276908a2f))
* spinner infinie apres supression ([97c573c](https://github.com/Masutayunikon/FanKarr/commit/97c573cc1d4fb76787ba5d1df4fda30faec5fbf2))
* **UI/UX:** chevron pour ouvrir la description, bouton desimporter de la serie passer à supprimer et affichage de toute la description de la serie lors d'un clique ([f6ef818](https://github.com/Masutayunikon/FanKarr/commit/f6ef818e60875a1ac5d859480c1e0e53082c0858))
* **UI/UX:** meilleur comprehension des parametres avancés des clients ([040a8d7](https://github.com/Masutayunikon/FanKarr/commit/040a8d7b339090d819c6e3dc049f1ab9d8b221f5))
## [3.11.5](https://github.com/Masutayunikon/FanKarr/compare/v3.11.4...v3.11.5) (2026-04-26)
### Bug Fixes
* le folder picker affiche tous les disques depuis le bouton homes sous windows ([7149022](https://github.com/Masutayunikon/FanKarr/commit/7149022c8701f79d194c675da87c6cb2d5793a35))
## [3.11.4](https://github.com/Masutayunikon/FanKarr/compare/v3.11.3...v3.11.4) (2026-04-26)
### Bug Fixes
* ajout de la mise a jour des noms toutes les 1h ([0fe5c8c](https://github.com/Masutayunikon/FanKarr/commit/0fe5c8ccb7ecdc1643b5e69de3957c39dfd8085e))
## [3.11.3](https://github.com/Masutayunikon/FanKarr/compare/v3.11.2...v3.11.3) (2026-04-26)
### Bug Fixes
* dockerfile node version to 22 ([5983494](https://github.com/Masutayunikon/FanKarr/commit/598349407750c729579bd639680ebc6a8ebcc12f))
## [3.11.2](https://github.com/Masutayunikon/FanKarr/compare/v3.11.1...v3.11.2) (2026-04-26)
### Bug Fixes
* ajout de undici dans le package.json ([459e602](https://github.com/Masutayunikon/FanKarr/commit/459e602c1df1c610466be5015a660624b1189620))
## [3.11.1](https://github.com/Masutayunikon/FanKarr/compare/v3.11.0...v3.11.1) (2026-04-26)
### Bug Fixes
* le setup plex peux maintenant etre utilisé sur un serveur local (probleme de certificat) ([6907584](https://github.com/Masutayunikon/FanKarr/commit/69075842be396b149949b158922a9dc84dbc7ccf))
## [3.11.0](https://github.com/Masutayunikon/FanKarr/compare/v3.10.0...v3.11.0) (2026-04-23)
### Features
* ajout d'un bouton pour supprimer les nfo / png ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
* ajout de log pour le bouton tout renommer ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
* ajout du support multifile sur utorrent ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
### Bug Fixes
* renommage d'episodes qui ne fonctionne pas en 1 par 1 ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
## [3.10.0](https://github.com/Masutayunikon/FanKarr/compare/v3.9.2...v3.10.0) (2026-04-22)
### Features
* ajout d'une variable d'environment pour augmenter la durée du token ou le desactiver avec "never" ou 0 ([dc5387c](https://github.com/Masutayunikon/FanKarr/commit/dc5387c69a308bdf99f0b77f9769462ffda13db5))
* ajout des telechargments par episodes / saisons ([d6e3c99](https://github.com/Masutayunikon/FanKarr/commit/d6e3c9963c9db5fedc579377cbad485f25ab0537))
* ajout du .env dans les dossiers du binaires pour pouvoir changer leur valeurs ([dc5387c](https://github.com/Masutayunikon/FanKarr/commit/dc5387c69a308bdf99f0b77f9769462ffda13db5))
* ajout du langage ([94e7b66](https://github.com/Masutayunikon/FanKarr/commit/94e7b668e13c8406768a84e7a821e2fd7fdc96fc))
* update readme ([01e70ed](https://github.com/Masutayunikon/FanKarr/commit/01e70ed9c00f8c614a6a9947466e17a5128f52db))
* utilisation des bar de telechargement par fichier dans le torrent plutot que celui du torrent ([d03f0ac](https://github.com/Masutayunikon/FanKarr/commit/d03f0ac397acd117b75a92b6763441a667f4e6cb))
### Bug Fixes
* affichage de la bar de progression et confirmation sur le bouton pour les episodes en doublons ([23be797](https://github.com/Masutayunikon/FanKarr/commit/23be797f97b1e49723140cdd34d0d217a0e819a3))
* affichage de la bar de progression et confirmation sur le bouton pour les episodes en doublons et dropdown ([95d4b01](https://github.com/Masutayunikon/FanKarr/commit/95d4b015eeb0298f77cfe5828f7a14d15fb77dd5))
* ajout des boutons pour les episodes sans torrent ([94048e4](https://github.com/Masutayunikon/FanKarr/commit/94048e458248a43e50c566e01d985105ee850ec3))
* bouton global de serie ([93a3505](https://github.com/Masutayunikon/FanKarr/commit/93a35055467cfab2503f6b0dfefcce8cd3f53559))
* bouton tous telecharger a la place de integral, multi torrents sur le bouton saisons et tous telecharger ([02dd4c8](https://github.com/Masutayunikon/FanKarr/commit/02dd4c866ad4c396b324c5185e2b9458d8864b58))
* changer les badge de language pour un truc plus explicite ([94a1ed1](https://github.com/Masutayunikon/FanKarr/commit/94a1ed1fe3cb9e49f90c8fbc200aaac969895dff))
* coherence des badges ([5d84737](https://github.com/Masutayunikon/FanKarr/commit/5d84737a28c2d0c49e8acce6ccd7a7bf915e1309))
* dropdown overflow ([5baf707](https://github.com/Masutayunikon/FanKarr/commit/5baf7072207eca1f62a42c13602c2fdc85d6e54f))
* dropdown overflow ([f820ea0](https://github.com/Masutayunikon/FanKarr/commit/f820ea0cf252cbc11c527f1393e332b9acd274fe))
* fallback de noms si les noms de torrents se ressemble il fallback sur les noms dans nyaa ([59fb769](https://github.com/Masutayunikon/FanKarr/commit/59fb7693a4726fc31223373559404f63b6963340))
* improve container detection to support Podman and other runtimes ([7d6c86f](https://github.com/Masutayunikon/FanKarr/commit/7d6c86f888b4b7c5696a5de21bce12d1864035a2))
* le dropdown apparait sur le bouton de telechargement et pas les 3 petit points pour les multiples ([eaf733f](https://github.com/Masutayunikon/FanKarr/commit/eaf733f2e5caccf10a2f30a0e10db00831d6d635))
* les fichiers non selectionner sur un torrent ne produise plus d'erreurs d'import ([2a08d7d](https://github.com/Masutayunikon/FanKarr/commit/2a08d7dbf5a8a91323801e9fa3722dfb02a7cd5e))
* les fichiers non selectionner sur un torrent ne sont plus en "attente d'import" sur l'affichage de la serie ([7799f7d](https://github.com/Masutayunikon/FanKarr/commit/7799f7de86d0a207c960f72fa8e8db84de86d2a8))
* les torrents sont stopé lors de la selection de fichier pour eviter qu'il commence le telechargement des autres fichiers avant de set les indexs ([45d340d](https://github.com/Masutayunikon/FanKarr/commit/45d340d01ab8507423b187447d0f0e36a75d21d4))
* les torrents sont stopé lors de la selection de fichier pour eviter qu'il commence le telechargement des autres fichiers avant de set les indexs ([cdfb330](https://github.com/Masutayunikon/FanKarr/commit/cdfb330dd6ac20d2bfe02d196a8d94f0996c763c))
* n'affiche plus tous les épisodes en train de se telecharger si seulement un fichier est selectionner ([c4bfa2b](https://github.com/Masutayunikon/FanKarr/commit/c4bfa2ba87167bf20c066b3f682bd70aa9b18467))
* n'affiche plus tous les épisodes en train de se telecharger si seulement un fichier est selectionner ([1e4f735](https://github.com/Masutayunikon/FanKarr/commit/1e4f73522dd216d80c57839bd35c15dcc906bf90))
* ne retire pas les fichiers quand on en ajoute sur le torrent ([5c1ae3e](https://github.com/Masutayunikon/FanKarr/commit/5c1ae3e7beb59dcc2769bddb0fa63266e10bcbab))
* prise en charge de multiple torrent sur les telechargement d'episodes ([dc5387c](https://github.com/Masutayunikon/FanKarr/commit/dc5387c69a308bdf99f0b77f9769462ffda13db5))
* qbit utilisation de /start au lieu de /resume pour la v5 ([98a3d81](https://github.com/Masutayunikon/FanKarr/commit/98a3d81c4882ed4e8db1047b2b8c5edddbe037b2))
* utilisation de svg pour les drapeau ([c11287b](https://github.com/Masutayunikon/FanKarr/commit/c11287bd3fd88badc91d35b5ec825b09ff5237a1))
* utilisation des noms de dossier du torrent pour l'affichage plutot que le nom sur nyaa (notament pour gto kai qui a plusieurs integral et qui est plus explicite qque le nom nyaa) ([4069035](https://github.com/Masutayunikon/FanKarr/commit/4069035956472bf179a3a9bb8bd317d6cce8dc13))
* utilisation du fichier torrent avec le magnet pour les bug de metadonnées ([dc5387c](https://github.com/Masutayunikon/FanKarr/commit/dc5387c69a308bdf99f0b77f9769462ffda13db5))
## [3.9.2](https://github.com/Masutayunikon/FanKarr/compare/v3.9.1...v3.9.2) (2026-04-16)
### Bug Fixes
* le bouton "tous télécharger" ne s'affiche plus si la series de contient que des integrales comme torrent ([d21270a](https://github.com/Masutayunikon/FanKarr/commit/d21270ae802ee43242365331922393b5cbf689fa))
## [3.9.1](https://github.com/Masutayunikon/FanKarr/compare/v3.9.0...v3.9.1) (2026-04-16)
### Bug Fixes
* le badge de rename utilise la meme logique que coté serveur et n'apparait plus sur les vue series si il ne devrais pas ([9764d2d](https://github.com/Masutayunikon/FanKarr/commit/9764d2db8ed2531af3feb6ceba947eaf116c1e76))
## [3.9.0](https://github.com/Masutayunikon/FanKarr/compare/v3.8.1...v3.9.0) (2026-04-15)
### Features
* ajout d'un bouton supprimé l'importation dans le menu d'importation d'une serie ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
* ajout de badge quand le fichier est introuvable ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
* ajout de la possibilité de renommer des imports dans le bon format ([67444d4](https://github.com/Masutayunikon/FanKarr/commit/67444d418d65cf0d0323c8955adb8d9e136a8797))
* ajout du bouton suppresion de torrent en cours ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
* ajout setup plex ([2123e73](https://github.com/Masutayunikon/FanKarr/commit/2123e73b7fec9c637b349e63da9c9a88dd9fccd6))
* bouton tous télécharger sur les saisons et series ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
* suppression des series supprimé ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
### Bug Fixes
* ajout d'un loader sur certaines pages pour pas avoir des trucs par default qui s'affiche ([d241be3](https://github.com/Masutayunikon/FanKarr/commit/d241be30d126f5b975aca72d2c21ab74e7215fa4))
* ajout de la page management dans le layout ([5dc0531](https://github.com/Masutayunikon/FanKarr/commit/5dc0531bfd50ac9ca5a59126670757179ed8a226))
* cast string ([16d1bfe](https://github.com/Masutayunikon/FanKarr/commit/16d1bfe7e7766987876db80f84e6c0dc31217403))
* Deux corrections : ([5398a4f](https://github.com/Masutayunikon/FanKarr/commit/5398a4fe12b1ac235884bb9c6218174b0c438ac1))
* le bouton tout telecharger apparait sur les packs de saisons et les integrals + autres et pas que sur les series avec des episodes telechargable ([e105587](https://github.com/Masutayunikon/FanKarr/commit/e105587147e03e39e2c3b985a114434c2512417a))
* move plex settings en haut des boutons ([96af23d](https://github.com/Masutayunikon/FanKarr/commit/96af23d258e14dacf371b043b92b554c2b22f80c))
* plex catcher ([ba0dc8d](https://github.com/Masutayunikon/FanKarr/commit/ba0dc8d21bca098b0203f9553d81a47048fad452))
* suppression de dest_filename du fichier d'organisation qui est faux et ne sert à rien ([3c69eba](https://github.com/Masutayunikon/FanKarr/commit/3c69eba440f4f83180bd5d3f25a2ad0499b91940))
* utorrent prend desormais le dossier cible correctement ([cc68fef](https://github.com/Masutayunikon/FanKarr/commit/cc68fef88d6e13339867f6b6b47667a141a86091))
* wrong file copy ([fa7da76](https://github.com/Masutayunikon/FanKarr/commit/fa7da7681193e89214a521c5bd16106f0ca03f46))
## [3.8.1](https://github.com/Masutayunikon/FanKarr/compare/v3.8.0...v3.8.1) (2026-04-11)
### Bug Fixes
* la version dev se met uniquement en dev ([79e5478](https://github.com/Masutayunikon/FanKarr/commit/79e547859720f36330d95036de813240da034bbd))
## [3.8.0](https://github.com/Masutayunikon/FanKarr/compare/v3.7.1...v3.8.0) (2026-04-11)
### Features
* force release for test ([d921fc1](https://github.com/Masutayunikon/FanKarr/commit/d921fc16d6b19d8aeb354fc3a5fc533afc81cb5b))
## [3.7.1](https://github.com/Masutayunikon/FanKarr/compare/v3.7.0...v3.7.1) (2026-04-11)
### Bug Fixes
* ajout de la copy dans le worker ([a7d4488](https://github.com/Masutayunikon/FanKarr/commit/a7d44886ddb4c5ca4e12bfa5dbb15e115257d9c8))
## [3.7.0](https://github.com/Masutayunikon/FanKarr/compare/v3.6.1...v3.7.0) (2026-04-11)
### Features
* activation de l'import manuel ([9712644](https://github.com/Masutayunikon/FanKarr/commit/971264446c45401f5aaa3b3acae0fab6cae15264))
* ajout d'une "notification" dans la navbar si une nouvelle versions est disponible ([9712644](https://github.com/Masutayunikon/FanKarr/commit/971264446c45401f5aaa3b3acae0fab6cae15264))
* ajout d'une hashmap pour matcher les titres sur synologie et ajouter les hash de torrent manuellement ([3b8c448](https://github.com/Masutayunikon/FanKarr/commit/3b8c448ea0b0917e40d493ad4bf0e81cd35dff5a))
* ajout de l'option supprimé le torrent si l'option déplacer est activé ([3e83dc0](https://github.com/Masutayunikon/FanKarr/commit/3e83dc0d79107e46cac14f73dd3ef04794ad824d))
* ajout de la suppression des torrents depuis l'interface ([3e83dc0](https://github.com/Masutayunikon/FanKarr/commit/3e83dc0d79107e46cac14f73dd3ef04794ad824d))
* ajout du bouton copie ([3e83dc0](https://github.com/Masutayunikon/FanKarr/commit/3e83dc0d79107e46cac14f73dd3ef04794ad824d))
* ajout du ratio, upload speed, etc dans les activités ([ee34a53](https://github.com/Masutayunikon/FanKarr/commit/ee34a5350086dcd7e783f9c14ca60e09c60eb032))
* edit d'episodes importer dans le menu d'importation ([3017cac](https://github.com/Masutayunikon/FanKarr/commit/3017cac67a5d3f5ccb9f153f233a446b0afaaec8))
### Bug Fixes
* ajout de l'option de suppresion dans l'interface settings.ts ([a70e65f](https://github.com/Masutayunikon/FanKarr/commit/a70e65f0e4323369d1fd7401665fb3033352a286))
* ajout de logs lors de non match ([0c6c802](https://github.com/Masutayunikon/FanKarr/commit/0c6c802cbfdf297df40aa5944eacf16f58489bd1))
* ajout des series non disponible pour pouvoir les add manuellement ([9e0664a](https://github.com/Masutayunikon/FanKarr/commit/9e0664aa49d6f99be8ca7e7bbd7c8e7cec0f9e7c))
* changer le noms de build arm64 et windows legacy pour une meilleur comprehension ([47d81e6](https://github.com/Masutayunikon/FanKarr/commit/47d81e692d866beedf80a6c46317dfe79213547d))
* formatage ([82a61eb](https://github.com/Masutayunikon/FanKarr/commit/82a61ebe74b8196891a50c1380ffaa89178c1fd1))
* formatage ([f6b384e](https://github.com/Masutayunikon/FanKarr/commit/f6b384ec49bf018e23eb0a0b15917aee4578d66e))
* l'import a le meme comportement que radarr/sonarr en ouvrant l'import dans le dossier de la serie attendu directement ([1485efe](https://github.com/Masutayunikon/FanKarr/commit/1485efe053d3281521978bb2d29a6c9ce219c2fd))
* les series sans torrent et non ajoutées manuellement sont bien grisé ([2bdafd8](https://github.com/Masutayunikon/FanKarr/commit/2bdafd8e377d1a957903f228c61c1f3ab3a45955))
* les series sans torrent et non ajoutées manuellement sont bien grisé ([d854247](https://github.com/Masutayunikon/FanKarr/commit/d854247b7fe6d5e01b0803ff7d415aba431c4f0d))
* les series sans torrents sont bien organiser si importé manuellement ([6ed77c1](https://github.com/Masutayunikon/FanKarr/commit/6ed77c1196c45e57dbd989cc049f42403f522821))
* les series sans torrents sont bien organiser si importé manuellement ([bc10889](https://github.com/Masutayunikon/FanKarr/commit/bc1088903053b7b98cabbe24c9cdcb8ac4c489b2))
* log ([ef4b364](https://github.com/Masutayunikon/FanKarr/commit/ef4b3643c2852500f048ee330b92d60fed4b5ba5))
* organisation utilise l'extension du fichier au lieu de tester si ça existe ([2b7bf48](https://github.com/Masutayunikon/FanKarr/commit/2b7bf4808e4a11d450403f477ea3031add8a2ebc))
* settings ([60a548f](https://github.com/Masutayunikon/FanKarr/commit/60a548f6bd658fe9986d94c094cad0fbed103882))
* type ([f4305b5](https://github.com/Masutayunikon/FanKarr/commit/f4305b537f2ab0a1370445eae9e9150a34e44e86))
* type ([bdfe74b](https://github.com/Masutayunikon/FanKarr/commit/bdfe74bb8132b6bff0b14ccb135bb3b90f3441b6))
* vue des series importer ([6786cb5](https://github.com/Masutayunikon/FanKarr/commit/6786cb50f4c50b95f1dcd48d15742b1ae6602ed5))
* vue des series importer ([50efd74](https://github.com/Masutayunikon/FanKarr/commit/50efd74a5b918c4ff61e7d4feac2643693db1861))
## [3.6.1](https://github.com/Masutayunikon/FanKarr/compare/v3.6.0...v3.6.1) (2026-04-10)
### Bug Fixes
* windows baseline ([0469031](https://github.com/Masutayunikon/FanKarr/commit/0469031ff05c8794b20f73bc7c3ac417ab473e5d))
## [3.6.0](https://github.com/Masutayunikon/FanKarr/compare/v3.5.0...v3.6.0) (2026-04-10)
### Features
* ajout d'un exe pour les cpu non avx2 ([51aacaf](https://github.com/Masutayunikon/FanKarr/commit/51aacafbcb0de1ca6a8b496c0905aa4e831d6008))
## [3.5.0](https://github.com/Masutayunikon/FanKarr/compare/v3.4.0...v3.5.0) (2026-04-09)
### Features
* add uttorent driver ([712db87](https://github.com/Masutayunikon/FanKarr/commit/712db87a77be4dec157c3addb5bc12b1ac2bc124))
* ajout d'information dans le fichier d'organisation pour l'organisation manuel plus tard ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* ajout d'un bouton refresh ([48f86ea](https://github.com/Masutayunikon/FanKarr/commit/48f86ea9cc36ded75f02fa3879a7d3bd2640fb70))
* ajout de synology downloader comme client ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* Ajout des mapping sur les clients ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* ajout des parametres dossier de téléchargements pour les clients torrent ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* ajout du driver rtorrent ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* auto update des fichiers nfo ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* manual import ([b65e3e7](https://github.com/Masutayunikon/FanKarr/commit/b65e3e7cd8563466725e7eaf2f8aad7afd927296))
### Bug Fixes
* add log for candidate path ([9c6465b](https://github.com/Masutayunikon/FanKarr/commit/9c6465bc45f9e08e67c53f4e0c83b6a625ea25f1))
* ajout d'une verif lors de l'écriture de la config sur les items dans body ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* augmentation du header size 8kb -&gt; 32kb ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* empeche un login failed de se repeter et de se faire bloquer par les clients ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* import manuelle desactivé pour le moment ([fc7fd3a](https://github.com/Masutayunikon/FanKarr/commit/fc7fd3a0ecfba3a82f851fdcc7a278fdeb43febe))
* l'organiseur prend les fichiers renommer maintenant ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* ne sauvegarde plus le mots de passe "fake" quand on edit un client ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* organiser take correct name ([712db87](https://github.com/Masutayunikon/FanKarr/commit/712db87a77be4dec157c3addb5bc12b1ac2bc124))
* polling des torrents à 60s ([48f86ea](https://github.com/Masutayunikon/FanKarr/commit/48f86ea9cc36ded75f02fa3879a7d3bd2640fb70))
* remove log for mapping ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* remove log for mapping ([4d963e5](https://github.com/Masutayunikon/FanKarr/commit/4d963e5bf3ab00e798089e93cdc846fbd2c31de6))
* unlink before move ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* unlink before move ([4d963e5](https://github.com/Masutayunikon/FanKarr/commit/4d963e5bf3ab00e798089e93cdc846fbd2c31de6))
* utorrent path for import ([eba2552](https://github.com/Masutayunikon/FanKarr/commit/eba2552e1dba67eec615c2a17e0ca1b896fc1a3b))
## [3.4.0](https://github.com/Masutayunikon/FanKarr/compare/v3.3.3...v3.4.0) (2026-04-08)
### Features
* ajout d'un retour visuel si on a un path par defaut sur / ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
* Ajout du support pour l'édition/mise à jour des clients torrent ([dda8a03](https://github.com/Masutayunikon/FanKarr/commit/dda8a03403b46027a23b4a2812ca3797a2628673))
* Ajout du support pour l'édition/mise à jour des clients torrent ([a3e1c34](https://github.com/Masutayunikon/FanKarr/commit/a3e1c34e7547bab75c45ddd3eace0d211409ccd5))
### Bug Fixes
* le driver de Transmission n'est plus basé sur android ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
* les torrent inconnue en états seront mis dans la catégorie terminée ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
* typo ([18ec548](https://github.com/Masutayunikon/FanKarr/commit/18ec548d76c929afac11cd16d88e734480ee2755))
* utilisation de nfo_filename au lieu de original_filename ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
## [3.3.3](https://github.com/Masutayunikon/FanKarr/compare/v3.3.2...v3.3.3) (2026-04-07)
### Bug Fixes
* MediaManagementView.vue api call is now in mounted ([8c5b650](https://github.com/Masutayunikon/FanKarr/commit/8c5b650ca6eeac75f33e8c5423f6d4fd24f9871b))
* path ([58ae88a](https://github.com/Masutayunikon/FanKarr/commit/58ae88a01c063318407134cd7351572220acd892))
## [3.3.2](https://github.com/Masutayunikon/FanKarr/compare/v3.3.1...v3.3.2) (2026-04-07)
### Bug Fixes
* use fetch instead of useFetch ([af550ec](https://github.com/Masutayunikon/FanKarr/commit/af550eccfbeeb116491e8ea4973eac493ed962d3))
## [3.3.1](https://github.com/Masutayunikon/FanKarr/compare/v3.3.0...v3.3.1) (2026-04-07)
### Bug Fixes
* add driver register for transmission ([7d2096e](https://github.com/Masutayunikon/FanKarr/commit/7d2096ec1972079a2e0e5bdfe01c4cc702e6b185))
## [3.3.0](https://github.com/Masutayunikon/FanKarr/compare/v3.2.1...v3.3.0) (2026-04-07)
### Features
* add transmission client ([a2b88de](https://github.com/Masutayunikon/FanKarr/commit/a2b88de3a123aeb9c140e83605901dd5cdad041a))
* set defaultPath for any system ([a2b88de](https://github.com/Masutayunikon/FanKarr/commit/a2b88de3a123aeb9c140e83605901dd5cdad041a))
## [3.2.1](https://github.com/Masutayunikon/FanKarr/compare/v3.2.0...v3.2.1) (2026-04-07)
### Bug Fixes
* bun target android ([a167a87](https://github.com/Masutayunikon/FanKarr/commit/a167a870188bdcd6b26644e304c2059ad5f05e96))
## [3.2.0](https://github.com/Masutayunikon/FanKarr/compare/v3.1.1...v3.2.0) (2026-04-07)
### Features
* add build for arm64 ([96acc13](https://github.com/Masutayunikon/FanKarr/commit/96acc134698ca09842d57d906e4a28ab8e94ba56))
* add developer settings on settings and dashboard debug ([96acc13](https://github.com/Masutayunikon/FanKarr/commit/96acc134698ca09842d57d906e4a28ab8e94ba56))
### Bug Fixes
* android build name ([0cfe6eb](https://github.com/Masutayunikon/FanKarr/commit/0cfe6eb81055b841a67c08e02129709c0214d87d))
## [3.1.1](https://github.com/Masutayunikon/FanKarr/compare/v3.1.0...v3.1.1) (2026-03-28)
### Bug Fixes
* remove plex settings ([8744ade](https://github.com/Masutayunikon/FanKarr/commit/8744adeff5f2db1ab648412d50495690d9e68d0b))
* torrent for multiple seasons is now working ([2514008](https://github.com/Masutayunikon/FanKarr/commit/25140086c13ce5a497500659bdd6cea2aeff96ed))
## [3.1.0](https://github.com/Masutayunikon/FanKarr/compare/v3.0.0...v3.1.0) (2026-03-27)
### Features
* use original_name and formated_name from scraper to bypass timeout ([1b693fc](https://github.com/Masutayunikon/FanKarr/commit/1b693fc53b9ca3a830c952c3f2c101bf1da7d27e))
## [3.0.0](https://github.com/Masutayunikon/FanKarr/compare/v2.4.1...v3.0.0) (2026-03-27)
### ⚠ BREAKING CHANGES
* nouvelle architecture frontend avec layout sidebar, système de thèmes et routes restructurées
### Features
* hors-fankai badge and fix series datetime and typo ([9d4500a](https://github.com/Masutayunikon/FanKarr/commit/9d4500a3834455b5dd72663c8eeb2d1c54b240e2))
* refonte complète du frontend et amélioration des logs serveur ([ed38a2d](https://github.com/Masutayunikon/FanKarr/commit/ed38a2d07d977bd3d3bb41da6f2aad8b70e7f359))
## [2.4.1](https://github.com/Masutayunikon/FanKarr/compare/v2.4.0...v2.4.1) (2026-03-24)
### Bug Fixes
* add usePlexTitles to return function ([381e327](https://github.com/Masutayunikon/FanKarr/commit/381e3274344c59cc80ba6230d30c96e5c5637ea5))
## [2.4.0](https://github.com/Masutayunikon/FanKarr/compare/v2.3.3...v2.4.0) (2026-03-24)
### Features
* **settings:** option titres optimisés Plex (title_for_plex) ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
### Bug Fixes
* **catalogue:** activeTorrents filtre désormais uniquement state=downloading ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
* **organize:** retry sur les erreurs réseau dans enrichSeriesDataWithOriginalFilenames ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
* **organize:** sanitisation des caractères spéciaux dans les noms de dossiers ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
## [2.3.3](https://github.com/Masutayunikon/FanKarr/compare/v2.3.2...v2.3.3) (2026-03-24)
### Bug Fixes
* recuperation des episodes avec un delai ([dfc6c13](https://github.com/Masutayunikon/FanKarr/commit/dfc6c1300e3dd8323e422ce3e71465bac77f94cf))
## [2.3.2](https://github.com/Masutayunikon/FanKarr/compare/v2.3.1...v2.3.2) (2026-03-24)
### Bug Fixes
* badge catalogue sur les series importé ([f070f17](https://github.com/Masutayunikon/FanKarr/commit/f070f1754ef616874cfb081eb2fefadad602c221))
## [2.3.1](https://github.com/Masutayunikon/FanKarr/compare/v2.3.0...v2.3.1) (2026-03-24)
### Bug Fixes
* add readsettings import ([ceb8e64](https://github.com/Masutayunikon/FanKarr/commit/ceb8e64a55937e20e27c1fb64865aba59516d518))
## [2.3.0](https://github.com/Masutayunikon/FanKarr/compare/v2.2.0...v2.3.0) (2026-03-24)
### Features
* **catalogue:** badge IMPORTÉ et refresh automatique post-import ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
* **catalogue:** tri du catalogue avec 4 options ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
* **downloads:** bouton Organiser tout renommé Importer tout ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
* **organize:** nommage des fichiers selon original_filename ou formatted_name ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
* **settings:** ajout du toggle import automatique (activé par défaut) ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
* **settings:** textes génériques sans mention de logiciels spécifiques ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
### Bug Fixes
* add config directory to .gitignore ([2d8749a](https://github.com/Masutayunikon/FanKarr/commit/2d8749ae7bc2986871fb402c2a4242bcb87d3a77))
## [2.2.0](https://github.com/Masutayunikon/FanKarr/compare/v2.1.3...v2.2.0) (2026-03-23)
### Features
* add organize all button ([bbaccfd](https://github.com/Masutayunikon/FanKarr/commit/bbaccfd97b51724932f2ebf22cad81be1cde61c0))
### Bug Fixes
* organizer reuse the wrong name for gitlab path ([bbaccfd](https://github.com/Masutayunikon/FanKarr/commit/bbaccfd97b51724932f2ebf22cad81be1cde61c0))
## [2.1.3](https://github.com/Masutayunikon/FanKarr/compare/v2.1.2...v2.1.3) (2026-03-23)
### Bug Fixes
* use magnet before torrent ([ed4665a](https://github.com/Masutayunikon/FanKarr/commit/ed4665a378d15c0b45a2be32d612e41082a43df1))
## [2.1.2](https://github.com/Masutayunikon/FanKarr/compare/v2.1.1...v2.1.2) (2026-03-23)
### Bug Fixes
* path now work on every device ([cfc3440](https://github.com/Masutayunikon/FanKarr/commit/cfc3440a95cb73802778e01d5f256f473e0cc952))
## [2.1.1](https://github.com/Masutayunikon/FanKarr/compare/v2.1.0...v2.1.1) (2026-03-23)
### Bug Fixes
* torrent path to hash ([d9e453b](https://github.com/Masutayunikon/FanKarr/commit/d9e453b486b9fd85b69ee134bee2bd6a1a6d3702))
## [2.1.0](https://github.com/Masutayunikon/FanKarr/compare/v2.0.0...v2.1.0) (2026-03-23)
### ⚠ BREAKING CHANGES
* **organize:** le format paths[] dans series/{id}.json passe de string[] à { infohash: string, path: string }[] — nécessite une mise à jour du scraper.
### Bug Fixes
* **organize:** buildFileMap matche par infohash au lieu d'index ou titre ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
* **organize:** migration vers paths { infohash, path } pour le matching fichiers ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
* **organize:** scanMediaPath adapté au nouveau format paths[] ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
* **organize:** source fichier via save_path qBittorrent + fullPath relatif ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
* remove logo ([48416c4](https://github.com/Masutayunikon/FanKarr/commit/48416c4fef3a964b05aa135ffd9e80d5dec9a81a))
## [2.0.0](https://github.com/Masutayunikon/FanKarr/compare/v1.14.0...v2.0.0) (2026-03-23)
### ⚠ BREAKING CHANGES
* suppression de torrent_final.json au profit d'un système de fichiers par série (series/{id}.json) avec cache mémoire GitHub 6h. Version majeure — nécessite un nouveau scraper et une mise à jour complète.
### Features
* **api:** adaptation complète à la nouvelle structure API Fankai ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **api:** cache mémoire GitHub TTL 6h avec invalidation via /api/update ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **api:** matching torrents par hiérarchie série/saison/épisode ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* migration vers nouvelle architecture API et scraper par série ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **organize:** refonte complète sans torrent_final.json ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **scraper:** extraction nom bencode dans raw pour torrents locaux ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **series-view:** badge "Hors Fankai" sur épisodes de torrents manuels ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **series-view:** tooltip raw name sur boutons intégrale via :title ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
### Bug Fixes
* **catalogue:** état download basé sur épisodes organisés vs torrents bruts ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **series:** boutons saison Yu-Gi-Oh via fallback season_number ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
## [1.14.0](https://github.com/Masutayunikon/FanKarr/compare/v1.13.0...v1.14.0) (2026-03-21)
### Features
* **scraper:** extraction du nom bencode pour les torrents locaux ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
* **series-view:** badge "Hors Fankai" sur les épisodes de torrents manuels ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
* **series-view:** tooltip avec le nom du torrent sur les boutons intégrale ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
### Bug Fixes
* **catalogue:** état "partiel" incorrect quand l'intégrale est organisée ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
* **series:** résolution boutons de téléchargement saisons Yu-Gi-Oh et séries similaires ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
## [1.13.0](https://github.com/Masutayunikon/FanKarr/compare/v1.12.2...v1.13.0) (2026-03-21)
### Features
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
* fix worker for multiple series in integral (one piece fix ?) ([2c773f8](https://github.com/Masutayunikon/FanKarr/commit/2c773f83ffd76d82c77a50a9c5729f20ea22b450))
* initial project ([eaf01cf](https://github.com/Masutayunikon/FanKarr/commit/eaf01cfc591dd72f9b093b533ced6c12a6c490a1))
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
* now rename episodes with original_filename from api ([9bdb21a](https://github.com/Masutayunikon/FanKarr/commit/9bdb21a673ebb9871548aea6691bafbd049554ce))
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
### Bug Fixes
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
* add target node ([58b09f2](https://github.com/Masutayunikon/FanKarr/commit/58b09f2267345c60ed589c160f7e47698e9be9a4))
* add target node ([6c629e0](https://github.com/Masutayunikon/FanKarr/commit/6c629e0f6b4d38453cfb22fd315e02d9e8750c45))
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
* config path /config/config to /config ([7518191](https://github.com/Masutayunikon/FanKarr/commit/75181910244621a8eca989067fd4777922914d8e))
* docker errors ([2702764](https://github.com/Masutayunikon/FanKarr/commit/27027642eb55b75adb04446a88e5007e88f75948))
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
* improve torrent organization and cleanup logic ([1908cb3](https://github.com/Masutayunikon/FanKarr/commit/1908cb3e8c5b99e91e26735f7cfa3dcd23709df7))
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
* path ([7ddd9ca](https://github.com/Masutayunikon/FanKarr/commit/7ddd9cad9271d79a258f45245bf405f13b9d764f))
* path ([0329ee2](https://github.com/Masutayunikon/FanKarr/commit/0329ee27a34038456eb297f07b808de3157d88f4))
* path ([314f817](https://github.com/Masutayunikon/FanKarr/commit/314f817280f39d10eb21c4784f2aea41b9a6d32c))
* port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve ([3a278ba](https://github.com/Masutayunikon/FanKarr/commit/3a278ba33388d6ff6f33413d7696d58d0172c953))
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
* season 0 not clonficting anymore ([635082d](https://github.com/Masutayunikon/FanKarr/commit/635082de00bb42bdeaf20b1654f742ad1c4addbe))
* season 0 resolved is no longer skipped in the worker ([c48ec76](https://github.com/Masutayunikon/FanKarr/commit/c48ec769ba7725327e657366e882498846d0c63f))
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
* use season number from resolved episodes in priority ([dc48634](https://github.com/Masutayunikon/FanKarr/commit/dc486347193a8066a32d8f0106518abc2b04b7e2))
* worker now work on multiple github files pages ([71784cd](https://github.com/Masutayunikon/FanKarr/commit/71784cd8bd8f3ea5dc7423687b080c1588d70854))
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
* worker path ([3df8def](https://github.com/Masutayunikon/FanKarr/commit/3df8defa5e46b09722a345c7c3a94e91aa16e4d8))
* worker path ([360572b](https://github.com/Masutayunikon/FanKarr/commit/360572b8be64dafbb452dfcb849a40305bf1b8cf))
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
## [1.12.2](https://github.com/Masutayunikon/FanKarr/compare/v1.12.1...v1.12.2) (2026-03-21)
### Bug Fixes
* use season number from resolved episodes in priority ([dc48634](https://github.com/Masutayunikon/FanKarr/commit/dc486347193a8066a32d8f0106518abc2b04b7e2))
## [1.12.1](https://github.com/Masutayunikon/FanKarr/compare/v1.12.0...v1.12.1) (2026-03-21)
### Bug Fixes
* path ([7ddd9ca](https://github.com/Masutayunikon/FanKarr/commit/7ddd9cad9271d79a258f45245bf405f13b9d764f))
## [1.12.0](https://github.com/Masutayunikon/FanKarr/compare/v1.11.10...v1.12.0) (2026-03-21)
### Features
* now rename episodes with original_filename from api ([9bdb21a](https://github.com/Masutayunikon/FanKarr/commit/9bdb21a673ebb9871548aea6691bafbd049554ce))
## [1.11.10](https://github.com/Masutayunikon/FanKarr/compare/v1.11.9...v1.11.10) (2026-03-21)
### Bug Fixes
* improve torrent organization and cleanup logic ([1908cb3](https://github.com/Masutayunikon/FanKarr/commit/1908cb3e8c5b99e91e26735f7cfa3dcd23709df7))
* season 0 not clonficting anymore ([635082d](https://github.com/Masutayunikon/FanKarr/commit/635082de00bb42bdeaf20b1654f742ad1c4addbe))
## [1.11.9](https://github.com/Masutayunikon/FanKarr/compare/v1.11.8...v1.11.9) (2026-03-20)
### Bug Fixes
* season 0 resolved is no longer skipped in the worker ([c48ec76](https://github.com/Masutayunikon/FanKarr/commit/c48ec769ba7725327e657366e882498846d0c63f))
## [1.11.8](https://github.com/Masutayunikon/FanKarr/compare/v1.11.7...v1.11.8) (2026-03-20)
### Bug Fixes
* config path /config/config to /config ([7518191](https://github.com/Masutayunikon/FanKarr/commit/75181910244621a8eca989067fd4777922914d8e))
## [1.11.7](https://github.com/Masutayunikon/FanKarr/compare/v1.11.6...v1.11.7) (2026-03-20)
### Bug Fixes
* worker path ([3df8def](https://github.com/Masutayunikon/FanKarr/commit/3df8defa5e46b09722a345c7c3a94e91aa16e4d8))
## [1.11.6](https://github.com/Masutayunikon/FanKarr/compare/v1.11.5...v1.11.6) (2026-03-20)
### Bug Fixes
* path ([0329ee2](https://github.com/Masutayunikon/FanKarr/commit/0329ee27a34038456eb297f07b808de3157d88f4))
## [1.11.5](https://github.com/Masutayunikon/FanKarr/compare/v1.11.4...v1.11.5) (2026-03-20)
### Bug Fixes
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
## [1.11.4](https://github.com/Masutayunikon/FanKarr/compare/v1.11.3...v1.11.4) (2026-03-20)
### Bug Fixes
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
## [1.11.3](https://github.com/Masutayunikon/FanKarr/compare/v1.11.2...v1.11.3) (2026-03-20)
### Bug Fixes
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
## [1.11.2](https://github.com/Masutayunikon/FanKarr/compare/v1.11.1...v1.11.2) (2026-03-20)
### Bug Fixes
* port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve ([3a278ba](https://github.com/Masutayunikon/FanKarr/commit/3a278ba33388d6ff6f33413d7696d58d0172c953))
## [1.11.1](https://github.com/Masutayunikon/FanKarr/compare/v1.11.0...v1.11.1) (2026-03-13)
### Bug Fixes
* worker now work on multiple github files pages ([71784cd](https://github.com/Masutayunikon/FanKarr/commit/71784cd8bd8f3ea5dc7423687b080c1588d70854))
## [1.11.0](https://github.com/Masutayunikon/FanKarr/compare/v1.10.3...v1.11.0) (2026-03-13)
### Features
* fix worker for multiple series in integral (one piece fix ?) ([2c773f8](https://github.com/Masutayunikon/FanKarr/commit/2c773f83ffd76d82c77a50a9c5729f20ea22b450))
## [1.10.3](https://github.com/Masutayunikon/FanKarr/compare/v1.10.2...v1.10.3) (2026-03-13)
### Bug Fixes
* worker path ([360572b](https://github.com/Masutayunikon/FanKarr/commit/360572b8be64dafbb452dfcb849a40305bf1b8cf))
## [1.10.2](https://github.com/Masutayunikon/FanKarr/compare/v1.10.1...v1.10.2) (2026-03-13)
### Bug Fixes
* add target node ([58b09f2](https://github.com/Masutayunikon/FanKarr/commit/58b09f2267345c60ed589c160f7e47698e9be9a4))
## [1.10.1](https://github.com/Masutayunikon/FanKarr/compare/v1.10.0...v1.10.1) (2026-03-13)
### Bug Fixes
* add target node ([6c629e0](https://github.com/Masutayunikon/FanKarr/commit/6c629e0f6b4d38453cfb22fd315e02d9e8750c45))
## [1.10.0](https://github.com/Masutayunikon/FanKarr/compare/v1.9.5...v1.10.0) (2026-03-13)
### Features
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
* initial project ([eaf01cf](https://github.com/Masutayunikon/FanKarr/commit/eaf01cfc591dd72f9b093b533ced6c12a6c490a1))
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
### Bug Fixes
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
* docker errors ([2702764](https://github.com/Masutayunikon/FanKarr/commit/27027642eb55b75adb04446a88e5007e88f75948))
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
* path ([314f817](https://github.com/Masutayunikon/FanKarr/commit/314f817280f39d10eb21c4784f2aea41b9a6d32c))
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
## [1.9.5](https://github.com/Masutayunikon/FanKarr/compare/v1.9.4...v1.9.5) (2026-03-13)
### Bug Fixes
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
## [1.9.4](https://github.com/Masutayunikon/FanKarr/compare/v1.9.3...v1.9.4) (2026-03-13)
### Bug Fixes
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
## [1.9.3](https://github.com/Masutayunikon/FanKarr/compare/v1.9.2...v1.9.3) (2026-03-13)
### Bug Fixes
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
## [1.9.2](https://github.com/Masutayunikon/FanKarr/compare/v1.9.1...v1.9.2) (2026-03-13)
### Bug Fixes
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
## [1.9.1](https://github.com/Masutayunikon/FanKarr/compare/v1.9.0...v1.9.1) (2026-03-13)
### Bug Fixes
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
## [1.9.0](https://github.com/Masutayunikon/FanKarr/compare/v1.8.2...v1.9.0) (2026-03-13)
### Features
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
## [1.8.2](https://github.com/Masutayunikon/FanKarr/compare/v1.8.1...v1.8.2) (2026-03-13)
### Bug Fixes
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
## [1.8.1](https://github.com/Masutayunikon/FanKarr/compare/v1.8.0...v1.8.1) (2026-03-13)
### Bug Fixes
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
## [1.8.0](https://github.com/Masutayunikon/FanKarr/compare/v1.7.3...v1.8.0) (2026-03-12)
+4 -4
View File
@@ -1,4 +1,4 @@
FROM node:20-slim AS base
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -15,7 +15,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
# ── Production ─────────────────────────────────────────────────
FROM node:20-slim
FROM node:22-slim
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -24,8 +24,8 @@ COPY --from=build /app/dist/server /app/dist/server
COPY --from=build /app/dist/client /app/public
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN mkdir -p /app/data
RUN mkdir -p /config
EXPOSE 3001
ENV NODE_ENV=production
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/entrypoint.sh"]
+151 -75
View File
@@ -1,59 +1,72 @@
<div align="center">
# Fankarr
# FanKarr
![Version](https://img.shields.io/github/v/release/masutayunikon/fankarr?style=flat-square&color=e8513a&label=version)
![Docker Image Size](https://img.shields.io/docker/image-size/masutayunikon/fankarr/latest?style=flat-square&color=1e2d3d&label=image)
![Docker Pulls](https://img.shields.io/docker/pulls/masutayunikon/fankarr?style=flat-square&color=1e2d3d)
![GitHub Actions](https://img.shields.io/github/actions/workflow/status/masutayunikon/fankarr/containerize.yml?style=flat-square&label=build)
![License](https://img.shields.io/github/license/masutayunikon/fankarr?style=flat-square&color=5a7a94)
![Node](https://img.shields.io/badge/node-20-5a7a94?style=flat-square)
![Vue](https://img.shields.io/badge/vue-3-42b883?style=flat-square)
![Bun](https://img.shields.io/badge/bun-ready-f9f1e1?style=flat-square)
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
![Banner](.github/images/banner.svg)
</div>
---
## Aperçu
<!-- [img] : vue catalogue complète — grille des affiches avec les barres d'état en bas de chaque affiche (couleurs : dispo / en cours / importé), barre de recherche visible -->
![Catalogue](.github/images/catalog.png)
> Vue catalogue : toutes les séries Fankai avec leur état d'organisation. Les badges indiquent si les fichiers sont déjà présents dans votre médiathèque Jellyfin.
> Vue catalogue : toutes les séries Fankai. Une barre en bas de chaque affiche indique l'état — disponible, en cours de téléchargement ou déjà importé dans votre médiathèque.
---
<!-- [img] : vue série — détail des saisons et épisodes avec badges VOSTFR/MULTI + drapeaux, boutons de téléchargement avec dropdown multi-sources, barres de progression par épisode -->
![Série](.github/images/serie.png)
> Vue série : épisodes avec badge de langue (VOSTFR / MULTI), progression par fichier, et dropdown de sélection quand plusieurs sources sont disponibles.
---
<!-- [img] : vue téléchargements — liste des torrents actifs avec barres de progression, état d'organisation, badges erreur au hover -->
![Téléchargements](.github/images/downloads.png)
> Vue téléchargements : suivi en temps réel, état d'organisation par torrent et détail des erreurs au survol.
---
## Fonctionnalités
- **Catalogue complet** des séries Fankai avec affiches, statut et badges d'état
- **Téléchargement en un clic** vers qBittorrent — par épisode, par saison ou intégrale
- **Organisation automatique** des fichiers téléchargés vers votre médiathèque (hardlink ou déplacement)
- **Suivi par épisode** — badge ✓ organisé sur chaque épisode et compteur par saison
- **Notifications** toast automatiques quand une organisation se termine
- **Page téléchargements** avec statut d'organisation et badge erreurs avec détail au hover
- **Logs centralisés** avec filtre par niveau/source, rotation automatique et clear depuis l'UI
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
### 📥 Téléchargement
- **Catalogue complet** des séries Fankai avec affiches et état de disponibilité
- **Téléchargement en un clic** vers votre client torrent — par épisode, par saison ou en intégrale
- **Dropdown intelligent** sur chaque bouton quand plusieurs sources sont disponibles (qualités différentes, packs différents)
- **"Tout télécharger"** unifié — sélectionne automatiquement les épisodes non couverts par l'intégrale choisie et complète avec les packs individuels
- **Progression par fichier** — barre de téléchargement au niveau de l'épisode, même pour les fichiers dans un pack intégrale
### 🗂️ 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
### 🏷️ 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
### ⚙️ Système
- **Logs centralisés** — filtre par niveau et source, rotation automatique, clear depuis l'UI
- **Authentification** par mot de passe avec session JWT
- **Compatible Runtipi / Docker**
---
![Serie](.github/images/serie.png)
> Vue série : détail des épisodes avec leur état de disponibilité torrent et d'organisation dans Jellyfin.
---
![Download](.github/images/download.png)
> Vue téléchargements : suivi en temps réel de l'organisation avec détail des erreurs au survol.
---
![Logs](.github/images/log.png)
> Page logs : historique complet des événements avec filtres et gestion de la taille.
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
- **Compatible Docker / Runtipi / Binaire autonome**
---
@@ -61,22 +74,17 @@ Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
Les données du catalogue sont récupérées depuis le repo [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
Ce repo contient un pipeline de scripts Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, puis les croise avec l'API metadata de Fankai pour identifier chaque épisode. Les scripts tournent automatiquement **toutes les 6 heures** via GitHub Actions et publient le fichier `torrent_final.json` dans le repo.
Ce repo contient un pipeline Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, les croise avec l'API metadata de Fankai pour identifier chaque épisode, et publie le résultat automatiquement **toutes les 6 heures** via GitHub Actions. Les données sont organisées par série dans un dossier `/serie` et des fichiers d'index à la racine du repo.
Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
FanKarr récupère ces données au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
---
## Installation
### Prérequis
### Docker Compose (recommandé)
- Docker + Docker Compose
- qBittorrent accessible en réseau
- Un dossier de téléchargements complétés (`/downloads/complete` ou équivalent)
- Un dossier médiathèque pour Jellyfin (`/media/Kai` ou équivalent)
### Docker Compose
**Prérequis** : Docker + Docker Compose, un client torrent accessible en réseau.
```yaml
services:
@@ -88,11 +96,10 @@ services:
- PGID=1000 # GID de votre utilisateur (id -g)
- TZ=Europe/Paris # Votre timezone
volumes:
- ./data:/app/data # Config, logs, base de données
- /votre/chemin/media:/media/Kai # Destination Jellyfin
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
- ./config:/config # Config, logs, base de données
- /votre/chemin:/media # Racine de votre médiathèque (voir note hardlinks)
ports:
- 3001:3001
- 9898:9898
restart: unless-stopped
```
@@ -100,65 +107,134 @@ services:
docker compose up -d
```
Fankarr sera accessible sur `http://localhost:3001`.
> **⚠️ 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.
>
> Plusieurs volumes sont possibles mais les hardlinks ne fonctionneront pas entre deux volumes différents — FanKarr basculera automatiquement en copie.
---
### Binaire autonome (Windows / Linux / macOS)
Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
| Système | Archive |
| -------------------------------- | -------------------------------- |
| Windows x64 | `fankarr-windows-x64.zip` |
| Windows Legacy (anciens CPU) | `fankarr-windows-legacy.zip` |
| Linux x64 | `fankarr-linux-x64.zip` |
| macOS (Apple Silicon) | `fankarr-macos.zip` |
Extrayez l'archive — tous les fichiers doivent rester ensemble dans le même répertoire :
```
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)
```
> Le fichier `.env` permet de configurer le port, la durée des sessions, etc. sans passer par des variables d'environnement système. Il est chargé automatiquement au démarrage si présent à côté du binaire.
**Linux / macOS** — rendre le binaire exécutable avant le premier lancement :
```bash
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.
---
### Variables d'environnement
| Variable | Défaut | Description |
|---|---|---|
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers |
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `data/secret.key` si absent |
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
| Variable | Défaut | Description |
| ------------------ | ------------- | ------------------------------------------------------------------------------- |
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `PORT` | `9898` | Port d'écoute du serveur |
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent |
| `AUTH_TOKEN_EXPIRY`| `30d` | Durée de validité du token de connexion (ex: `7d`, `1y`, `never`) |
| `DATA_DIR` | `./config` | Répertoire de stockage des données (binaire uniquement) |
| `GITHUB_RAW_URL` | repo scraper | URL de base du repo scraper si vous hébergez votre propre instance |
---
### Premier démarrage
1. Ouvrir `http://localhost:3001`
1. Ouvrir `http://localhost:9898`
2. Créer un mot de passe à la première connexion
3. Aller dans **Paramètres** → ajouter votre client qBittorrent
4. Vérifier les chemins `completePath` et `mediaPath`
5. Choisir le mode d'organisation : `hardlink` (recommandé) ou `move`
6. Retourner au catalogue et télécharger
3. Aller dans **Paramètres** → configurer votre client torrent
4. Renseigner le chemin vers le dossier des **torrents terminés** et celui de votre **médiathèque**
5. Choisir le mode d'organisation : `hardlink` (recommandé), `move` ou `copy`
6. Optionnel : lancer le **scan** pour référencer les fichiers déjà présents sur le disque
7. Retourner au catalogue et télécharger
<!-- [img] : page paramètres — formulaire de configuration client torrent, chemins, mode d'organisation, toggles autoImport / NFO / désimport auto -->
![Paramètres](.github/images/settings.png)
---
## Organisation des fichiers
Fankarr organise automatiquement les fichiers terminés toutes les **30 secondes** vers la structure attendue par Jellyfin :
FanKarr organise les fichiers terminés vers la structure attendue par Jellyfin / Plex :
```
/media/Kai/
└── Black Lagoon Henshū/
└── Saison 001/
├── Black Lagoon Henshū S01E01.mkv
├── Black Lagoon Henshū S01E02.mkv
└── ...
Black Lagoon Henshū/
├── Saison 1/
│ ├── Black Lagoon Henshū.S01E01.MULTI.1080p.x264-FANKAI.mkv
│ ├── Black Lagoon Henshū.S01E02.MULTI.1080p.x264-FANKAI.mkv
│ ├── Black Lagoon Henshū.S01E03.MULTI.1080p.x264-FANKAI.mkv
│ └── Black Lagoon Henshū.S01E04.MULTI.1080p.x264-FANKAI.mkv
├── Saison 2/
│ ├── Black Lagoon Henshū.S02E05.MULTI.1080p.x264-FANKAI.mkv
│ ├── Black Lagoon Henshū.S02E06.MULTI.1080p.x264-FANKAI.mkv
│ └── Black Lagoon Henshū.S02E07.MULTI.1080p.x264-FANKAI.mkv
└── Saison 3/
└── Black Lagoon Henshū.S03E08.MULTI.1080p.x264-FANKAI.mkv
```
Le mode **hardlink** est recommandé si vos dossiers `complete` et `media` sont sur le même filesystem — les fichiers ne sont pas déplacés, qBittorrent continue de seeder.
Le mode **hardlink** est recommandé si vos dossiers de téléchargement et de médiathèque sont sur le même filesystem — les fichiers ne sont pas déplacés et le client torrent continue de seeder sans interruption.
L'organisation se déclenche :
- **Automatiquement** dès la fin d'un téléchargement, et toutes les 5 minutes en arrière-plan pour les torrents déjà en seeding
- **Manuellement** depuis le bouton **Importer** dans la vue téléchargements
Le **scan médiathèque** permet de référencer des fichiers déjà présents sur le disque sans les déplacer — utile si vous avez déjà une bibliothèque existante.
---
## Logs
<!-- [img] : page logs — liste des événements avec filtres niveau/source, bouton clear, affichage compact -->
![Logs](.github/images/logs.png)
> Tous les événements sont horodatés et filtrables par source (`organize`, `api`, `torrent`, …) et niveau (`info`, `warn`, `error`, `debug`).
---
## Runtipi
Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
FanKarr est disponible dans l'appstore personnalisé. Pour l'installer :
1. Dans Runtipi, aller dans **Paramètres → App Stores**
2. Ajouter l'URL : `https://github.com/Masutayunikon/runtipi-appstore`
3. Fankarr apparaît dans le catalogue → **Installer**
3. FanKarr apparaît dans le catalogue → **Installer**
---
## Stack technique
| Couche | Technologie |
|---|---|
| Frontend | Vue 3 + Vite + Tailwind CSS |
| Backend | Express 5 + TypeScript |
| Auth | JWT + bcrypt |
| Packaging | Docker (`node:20-slim`) + pnpm |
| Permissions | gosu (PUID/PGID) |
| 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) |
---
@@ -167,4 +243,4 @@ Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
- [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
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
+14 -15
View File
@@ -1,15 +1,14 @@
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 # Votre timezone
volumes:
- ./data:/app/data # Config, logs, base de données
- /votre/chemin/media:/media/Kai # Destination Jellyfin
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
ports:
- 3001:3001
restart: unless-stopped
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
+1 -1
View File
@@ -7,6 +7,6 @@ PGID=${PGID:-1000}
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
# Corriger les permissions du dossier data
chown -R "$PUID:$PGID" /app/data
chown -R "$PUID:$PGID" /config
exec gosu "$PUID:$PGID" node dist/server/index.js
+13 -2
View File
@@ -7,18 +7,29 @@
"dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
"dev:server": "tsx watch server/index.ts",
"dev:client": "vite",
"build": "vite build --outDir dist/client && tsc -p tsconfig.server.json",
"build": "vite build && tsc -p tsconfig.server.json",
"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",
"build:bun:all": "pnpm build:bun:linux && pnpm build:bun:windows && pnpm build:bun:macos",
"start": "node dist/server/index.js",
"preview": "vite preview"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"bcrypt": "^6.0.0",
"@vueuse/core": "^14.2.1",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"lucide-vue-next": "^1.0.0",
"node-fetch": "^3.3.2",
"pinia": "^3.0.4",
"tailwindcss": "^4.2.1",
"undici": "^8.1.0",
"vue": "^3.5.25",
"vue-router": "^5.0.3"
},
+62 -22
View File
@@ -11,9 +11,12 @@ importers:
'@tailwindcss/vite':
specifier: ^4.2.1
version: 4.2.1(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))
bcrypt:
specifier: ^6.0.0
version: 6.0.0
'@vueuse/core':
specifier: ^14.2.1
version: 14.2.1(vue@3.5.29(typescript@5.9.3))
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
@@ -23,6 +26,9 @@ importers:
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
lucide-vue-next:
specifier: ^1.0.0
version: 1.0.0(vue@3.5.29(typescript@5.9.3))
node-fetch:
specifier: ^3.3.2
version: 3.3.2
@@ -32,6 +38,9 @@ importers:
tailwindcss:
specifier: ^4.2.1
version: 4.2.1
undici:
specifier: ^8.1.0
version: 8.1.0
vue:
specifier: ^3.5.25
version: 3.5.29(typescript@5.9.3)
@@ -536,6 +545,9 @@ packages:
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@vitejs/plugin-vue@6.0.4':
resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -622,6 +634,19 @@ packages:
vue:
optional: true
'@vueuse/core@14.2.1':
resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@14.2.1':
resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==}
'@vueuse/shared@14.2.1':
resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==}
peerDependencies:
vue: ^3.5.0
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -650,9 +675,9 @@ packages:
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
engines: {node: '>=20.19.0'}
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
@@ -1039,6 +1064,11 @@ packages:
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lucide-vue-next@1.0.0:
resolution: {integrity: sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==}
peerDependencies:
vue: '>=3.0.1'
magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
engines: {node: '>=20.19.0'}
@@ -1087,10 +1117,6 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
node-addon-api@8.6.0:
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
engines: {node: ^18 || ^20 || >= 21}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -1100,10 +1126,6 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -1324,6 +1346,10 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@8.1.0:
resolution: {integrity: sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==}
engines: {node: '>=22.19.0'}
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
@@ -1766,6 +1792,8 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/node': 24.12.0
'@types/web-bluetooth@0.0.21': {}
'@vitejs/plugin-vue@6.0.4(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))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2
@@ -1894,6 +1922,19 @@ snapshots:
typescript: 5.9.3
vue: 3.5.29(typescript@5.9.3)
'@vueuse/core@14.2.1(vue@3.5.29(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.2.1
'@vueuse/shared': 14.2.1(vue@3.5.29(typescript@5.9.3))
vue: 3.5.29(typescript@5.9.3)
'@vueuse/metadata@14.2.1': {}
'@vueuse/shared@14.2.1(vue@3.5.29(typescript@5.9.3))':
dependencies:
vue: 3.5.29(typescript@5.9.3)
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -1919,10 +1960,7 @@ snapshots:
'@babel/parser': 7.29.0
ast-kit: 2.2.0
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
bcryptjs@3.0.3: {}
birpc@2.9.0: {}
@@ -2314,6 +2352,10 @@ snapshots:
lodash.once@4.1.1: {}
lucide-vue-next@1.0.0(vue@3.5.29(typescript@5.9.3)):
dependencies:
vue: 3.5.29(typescript@5.9.3)
magic-string-ast@1.0.3:
dependencies:
magic-string: 0.30.21
@@ -2351,8 +2393,6 @@ snapshots:
negotiator@1.0.0: {}
node-addon-api@8.6.0: {}
node-domexception@1.0.0: {}
node-fetch@3.3.2:
@@ -2361,8 +2401,6 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-gyp-build@4.8.4: {}
object-inspect@1.13.4: {}
on-finished@2.4.1:
@@ -2614,6 +2652,8 @@ snapshots:
undici-types@7.16.0: {}
undici@8.1.0: {}
unpipe@1.0.0: {}
unplugin-utils@0.3.1:
+38 -12
View File
@@ -1,15 +1,29 @@
import fs from 'fs'
import path from 'path'
import bcrypt from 'bcrypt'
import bcrypt from 'bcryptjs'
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'
const DATA_PATH = path.join(process.cwd(), 'data', 'auth.json')
// 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'
const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY
function signToken(username: string): string {
return NO_EXPIRY
? jwt.sign({ username }, JWT_SECRET)
: jwt.sign({ username }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
}
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10
interface AuthData {
username: string
username : string
passwordHash: string
}
@@ -31,25 +45,25 @@ function writeAuth(data: AuthData): void {
// GET /api/auth/status
export function authStatus(req: Request, res: Response): void {
const existing = readAuth()
const token = req.cookies?.fankarr_token
const token = req.cookies?.fankarr_token
let loggedIn = false
if (token) {
try {
jwt.verify(token, JWT_SECRET)
loggedIn = true
} catch {}
} catch {
logger.debug('auth', 'Token invalide ou expiré')
}
}
res.json({
setup: !!existing,
loggedIn
})
res.json({ setup: !!existing, loggedIn })
}
// POST /api/auth/setup
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
}
@@ -62,8 +76,9 @@ export function authSetup(req: Request, res: Response): void {
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
writeAuth({ username, passwordHash })
logger.info('auth', `Compte créé pour "${username}"`)
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
const token = signToken(username)
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true })
}
@@ -83,17 +98,26 @@ export function authLogin(req: Request, res: Response): void {
}
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 token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
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 })
}
// POST /api/auth/logout
export function authLogout(_req: Request, res: Response): void {
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}"`)
} catch {}
}
res.clearCookie('fankarr_token')
res.json({ success: true })
}
@@ -102,6 +126,7 @@ export function authLogout(_req: Request, res: Response): void {
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
}
@@ -109,6 +134,7 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
jwt.verify(token, JWT_SECRET)
next()
} catch {
logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`)
res.status(401).json({ error: 'Token invalide' })
}
}
+66
View File
@@ -0,0 +1,66 @@
import fs from 'fs'
import path from 'path'
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
&& path.dirname((process as any).execPath) !== process.cwd()
// ── Chargement anticipé du fichier .env pour les binaires bun ─────────────
// Doit s'exécuter avant tout autre module qui lit process.env
// (secret.ts, auth.ts…) car config.ts est la dépendance la plus profonde.
;(() => {
if (!_isBunBinary) return
const binDir = path.dirname((process as any).execPath)
const envPath = path.join(binDir, '.env')
if (!fs.existsSync(envPath)) return
try {
const lines = fs.readFileSync(envPath, 'utf-8').split('\n')
for (const raw of lines) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
const eq = line.indexOf('=')
if (eq === -1) continue
const key = line.slice(0, eq).trim()
let val = line.slice(eq + 1).trim()
// Supprimer les guillemets optionnels
if ((val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1)
}
// Ne pas écraser les variables déjà définies (CLI/système prioritaires)
if (key && !(key in process.env)) process.env[key] = val
}
console.log(`[config] .env chargé depuis ${envPath}`)
} catch (err) {
console.warn(`[config] Impossible de charger .env : ${err}`)
}
})()
export const BASE_DIR = _isBunBinary
? path.dirname((process as any).execPath)
: process.cwd()
const isInContainer = (): boolean => {
// Docker
if (fs.existsSync('/.dockerenv')) return true;
// Podman (crée un fichier /.containerenv)
if (fs.existsSync('/.containerenv')) return true;
// Vérification via cgroup (Docker, Podman, LXC, etc.)
try {
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
if (/docker|podman|containerd|lxc/i.test(cgroup)) return true;
} catch {}
// Vérification via /proc/1/environ (fallback général)
try {
const environ = fs.readFileSync('/proc/1/environ', 'utf8');
if (environ.includes('container=')) return true;
} catch {}
return false;
};
export const DATA_DIR = isInContainer()
? '/config'
: path.join(BASE_DIR, 'config');
+22
View File
@@ -0,0 +1,22 @@
/**
* gitlab-map.ts
* =============
* Map des titres API → titres GitLab pour le téléchargement des NFO.
* Partagé entre organize-worker.ts et index.ts.
*/
export const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
'Tower Of God Henshū' : 'Tower of God Henshū',
}
export function getGitlabTitle(title: string): string {
return SERIE_TITLE_GITLAB_MAP[title] ?? title
}
+121 -492
View File
@@ -1,32 +1,48 @@
import express from 'express'
import cookieParser from 'cookie-parser'
import http from 'http'
import fs from 'fs'
import path from 'path'
import { authStatus, authSetup, authLogin, authLogout, requireAuth } from './auth.js'
import { readSettings, writeSettings } from './settings.js'
import {
registerDriver, getDriver, getAvailableClients,
listClients, addClient, removeClient, getClient,
sanitizeClient, dispatchDownload, dispatchList
} from './torrent-clients/index.js'
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
import { authStatus, authSetup, authLogin, authLogout } from './auth.js'
import { readSettings } from './settings.js'
import { registerDriver, dispatchList, dispatchRemove } from './torrent-clients/index.js'
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import transmissionDriver from './torrent-clients/transmission.js'
import synologyDsDriver from './torrent-clients/synology-ds.js'
import utorrentDriver from './torrent-clients/utorrent.js'
import rtorrentDriver from './torrent-clients/rtorrent.js'
import realDebridDriver from './torrent-clients/real-debrid.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js'
import { logger } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js'
import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js'
import { pushNotif } from './lib/notifs.js'
import { checkNfoUpdates } from './lib/nfo.js'
import settingsRouter from './routes/settings.js'
import torrentClientsRouter from './routes/torrent-clients.js'
import seriesRouter from './routes/series.js'
import downloadsRouter from './routes/downloads.js'
import organizeRouter from './routes/organize.js'
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'
registerDriver(qbittorrentDriver)
registerDriver(transmissionDriver)
registerDriver(synologyDsDriver)
registerDriver(utorrentDriver)
registerDriver(rtorrentDriver)
registerDriver(realDebridDriver)
const app = express()
const PORT = 3001
const FANKAI_API = 'https://metadata.fankai.fr'
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json'
const app = express()
const PORT = Number(process.env.PORT) || 9898
app.use(express.json())
app.use(cookieParser())
// ── Static frontend (prod) ─────────────────────────────────────
const PUBLIC_PATH = path.join(process.cwd(), 'public')
const PUBLIC_PATH = path.join(BASE_DIR, 'public')
if (fs.existsSync(PUBLIC_PATH)) {
app.use(express.static(PUBLIC_PATH))
}
@@ -37,499 +53,112 @@ app.post('/api/auth/setup', authSetup)
app.post('/api/auth/login', authLogin)
app.post('/api/auth/logout', authLogout)
// ── Settings ───────────────────────────────────────────────────
app.get('/api/settings', requireAuth, (_req, res) => {
res.json(readSettings())
})
app.post('/api/settings', requireAuth, (req, res) => {
const { mediaPath, completePath, organizeMode, category } = req.body
res.json(writeSettings({ mediaPath, completePath, organizeMode, category }))
})
// ── Routes ─────────────────────────────────────────────────────
app.use('/api', settingsRouter)
app.use('/api', torrentClientsRouter)
app.use('/api', seriesRouter)
app.use('/api', downloadsRouter)
app.use('/api', organizeRouter)
app.use('/api', importRouter)
app.use('/api', systemRouter)
app.use('/api', nfoUpdatesRouter)
app.use('/api', plexRouter)
// ── Torrent clients ────────────────────────────────────────────
app.get('/api/torrent-clients/available', requireAuth, (_req, res) => {
res.json(getAvailableClients())
})
app.get('/api/torrent-clients', requireAuth, (_req, res) => {
res.json(listClients().map((c: any) => sanitizeClient(c)))
})
app.post('/api/torrent-clients', requireAuth, (req, res) => {
const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
res.json(sanitizeClient(addClient(name, type, config)))
})
app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
const ok = removeClient(String(req.params.uuid))
if (!ok) res.status(404).json({ error: 'Client introuvable' })
else res.json({ ok: true })
})
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
const { type, config } = req.body
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
const driver = getDriver(type)
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
res.json(await driver.test(config))
})
app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type)
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
res.json(await driver.test(client.config))
})
app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) => {
const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type)
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
res.json(await driver.healthcheck(client.config))
})
// ── Helpers ────────────────────────────────────────────────────
function readTorrents(): any[] {
try {
if (!fs.existsSync(TORRENTS_PATH)) return []
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
} catch { return [] }
}
async function fankaiGet(endpoint: string): Promise<any> {
const res = await fetch(`${FANKAI_API}${endpoint}`)
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
return res.json()
}
function indexBySerie(torrents: any[]): Map<number, any[]> {
const map = new Map<number, any[]>()
for (const t of torrents) {
if (!t.serie_id) continue
if (!map.has(t.serie_id)) map.set(t.serie_id, [])
map.get(t.serie_id)!.push(t)
}
return map
}
// ── Series ─────────────────────────────────────────────────────
function computeSerieDownloadState(
serieTorrents : any[],
organized : Record<string, Record<string, string>>,
activeTorrents: Set<string> // hashes actuellement dans qB en downloading
): 'none' | 'downloading' | 'partial' | 'complete' {
if (serieTorrents.length === 0) return 'none'
// Un torrent de la série est en cours de téléchargement ?
const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase()))
if (hasActive) return 'downloading'
// Compter les fichiers attendus vs organisés
let totalFiles = 0
let doneFiles = 0
for (const t of serieTorrents) {
const files: any[] = t.torrent_files ?? []
if (files.length === 0) {
// Fichier unique
totalFiles++
if (organized[t.infohash?.toLowerCase()]?.[t.raw]) doneFiles++
} else {
totalFiles += files.length
doneFiles += files.filter((f: any) => organized[t.infohash?.toLowerCase()]?.[f.filename]).length
}
}
if (totalFiles === 0) return 'none'
if (doneFiles === 0) return 'none'
if (doneFiles >= totalFiles) return 'complete'
return 'partial'
}
app.get('/api/series', requireAuth, async (_req, res) => {
try {
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())])
const byId = indexBySerie(torrents)
// Charger organized.json
let organized: Record<string, Record<string, string>> = {}
try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {}
// Récupérer les hashes actifs depuis qB (best effort)
const activeTorrents = new Set<string>()
try {
const { category } = readSettings()
const active = await dispatchList(category ?? 'fankai')
for (const t of active) {
if (t.state === 'downloading' && t.hash)
activeTorrents.add(t.hash.toLowerCase())
}
} catch {}
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
res.json({ series: seriesList.map((serie: any) => {
const serieTorrents = byId.get(serie.id) ?? []
return {
...serie,
torrent_count : serieTorrents.length,
has_torrents : serieTorrents.length > 0,
download_state: computeSerieDownloadState(serieTorrents, organized, activeTorrents),
}
})})
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
app.get('/api/series/:id', requireAuth, async (req, res) => {
const id = Number(req.params.id)
try {
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`)])
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`)
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
}))
const torrents = readTorrents().filter(t => t.serie_id === id)
const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
const availableEpisodeIds = new Set<number>()
const episodeTorrentMap: Record<number, any> = {}
const seasonTorrentMap : Record<number, any> = {}
const integraleTorrents: any[] = []
const packEpisodesTorrents: any[] = []
for (const t of torrents) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw }
for (const ep of t.resolved_episodes ?? []) {
availableEpisodeIds.add(ep.episode_id)
if (t.type === 'episode') episodeTorrentMap[ep.episode_id] = ref
}
if (t.type === 'pack_saison') {
const rs: any[] = t.resolved_seasons ?? []
if (rs.length > 1) {
packEpisodesTorrents.push(t)
} else {
const sids = rs.length > 0 ? rs.map((r: any) => r.season_id) : [t.season_id]
for (const sid of sids) { if (sid) seasonTorrentMap[sid] = ref }
}
} else if (t.type === 'pack_integrale') {
integraleTorrents.push(t)
} else if (t.type === 'pack_episodes') {
packEpisodesTorrents.push(t)
}
}
let promotedIntegrale: any = null
if (integraleTorrents.length === 0 && allSeasonIds.size > 0) {
for (const t of torrents.filter(t => t.type === 'pack_saison')) {
const rs: any[] = t.resolved_seasons ?? []
const tIds = new Set<number>(rs.length > 0 ? rs.map((r: any) => r.season_id) : t.season_id ? [t.season_id] : [])
if ([...allSeasonIds].every(sid => tIds.has(sid))) { promotedIntegrale = t; break }
}
}
if (integraleTorrents.length > 0 || promotedIntegrale) {
for (const key of Object.keys(seasonTorrentMap)) delete seasonTorrentMap[Number(key)]
if (promotedIntegrale) {
const idx = packEpisodesTorrents.indexOf(promotedIntegrale)
if (idx !== -1) packEpisodesTorrents.splice(idx, 1)
}
}
function packEpisodesLabel(t: any): string {
const eps: any[] = t.resolved_episodes ?? []
if (!eps.length) return t.raw
const bySeason = new Map<number, number[]>()
for (const ep of eps) {
const sn = ep.season_number ?? 0
if (!bySeason.has(sn)) bySeason.set(sn, [])
bySeason.get(sn)!.push(ep.episode_number)
}
return [...bySeason.entries()].sort(([a], [b]) => a - b).map(([sn, nums]) => {
const s = nums.sort((a, b) => a - b)
const label = sn === 0 ? 'S0' : `S${sn}`
return s[0] === s[s.length-1] ? `${label} Ep ${s[0]}` : `${label} Eps ${s[0]}-${s[s.length-1]}`
}).join(' · ')
}
// Charger organized.json pour les badges d'état
let organized: Record<string, Record<string, string>> = {}
try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {}
// Construire un set des episode_id organisés
// On croise resolved_episodes de chaque torrent avec organized.json
const organizedEpisodeIds = new Set<number>()
for (const t of torrents) {
const hash = t.infohash?.toLowerCase()
const files = t.torrent_files ?? []
const orgFiles = organized[hash] ?? {}
if (Object.keys(orgFiles).length === 0) continue
// Au moins un fichier organisé pour ce torrent → on marque les épisodes résolus
for (const ep of t.resolved_episodes ?? []) {
// Vérifier si le fichier de cet épisode est organisé
const epFile = files.find((f: any) =>
f.episode_ids?.includes(ep.episode_id) || f.episode_number === ep.episode_number
)
if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) {
organizedEpisodeIds.add(ep.episode_id)
}
}
}
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
const eps = season.episodes.map((ep: any) => ({
...ep,
available : availableEpisodeIds.has(ep.id),
torrent : episodeTorrentMap[ep.id] ?? null,
organized : organizedEpisodeIds.has(ep.id),
}))
// State saison : none / partial / complete
const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).length
const seasonOrganizedState =
orgCount === 0 ? 'none' :
orgCount >= total && total > 0 ? 'complete' : 'partial'
return {
...season,
torrent : seasonTorrentMap[season.id] ?? null,
organized_state : seasonOrganizedState,
organized_count : orgCount,
episodes : eps,
}
})
const heroBundles: any[] = [
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })),
...(promotedIntegrale ? [{ label: 'Intégrale', torrent_url: promotedIntegrale.torrent_url, magnet: promotedIntegrale.magnet, raw: promotedIntegrale.raw }] : []),
...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw }))
]
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: heroBundles })
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Download ───────────────────────────────────────────────────
app.post('/api/download', requireAuth, async (req, res) => {
const url = req.body.torrent_url ?? req.body.magnet
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
const results = await dispatchDownload(url)
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
})
// ── Downloads — liste torrents Fankai ──────────────────────────
app.get('/api/downloads', requireAuth, async (_req, res) => {
try {
const { category } = readSettings()
const torrents = await dispatchList(category ?? 'fankai')
// Enrichir avec l'état d'organisation
let organized: Record<string, Record<string, string>> = {}
try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {}
let torrentFinal: any[] = []
try {
const tfPath = path.join(process.cwd(), 'data', 'torrent_final.json')
if (fs.existsSync(tfPath))
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
} catch {}
const enriched = torrents.map((t: any) => {
const orgFiles = organized[t.hash] ?? {}
const meta = torrentFinal.find((m: any) =>
m.infohash?.toLowerCase() === t.hash?.toLowerCase()
)
const totalFiles = (meta?.torrent_files?.length ?? 0) || 1
const doneFiles = Object.keys(orgFiles).length
let organizeState: 'none' | 'partial' | 'done' = 'none'
if (doneFiles >= totalFiles) organizeState = 'done'
else if (doneFiles > 0) organizeState = 'partial'
// Récupérer les erreurs depuis recentOrganized
const notif = recentOrganized.find(n => n.hash === t.hash)
const errorFiles = notif?.errorFiles ?? []
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles }
})
res.json(enriched)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Résultats d'organisation récents (pour notifications frontend) ──
interface OrganizeNotif {
hash : string
name : string
done : number
skipped : number
errors : number
errorFiles : { file: string; error: string }[]
at : string
}
const recentOrganized: OrganizeNotif[] = []
const MAX_NOTIFS = 20
function pushNotif(n: OrganizeNotif) {
recentOrganized.unshift(n)
if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
}
app.get('/api/organize/recent', requireAuth, (_req, res) => {
res.json(recentOrganized)
})
// Vider les notifs lues
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
recentOrganized.length = 0
res.json({ ok: true })
})
app.post('/api/organize', requireAuth, async (req, res) => {
const { hash, name, save_path } = req.body
if (!hash || !name || !save_path) {
res.status(400).json({ error: 'hash, name et save_path requis' })
return
}
try {
const result = await organizeTorrent(hash, name, save_path)
res.json(result)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Torrents status ────────────────────────────────────────────
// ── Logs ──────────────────────────────────────────────────────
app.get('/api/logs', requireAuth, (req, res) => {
const limit = Number(req.query.limit) || 100
const level = (req.query.level as string) || 'all'
const source = (req.query.source as string) || undefined
const entries = readLogs({ limit, level: level as any, source })
res.json({ entries, size: logsFileSize() })
})
app.post('/api/logs/clear', requireAuth, (_req, res) => {
clearLogs()
logger.info('api', 'Logs effacés')
res.json({ ok: true })
})
// ── System info ────────────────────────────────────────────────
app.get('/api/system', requireAuth, (_req, res) => {
const isDocker = fs.existsSync('/.dockerenv')
res.json({ isDocker })
})
app.get('/api/browse', requireAuth, (req, res) => {
const dirPath = (req.query.path as string) || '/'
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
const dirs = entries
.filter(e => e.isDirectory())
.map(e => e.name)
.filter(n => !n.startsWith('.'))
.sort((a, b) => a.localeCompare(b))
const parent = dirPath === '/' ? null : path.dirname(dirPath)
res.json({ path: dirPath, parent, dirs })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
}
})
app.get('/api/torrents/status', requireAuth, (_req, res) => {
try {
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
const raw = fs.readFileSync(TORRENTS_PATH, 'utf-8').trim()
if (!raw || raw === '[]') { res.json({ exists: true, count: 0, empty: true }); return }
const data = JSON.parse(raw)
const count = Array.isArray(data) ? data.length : 0
res.json({ exists: true, count, empty: count === 0 })
} catch { res.json({ exists: false, count: 0, empty: true }) }
})
// ── Update torrent_final.json ──────────────────────────────────
app.post('/api/update', requireAuth, async (_req, res) => {
try {
const response = await fetch(GITHUB_RAW_URL)
if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`)
const data = await response.json() as unknown[]
if (!Array.isArray(data)) throw new Error('Format invalide')
fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true })
fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8')
res.json({ ok: true, count: data.length })
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
app.post('/api/scan', requireAuth, async (_req, res) => {
try {
const { mediaPath } = readSettings()
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
const result = await scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH)
logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`)
res.json({ ok: true, ...result })
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── SPA fallback (prod) — doit être après toutes les routes API ─
// ── Catch-all SPA ──────────────────────────────────────────────
if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => {
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
})
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
}
// ── Start ──────────────────────────────────────────────────────
app.listen(PORT, () => {
// ── Démarrage ──────────────────────────────────────────────────
const server = http.createServer({ maxHeaderSize: 32768 }, app)
server.listen(PORT, async () => {
logger.info('api', `Serveur démarré sur le port ${PORT}`)
try {
const data = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
const count = Array.isArray(data) ? data.length : 0
if (count === 0) console.warn('[fankarr] ⚠ torrent_final.json est vide')
else console.log(`[fankarr] ${count} torrents chargés`)
} catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') }
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
const available = await readAvailable()
logger.info('api', `Cache GitHub initialisé — ${available.length} séries disponibles`)
} catch (err) {
logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`)
}
// Scan initial du mediaPath pour reconnaître les fichiers déjà présents
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
const { mediaPath } = readSettings()
scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err =>
logger.error('organize', `scanMediaPath échoué: ${err}`)
)
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
const { mediaPath } = readSettings()
// Migration organized.json : si ancien format (valeurs string) → reset + rescan
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')
}
}
} catch {}
loadEnrichedSeriesData()
.then(async seriesData => {
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
})
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
// Auto-organise toutes les 30s
const autoOrganize = async () => {
try {
const { category } = readSettings()
const infohashMap = await readInfohashMap()
const seriesData = await loadEnrichedSeriesData()
await autoOrganizeAll(
() => dispatchList(category ?? 'fankai'),
(result) => {
() => dispatchList(category ?? 'fankai', infohashMap),
seriesData,
async (result) => {
if (result.done > 0 || result.errors > 0) {
pushNotif({ ...result, at: new Date().toISOString() })
}
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}`)
}
}
}
)
} catch (err) {
console.error('[organize] Erreur auto-organise:', err)
logger.error('api', `Auto-organise échoué : ${err instanceof Error ? err.message : err}`)
}
}
// Premier run 30s après démarrage, puis toutes les 30s
setTimeout(() => {
autoOrganize()
setInterval(autoOrganize, 30_000)
setInterval(autoOrganize, 5 * 60_000)
}, 10_000)
setTimeout(() => {
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif initiale échouée : ${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}`))
}, 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 organizedPath = path.join(DATA_DIR, 'organized.json')
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
if (updated > 0)
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`)
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0)
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
} catch (err) {
logger.error('api', `Sync noms auto échoué : ${err instanceof Error ? err.message : err}`)
}
}, 60 * 60_000)
})
+65
View File
@@ -0,0 +1,65 @@
import { DATA_DIR } from '../config.js'
import path from 'path'
import fs from 'fs'
const GITHUB_BASE = process.env.GITHUB_BASE
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
interface CacheEntry<T> { data: T; expiresAt: number }
const _cache = new Map<string, CacheEntry<any>>()
export function cacheGet<T>(key: string): T | null {
const entry = _cache.get(key)
if (!entry) return null
if (Date.now() > entry.expiresAt) { _cache.delete(key); return null }
return entry.data as T
}
export function cacheSet<T>(key: string, data: T): void {
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
}
export function cacheClear(): void { _cache.clear() }
export function cacheSize(): number { return _cache.size }
export async function githubGet(urlPath: string): Promise<any> {
const cached = cacheGet(urlPath)
if (cached) return cached
const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
const data = await res.json()
cacheSet(urlPath, data)
return data
}
export async function readAvailable(): Promise<number[]> {
try { return await githubGet('available.json') as number[] }
catch { return [] }
}
export async function readInfohashMap(): Promise<Record<string, string>> {
try { return await githubGet('infohash_map.json') as Record<string, string> }
catch { return {} }
}
export async function readSerieData(serieId: number): Promise<any | null> {
try { return await githubGet(`series/${serieId}.json`) }
catch { return null }
}
export async function loadEnrichedSeriesData(): Promise<any[]> {
const ids = await readAvailable()
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
return allSd.filter(Boolean)
}
export function loadOrganized(): Record<string, Record<string, any>> {
try {
const p = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {}
return {}
}
+99
View File
@@ -0,0 +1,99 @@
import fs from 'fs'
import path from 'path'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { logger } from '../logger.js'
import { getGitlabTitle } from '../gitlab-map.js'
import { loadEnrichedSeriesData, loadOrganized } from './github-cache.js'
export const GITLAB_API_NFO = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
export const GITLAB_RAW_NFO = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
export interface NfoUpdateNotif {
serieTitle: string; commitSha: string; commitMsg: string
updatedAt: string; status: 'updated' | 'error'; error?: string
}
export const recentNfoUpdates: NfoUpdateNotif[] = []
const MAX_NFO_NOTIFS = 30
const NFO_COMMITS_PATH = path.join(DATA_DIR, 'nfo_commits.json')
export function loadNfoCommits(): Record<string, string> {
try { if (!fs.existsSync(NFO_COMMITS_PATH)) return {}; return JSON.parse(fs.readFileSync(NFO_COMMITS_PATH, 'utf-8')) }
catch { return {} }
}
export function saveNfoCommits(commits: Record<string, string>) {
fs.mkdirSync(path.dirname(NFO_COMMITS_PATH), { recursive: true })
fs.writeFileSync(NFO_COMMITS_PATH, JSON.stringify(commits, null, 2), 'utf-8')
}
export async function getLatestGitlabCommit(gitlabTitle: string): Promise<{ sha: string; message: string } | null> {
try {
const res = await fetch(`${GITLAB_API_NFO}/commits?path=${encodeURIComponent('pack/' + gitlabTitle)}&per_page=1&ref_name=main`, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) return null
const data: any[] = await res.json()
if (!data.length) return null
return { sha: data[0].id, message: data[0].title ?? data[0].message ?? '' }
} catch { return null }
}
export async function downloadNfoFolder(gitlabTitle: string, destRoot: string): Promise<void> {
let files: any[] = [], page = 1
while (true) {
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
if (!Array.isArray(batch) || batch.length === 0) break
files.push(...batch); if (batch.length < 100) break; page++
}
let downloaded = 0, skipped = 0
for (const entry of files.filter((f: any) => f.type === 'blob')) {
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
const destPath = path.join(destRoot, relativePath)
try {
const res = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) { skipped++; continue }
fs.mkdirSync(path.dirname(destPath), { recursive: true })
fs.writeFileSync(destPath, Buffer.from(await res.arrayBuffer()))
downloaded++
} catch { skipped++ }
}
logger.info('nfo-update', `"${gitlabTitle}" — ${downloaded} fichiers mis à jour, ${skipped} ignorés`)
}
export async function checkNfoUpdates() {
const { mediaPath, nfoSupport } = readSettings()
if (!nfoSupport) return
const organized = loadOrganized()
if (Object.keys(organized).length === 0) return
const seriesData = await loadEnrichedSeriesData()
const knownHashes = new Set(Object.keys(organized))
const serieTitles = new Set<string>()
for (const sd of seriesData) {
const allTorrents = [...(sd.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => s.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => (s.episodes ?? []).flatMap((e: any) => e.torrents ?? []))]
if (allTorrents.some((t: any) => knownHashes.has(t.infohash?.toLowerCase()))) {
const rawTitle = sd.title ?? sd.show_title ?? ''
if (rawTitle) serieTitles.add(rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim())
}
}
if (serieTitles.size === 0) return
const commits = loadNfoCommits()
let hasChanges = false
for (const serieTitle of serieTitles) {
const gitlabTitle = getGitlabTitle(serieTitle)
const latest = await getLatestGitlabCommit(gitlabTitle)
if (!latest || commits[gitlabTitle] === latest.sha) continue
logger.info('nfo-update', `MAJ NFO détectée pour "${gitlabTitle}" (${latest.sha.slice(0, 8)})`)
try {
await downloadNfoFolder(gitlabTitle, path.join(mediaPath, serieTitle))
commits[gitlabTitle] = latest.sha
hasChanges = true
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'updated' })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('nfo-update', `Échec MAJ NFO "${gitlabTitle}" : ${msg}`)
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'error', error: msg })
}
if (recentNfoUpdates.length > MAX_NFO_NOTIFS) recentNfoUpdates.pop()
}
if (hasChanges) saveNfoCommits(commits)
}
+12
View File
@@ -0,0 +1,12 @@
export interface OrganizeNotif {
hash: string; name: string; done: number; skipped: number; errors: number
errorFiles: { file: string; error: string }[]; at: string
}
export const recentOrganized: OrganizeNotif[] = []
const MAX_NOTIFS = 20
export function pushNotif(n: OrganizeNotif) {
recentOrganized.unshift(n)
if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
}
+186
View File
@@ -0,0 +1,186 @@
import path from 'path'
const FANKAI_API = 'https://metadata.fankai.fr'
export async function fankaiGet(endpoint: string): Promise<any> {
const res = await fetch(`${FANKAI_API}${endpoint}`)
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
return res.json()
}
export function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
if (!url) return null
const stripped = url.replace(/^https?:\/\//, '')
return `https://wsrv.nl/?url=${stripped}&w=${w}&q=${q}`
}
export function normalizeSerie(serie: any): any {
return { ...serie, poster_image: imgProxy(serie.poster_image ?? serie.images?.poster ?? null, 300), fanart_image: imgProxy(serie.fanart_image ?? serie.images?.fanart ?? null, 1280, 60) }
}
export function normalizeSeason(season: any): any {
return { ...season, poster_image: imgProxy(season.poster_image ?? season.images?.poster ?? null, 300) }
}
export function normalizeEpisode(ep: any): any {
return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 400) }
}
export function extractTorrentsFromSerieData(sd: any): any[] {
const result: any[] = []
if (!sd) return result
for (const t of sd.torrents ?? []) {
result.push({ ...t, raw: t.title, type: 'pack_integrale', serie_id: sd.id, serie_title: sd.title, _serieLevel: true, _serieData: sd, _torrentIdx: (sd.torrents ?? []).indexOf(t) })
}
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
result.push({ ...t, raw: t.title, type: 'pack_saison', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, _seasonLevel: true, _season: season, _torrentIdx: (season.torrents ?? []).indexOf(t) })
}
for (const ep of season.episodes ?? []) {
for (const t of ep.torrents ?? []) {
result.push({ ...t, raw: t.title, type: 'episode', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, episode_id: ep.id, episode_number: ep.episode_number, _episodeLevel: true, _episode: ep, _torrentIdx: (ep.torrents ?? []).indexOf(t) })
}
}
}
return result
}
/**
* Résout les noms d'un épisode en préférant les valeurs du path entry
* correspondant au hash donné (spécifique au torrent/encoding),
* avec fallback sur les champs de niveau épisode.
*
* Utilisation :
* const { formatted_name, nfo_filename, original_filename } = resolveEpNaming(ep, hash)
*/
export function resolveEpNaming(ep: any, hash?: string | null): {
formatted_name : string | null
nfo_filename : string | null
original_filename: string | null
} {
const h = hash?.toLowerCase() ?? null
const pathEntry = h
? (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === h)
: null
return {
formatted_name : pathEntry?.formatted_name ?? ep.formatted_name ?? null,
nfo_filename : pathEntry?.nfo_filename ?? ep.nfo_filename ?? null,
original_filename: pathEntry?.original_filename ?? ep.original_filename ?? null,
}
}
/**
* Calcule le nom attendu d'un fichier épisode selon le mode (NFO ou formaté).
* Source unique de vérité partagée entre organized-summary, organized/:id, rename-episode et rename-all.
*/
export function computeExpectedName(
ep : any,
orgEntry: any,
hash : string | null,
nfoSupport: boolean,
): { currentName: string; expectedName: string; needsRename: boolean } {
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, hash)
const expectedName = nfoSupport
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
return { currentName, expectedName, needsRename: expectedName !== currentName }
}
/**
* Dédoublonne une liste d'épisodes par episode_number.
* Quand plusieurs variantes existent (ex. x264 + x265), on garde la première
* comme épisode principal et on fusionne les torrents des autres dans sa liste.
*/
export function deduplicateEpisodes(episodes: any[]): any[] {
const map = new Map<number, any>()
for (const ep of episodes) {
const key = ep.episode_number
if (!map.has(key)) {
map.set(key, { ...ep, torrents: [...(ep.torrents ?? [])] })
} else {
const primary = map.get(key)!
const seenHashes = new Set(primary.torrents.map((t: any) => t.infohash).filter(Boolean))
for (const t of ep.torrents ?? []) {
if (!t.infohash || !seenHashes.has(t.infohash)) {
primary.torrents.push(t)
if (t.infohash) seenHashes.add(t.infohash)
}
}
primary.torrent = primary.torrents[0] ?? null
primary.available = primary.available || ep.available
primary.organized = primary.organized || ep.organized
}
}
return Array.from(map.values())
}
export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] {
const resolved: any[] = []
const h = hash.toLowerCase()
for (const season of sd.seasons ?? []) {
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
for (const ep of season.episodes ?? []) {
const paths: any[] = ep.paths ?? []
let filePath: string | null = null
for (const p of paths) {
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
else if (p?.infohash?.toLowerCase() === h) { filePath = p.path.replace(/\\/g, '/'); break }
}
if (!filePath) continue
const filename = filePath.split('/').pop() ?? filePath
resolved.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, season_id: season.id, filename, original_filename: filename })
}
}
return resolved
}
export function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, any>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
if (!serieData) return 'none'
const allTorrents = extractTorrentsFromSerieData(serieData)
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
// Dédoublonnage : épisodes uniques par (season_number, episode_number)
// pour éviter de compter x264 + x265 comme deux épisodes distincts
const uniqueEpKeys = new Set<string>()
const epIdToKey = new Map<number, string>()
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
const key = `${season.season_number}:${ep.episode_number}`
uniqueEpKeys.add(key)
epIdToKey.set(ep.id, key)
}
}
const organizedEpKeys = new Set<string>()
for (const t of allTorrents) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
const orgFiles = organized[hash] ?? {}
if (Object.keys(orgFiles).length === 0) continue
for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) {
const isOrganized = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
if (isOrganized) {
const key = epIdToKey.get(ep.episode_id)
if (key) organizedEpKeys.add(key)
}
}
}
const manualOrg = organized['manual'] ?? {}
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (manualOrg[String(ep.id)]) {
const key = epIdToKey.get(ep.id)
if (key) organizedEpKeys.add(key)
}
}
}
if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none'
if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete'
return 'partial'
}
+2 -1
View File
@@ -8,6 +8,7 @@
import fs from 'fs'
import path from 'path'
import { DATA_DIR } from './config.js'
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
@@ -19,7 +20,7 @@ export interface LogEntry {
meta? : Record<string, any>
}
const LOGS_PATH = path.join(process.cwd(), 'data', 'logs.jsonl')
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
const MAX_LINES = 2000 // rotation auto au-delà
const IS_PROD = process.env.NODE_ENV === 'production'
+369 -178
View File
@@ -2,74 +2,164 @@
* organize-worker.ts
* ==================
* Worker thread dédié à l'organisation des fichiers.
* Tourne dans un thread séparé pour ne pas bloquer le event loop Express.
*
* Messages reçus (parentPort.on) : { type: 'run', torrents: TorrentInfo[] }
* Messages envoyés (parentPort.postMessage) :
* { type: 'log', level: 'info'|'warn'|'error', msg: string }
* { type: 'result', hash: string, name: string, done, skipped, errors }
* { type: 'done' }
*/
import fs from 'fs'
import fsp from 'fs/promises'
import path from 'path'
import { parentPort } from 'worker_threads'
import { DATA_DIR } from './config.js'
import { getGitlabTitle } from './gitlab-map.js'
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// ─── Utils log ────────────────────────────────────────────────
// Le worker tourne dans un thread séparé — il passe les logs au thread principal
// qui les écrit dans le fichier via logger.ts
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 }) }
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
// ─── Settings (lu directement, pas d'import circulaire) ──────
// ─── Types ────────────────────────────────────────────────────
export interface OrgEntry {
at : string
season : number
episode : number
episode_id : number
src_filename : string
dest_filename: string
dest_path : 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 } {
try {
const p = path.join(process.cwd(), 'data', 'settings.json')
const p = path.join(DATA_DIR, 'settings.json')
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
}
// ─── Organized log ────────────────────────────────────────────
function loadOrganized(): Record<string, Record<string, string>> {
function loadOrganized(): Organized {
try {
if (!fs.existsSync(ORGANIZED_PATH)) return {}
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch { return {} }
}
function markOrganized(hash: string, filename: string) {
function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
const data = loadOrganized()
if (!data[hash]) data[hash] = {}
data[hash][filename] = new Date().toISOString()
data[hash][String(episodeId)] = entry
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
function isOrganized(hash: string, filename: string): boolean {
return !!loadOrganized()[hash]?.[filename]
function isOrganized(hash: string, episodeId: number): boolean {
return !!loadOrganized()[hash]?.[String(episodeId)]
}
// ─── Torrent lookup ───────────────────────────────────────────
// ─── Lookup torrent dans les seriesData ───────────────────────
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
const h = hash.toLowerCase()
for (const sd of seriesData) {
for (const t of sd.torrents ?? []) {
if (t.infohash?.toLowerCase() === h)
return { torrent: t, serieData: sd }
}
function findTorrentByHash(hash: string): any | null {
try {
if (!fs.existsSync(TORRENTS_PATH)) return null
const torrents = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
return torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) ?? null
} catch { return null }
const seasonsWithHash = (sd.seasons ?? []).filter((s: any) =>
s.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
)
if (seasonsWithHash.length === 1) {
const season = seasonsWithHash[0]
const t = season.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: { ...t, _season: season }, serieData: sd }
} else if (seasonsWithHash.length > 1) {
const t = seasonsWithHash[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: t, serieData: sd }
}
for (const season of sd.seasons ?? []) {
const matchingEpisodes = season.episodes?.filter((ep: any) =>
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
) ?? []
if (matchingEpisodes.length === 1) {
const ep = matchingEpisodes[0]
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
} else if (matchingEpisodes.length > 1) {
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: { ...t, _season: season }, serieData: sd }
}
}
}
return null
}
// ─── Dossiers exclus (bonus, musique, images) ────────────────
function buildFileMap(
serieData : any,
hash : string,
nfoSupport : boolean,
seasonFilter?: number,
): Map<string, { season_number: number; episode_number: number; episode_id: number; nfo_filename: string; fullPath: string }> {
const map = new Map<string, { season_number: number; episode_number: number; episode_id: number; nfo_filename: string; fullPath: string }>()
const h = hash.toLowerCase()
for (const season of serieData.seasons ?? []) {
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
for (const ep of season.episodes ?? []) {
const paths: any[] = ep.paths ?? []
let matchedPath : string | null = null
let matchedPathObj: any = null
for (const p of paths) {
if (typeof p === 'string') {
if (!matchedPath) matchedPath = p.replace(/\\/g, '/')
} else if (p?.infohash?.toLowerCase() === h) {
matchedPath = p.path.replace(/\\/g, '/')
matchedPathObj = p
break
}
}
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
const fmtName = rawFmt?.trim()
? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: null
if (!nfoSupport && !fmtName) {
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`)
}
const srcExt = path.extname(filename)
const resolvedName = nfoSupport
? (rawNfo ?? rawOriginal ?? filename)
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
const destName = swapExtension(resolvedName, srcExt)
map.set(filename, {
season_number : season.season_number,
episode_number: ep.episode_number,
episode_id : ep.id,
nfo_filename : destName,
fullPath : matchedPath,
})
}
}
return map
}
// ─── Dossiers exclus ──────────────────────────────────────────
const EXCLUDED_FOLDERS = new Set([
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
@@ -87,273 +177,374 @@ function isInExcludedFolder(filePath: string[]): boolean {
return false
}
// ─── Map correction titres API → titres GitLab ───────────────
const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
'Tower Of God Henshū' : 'Tower of God Henshū',
}
function getGitlabSerieTitle(serieTitle: string): string {
return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle
}
// ─── GitLab NFO downloader ────────────────────────────────────
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'
async function fetchJson(url: string): Promise<any> {
const { default: nodeFetch } = await import('node-fetch')
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
async function fetchBinary(url: string): Promise<Buffer> {
const { default: nodeFetch } = await import('node-fetch')
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return Buffer.from(await res.arrayBuffer())
}
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
const gitlabTitle = getGitlabSerieTitle(serieTitle)
const gitlabTitle = getGitlabTitle(serieTitle)
const folderPath = `pack/${gitlabTitle}`
log(`Téléchargement NFO/images depuis GitLab pour "${gitlabTitle}"`)
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
// Lister récursivement tous les fichiers du dossier
let files: any[]
let files: any[] = []
try {
files = await fetchJson(
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&ref=main`
)
let page = 1
while (true) {
const batch = await fetchJson(
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&page=${page}&ref=main`
)
if (!Array.isArray(batch) || batch.length === 0) break
files.push(...batch)
if (batch.length < 100) break
page++
}
} catch (err) {
warn(`[nfo] 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 (!Array.isArray(files) || files.length === 0) {
warn(`[nfo] Aucun fichier trouvé sur GitLab 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(`[nfo] ${fileEntries.length} fichiers à télécharger`)
log(`${fileEntries.length} fichiers NFO/images à télécharger pour "${gitlabTitle}"`)
let downloaded = 0, skipped = 0, failed = 0
for (const entry of fileEntries) {
// entry.path = "pack/Black Lagoon Henshū/Saison 1/fichier.nfo"
// On retire le préfixe "pack/{gitlabTitle}/" pour obtenir le chemin relatif
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
const destPath = path.join(destRoot, relativePath)
const destDir = path.dirname(destPath)
// Skip si déjà présent
if (fs.existsSync(destPath)) continue
if (fs.existsSync(destPath)) { skipped++; continue }
try {
fs.mkdirSync(destDir, { recursive: true })
fs.mkdirSync(path.dirname(destPath), { recursive: true })
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
const data = await fetchBinary(rawUrl)
fs.writeFileSync(destPath, data)
log(`[nfo] ✓ ${relativePath}`)
downloaded++
} catch (err) {
warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`)
warn(`Échec téléchargement NFO "${relativePath}" : ${err instanceof Error ? err.message : err}`)
failed++
}
}
log(`[nfo] Dossier GitLab synchronisé pour "${serieTitle}"`)
log(`NFO "${gitlabTitle}" — ${downloaded} téléchargés, ${skipped} déjà présents${failed > 0 ? `, ${failed} échecs` : ''}`)
}
// ─── Filesystem ops ───────────────────────────────────────────
function swapExtension(filename: string, ext: string): string {
const currentExt = path.extname(filename)
if (!currentExt || currentExt === ext) return filename
return filename.slice(0, -currentExt.length) + ext
}
function seasonFolder(n: number): string {
return `Saison ${n}`
return n === 0 ? 'Specials' : `Saison ${n}`
}
/**
* Supprime un fichier avec retry en cas d'EBUSY (Windows : fichier encore ouvert par qBit/autre).
* 5 tentatives × 500 ms = 2,5 s max avant de remonter l'erreur.
*/
async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
await fsp.unlink(filePath)
return
} catch (err: any) {
if (err?.code === 'EBUSY' && attempt < maxAttempts - 1) {
warn(`EBUSY sur "${path.basename(filePath)}" — retry ${attempt + 1}/${maxAttempts - 1}`)
await new Promise(r => setTimeout(r, 500))
} else {
throw err
}
}
}
}
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
catch (err) {
warn(`[organize] hardlink échoué (${err instanceof Error ? err.message : err})`)
debug(`Hardlink échoué (${err instanceof Error ? err.message : err}), fallback copie`)
return false
}
}
// ─── Organise un torrent ──────────────────────────────────────
function sanitizeDirName(name: string): string {
return name
.replace(/:/g, ' -')
.replace(/[<>"/\\|?*]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
async function organizeTorrent(hash: string, name: string, savePath: string) {
// ─── Organise un torrent ──────────────────────────────────────
async function organizeTorrent(
hash : string,
name : string,
savePath : string,
seriesData : any[],
completedFileNames: Set<string> | null = null,
) {
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
log(`[organize] ── ${name} ──`)
log(`[organize] hash=${hash} save_path=${savePath} mode=${organizeMode}`)
const torrent = findTorrentByHash(hash)
if (!torrent) {
error(`[organize] ✗ Hash ${hash} introuvable dans torrent_final.json`)
const found = findTorrentByHash(hash, seriesData)
if (!found) {
error(`Hash ${hash} introuvable dans les données série`)
throw new Error(`Torrent introuvable : ${hash}`)
}
log(`[organize] ✓ Trouvé : ${torrent.raw}`)
const serieTitle = torrent.serie_title || torrent.show_title || name
const torrentFiles: any[] = torrent.torrent_files ?? []
log(`[organize] serie=${serieTitle} fichiers=${torrentFiles.length}`)
const { torrent, serieData } = found
const rawTitle = serieData.title ?? serieData.show_title ?? name
const serieTitle = sanitizeDirName(rawTitle)
debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
// ── Téléchargement NFO/images depuis GitLab ───────────────
if (nfoSupport) {
const serieDestRoot = path.join(mediaPath, serieTitle)
await downloadGitlabFolder(serieTitle, serieDestRoot)
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
}
// ── Cas fichier unique ────────────────────────────────────
if (torrentFiles.length === 0) {
log(`[organize] torrent_files vide → tentative fichier unique`)
const candidates = [path.join(savePath, name), path.join(completePath, name)]
let src: string | null = null
for (const c of candidates) {
if (fs.existsSync(c)) log(`[organize] ✓ source trouvée: ${c}`)
else debug(`[organize] candidate absent: ${c}`)
if (fs.existsSync(c)) { src = c; break }
}
if (!src) throw new Error(`Source introuvable pour "${name}"`)
const seasonNum = torrent.resolved_episodes?.[0]?.season_number ?? 1
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, name)
log(`[organize] dest: ${dest}`)
if (!fs.existsSync(dest)) {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
} else {
await fsp.copyFile(src, dest)
await fsp.unlink(src)
// ── Cas torrent épisode unique ────────────────────────────
if (torrent._episode) {
const ep = torrent._episode
const season = torrent._season
const paths: any[] = ep.paths ?? []
let filePath : string | null = null
let matchedPathObj: any = null
for (const p of paths) {
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) {
filePath = p.path.replace(/\\/g, '/')
matchedPathObj = p
break
}
log(`[organize] ✓ ${organizeMode} OK`)
} else {
log(`[organize] → dest existe déjà, skip`)
}
markOrganized(hash, name)
return { ...result, total: 1, done: 1 }
}
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
const fmtName = rawFmt?.trim()
? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: null
// ── Map filename → season ─────────────────────────────────
const filenameSeasonMap = new Map<string, number>()
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.filename && ep.season_number !== undefined)
filenameSeasonMap.set(ep.filename, ep.season_number)
}
const srcExt = path.extname(filename)
const resolvedName = nfoSupport
? (rawNfo ?? rawOriginal ?? filename)
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
const destName = swapExtension(resolvedName, srcExt)
result.total = torrentFiles.length
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
const dest = path.join(destDir, destName)
for (const tf of torrentFiles) {
const filename = tf.filename
const filePath: string[] = tf.path
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
// Skip les fichiers dans des dossiers bonus/musique/images
if (isInExcludedFolder(filePath)) {
log(`[organize] skip bonus: ${filename}`)
result.skipped++
continue
// Vérification de complétion pour épisode unique
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
debug(`Fichier incomplet (skip) : ${filename}`)
return { ...result, total: 1, skipped: 1 }
}
if (isOrganized(hash, filename)) { result.skipped++; continue }
const candidates = [
path.join(savePath, name, ...filePath.slice(0, -1), filename),
path.join(savePath, ...filePath.slice(0, -1), filename),
path.join(savePath, name, filename),
const candidates = filePath ? [
path.join(savePath, filePath),
path.join(completePath, filePath),
path.join(savePath, filename),
path.join(completePath, filename),
] : [
path.join(savePath, filename),
path.join(completePath, name, ...filePath.slice(0, -1), filename),
path.join(completePath, name, filename),
path.join(completePath, filename),
]
let src: string | null = null
for (const c of candidates) {
if (fs.existsSync(c)) { src = c; break }
}
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
const tried = candidates.map(c => ` • ${c}`).join('\n')
error(`[organize] ✗ ${filename} introuvable:\n${tried}`)
result.errors.push({ file: filename, error: 'Source introuvable' })
error(`Fichier source introuvable : ${filename}`)
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] }
}
if (!fs.existsSync(dest)) {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'copy') {
await fsp.copyFile(src, dest)
} else if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
} else {
await fsp.copyFile(src, dest)
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,
})
await unlinkWithRetry(src)
log(`${destName} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 }
}
}
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,
})
log(`${destName} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 }
}
// ── Pack saison / 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}"`)
return result
}
result.total = fileMap.size
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}`)
result.skipped++
continue
}
const seasonNum = filenameSeasonMap.get(filename) ?? 1
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, filename)
const candidates = [
path.join(savePath, fullPath),
path.join(completePath, fullPath),
path.join(savePath, filename),
path.join(completePath, filename),
]
let src: string | null = null
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}`)
result.skipped++
continue
}
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number))
const dest = path.join(destDir, nfo_filename)
if (fs.existsSync(dest)) {
markOrganized(hash, filename)
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,
})
result.skipped++
continue
}
try {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'hardlink') {
if (organizeMode === 'copy') {
await fsp.copyFile(src, dest)
} 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,
})
} else {
await fsp.copyFile(src, dest)
await fsp.unlink(src)
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,
})
await unlinkWithRetry(src)
}
markOrganized(hash, filename)
result.done++
log(`[organize] ✓ ${filename} → Saison ${String(seasonNum).padStart(3,'0')}`)
debug(`${nfo_filename} → Saison ${season_number}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
error(`[organize] ✗ ${filename}: ${msg}`)
error(`Échec import "${filename}" : ${msg}`)
result.errors.push({ file: filename, error: msg })
}
}
log(`[organize] ── Résultat ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
return result
}
// ─── Boucle principale du worker ─────────────────────────────
// ─── Boucle principale ────────────────────────────────────────
parentPort?.on('message', async (msg: any) => {
if (msg.type !== 'run') return
const torrents: any[] = msg.torrents
const organized = loadOrganized()
const torrents: any[] = msg.torrents
const seriesData: any[] = msg.seriesData ?? []
const organized = loadOrganized()
const { nfoSupport } = readSettings()
for (const t of torrents) {
if (t.state !== 'seeding') continue
const torrent = findTorrentByHash(t.hash)
if (!torrent) {
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé`)
const found = findTorrentByHash(t.hash, seriesData)
if (!found) {
debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
continue
}
const files: any[] = torrent.torrent_files ?? []
// Pour fichier unique : vérifier via le name directement
const allDone = files.length > 0
? files.every((f: any) => organized[t.hash]?.[f.filename])
: !!organized[t.hash]?.[t.name]
const { torrent, serieData } = found
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
const orgHash = organized[t.hash] ?? {}
const allDone = fileMap.size > 0
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
: Object.keys(orgHash).length > 0
if (allDone) continue
if (allDone) {
debug(`"${t.name}" déjà entièrement importé — skip`)
continue
}
try {
const result = await organizeTorrent(t.hash, t.name, t.save_path)
// Construire l'ensemble des fichiers 100% complets à partir des infos de progression.
// On ne filtre QUE si le client fournit des noms de fichiers (qBittorrent, Transmission).
// Si aucun fichier n'a de nom (rtorrent…), on passe null → pas de filtrage.
let completedFileNames: Set<string> | null = null
if (Array.isArray(t.files) && t.files.length > 0) {
// Vérifier qu'au moins un fichier a un nom non vide
const hasNames = t.files.some((f: any) => f.name && String(f.name).trim())
if (hasNames) {
completedFileNames = new Set(
t.files
.filter((f: any) => (f.progress ?? 0) >= 1 && f.name && String(f.name).trim())
.map((f: any) => {
// f.name est un chemin relatif ex: "Pack S1/Episode1.mkv"
// on ne garde que le basename pour comparer au filename du fileMap
const parts = String(f.name).replace(/\\/g, '/').split('/')
return (parts[parts.length - 1] ?? '').toLowerCase()
})
.filter(Boolean)
)
}
}
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
} catch (err) {
error(`[organize] Erreur ${t.name}: ${err instanceof Error ? err.message : err}`)
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
}
}
+446 -92
View File
@@ -2,16 +2,26 @@
* organize.ts
* ===========
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
* Le worker gère tout le filesystem (copy, hardlink, move).
*/
import fs from 'fs'
import path from 'path'
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'
// Un seul worker à la fois
let workerRunning = false
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
export let workerRunning = false
const _prevStates = new Map<string, string>()
function resolveWorkerPath(): string {
if (typeof (globalThis as any).Bun !== 'undefined') {
return path.join(path.dirname((process as any).execPath), 'organize-worker.js')
}
return path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
}
export interface OrganizeResult {
total : number
@@ -20,55 +30,108 @@ export interface OrganizeResult {
errors : { file: string; error: string }[]
}
/**
* Scan initial du mediaPath au démarrage.
* Parcourt récursivement le dossier Jellyfin et marque dans organized.json
* tous les fichiers déjà présents qui correspondent à un torrent connu.
*/
interface OrgEntry {
at : string
season : number
episode : number
episode_id : number
src_filename : string
dest_filename: string
dest_path : string
}
type Organized = Record<string, Record<string, OrgEntry>>
// ── Scan initial ──────────────────────────────────────────────
function swapExt(filename: string, ext: string): string {
const cur = path.extname(filename)
if (!cur || cur === ext) return filename
return filename.slice(0, -cur.length) + ext
}
export async function scanMediaPath(
mediaPath : string,
torrentsPath: string,
organizedPath: string
mediaPath : string,
organizedPath: string,
seriesData : any[] = []
): Promise<{ found: number; added: number }> {
const result = { found: 0, added: 0 }
if (!fs.existsSync(mediaPath)) {
logger.warn('organize', `scanMediaPath: ${mediaPath} inexistant, skip`)
logger.warn('organize', `Dossier médiathèque introuvable : ${mediaPath}`)
return result
}
// Charger torrent_final.json
let torrents: any[] = []
try {
if (!fs.existsSync(torrentsPath)) return result
torrents = JSON.parse(fs.readFileSync(torrentsPath, 'utf-8'))
} catch {
logger.error('organize', 'scanMediaPath: impossible de lire torrent_final.json')
return result
}
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
// Construire un index filename → { hash, infohash }
const filenameIndex = new Map<string, string>() // filename → infohash
for (const t of torrents) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
for (const f of t.torrent_files ?? []) {
if (f.filename) filenameIndex.set(f.filename, hash)
}
// Fichier unique (torrent_files vide) → on indexe par raw name
if ((t.torrent_files ?? []).length === 0 && t.raw) {
filenameIndex.set(t.raw, hash)
// 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
srcFilename : string
episodeId : number
season : number
episode : number
destFilename: string
}>()
for (const sd of seriesData) {
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
filenameIndex.set(base, {
hash,
srcFilename : filename,
episodeId : ep.id,
season : season.season_number,
episode : ep.episode_number,
destFilename: destFilename ?? filename,
})
}
// 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
if (pathFmt?.trim()) {
const fmtBase = pathFmt.replace(/[<>:"/\\|?*]/g, '').trim()
idx(fmtBase, hash, fmtBase)
}
}
// 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)
if (ep.formatted_name?.trim()) {
const fmtBase = ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim()
idx(fmtBase, fallbackHash, fmtBase)
}
}
}
}
// Charger organized.json existant
let organized: Record<string, Record<string, string>> = {}
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch {}
// Parcourir récursivement mediaPath
// Track les paires hash:episodeId présentes
const presentFiles = new Set<string>()
let noMatch = 0
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
@@ -77,42 +140,110 @@ export async function scanMediaPath(
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full)
} else if (entry.isFile() && entry.name.endsWith('.mkv')) {
} else if (entry.isFile() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) {
result.found++
const hash = filenameIndex.get(entry.name)
if (!hash) continue
if (organized[hash]?.[entry.name]) continue // déjà connu
// Marquer comme organisé
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"
if (!match) {
const lower = nameWithoutExt.toLowerCase()
for (const [key, val] of filenameIndex.entries()) {
if (lower.startsWith(key.toLowerCase() + '.') || lower === key.toLowerCase()) {
match = val
break
}
}
}
if (!match) {
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}`)
continue
}
const { hash, srcFilename, episodeId, season, episode } = match
presentFiles.add(`${hash}:${episodeId}`)
if (organized[hash]?.[String(episodeId)]) continue
if (!organized[hash]) organized[hash] = {}
organized[hash][entry.name] = new Date().toISOString()
organized[hash][String(episodeId)] = {
at : new Date().toISOString(),
season,
episode,
episode_id : episodeId,
src_filename : srcFilename,
dest_filename: entry.name, // vrai nom sur disque
dest_path : full,
}
result.added++
logger.debug('organize', `Scan match : "${entry.name}" → ep ${episodeId} (S${season}E${episode})`)
}
}
}
walk(mediaPath)
if (result.added > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés à organized.json`)
} else {
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
// 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}`))
let removed = 0
for (const [hash, episodes] of Object.entries(organized)) {
for (const episodeId of Object.keys(episodes)) {
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
delete organized[hash][episodeId]
removed++
}
}
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
// Désimport automatique des fichiers manquants sur le disque
const { autoUnimportMissing } = readSettings() as any
let autoRemoved = 0
if (autoUnimportMissing) {
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
const destPath = entry?.dest_path
if (destPath && !fs.existsSync(destPath)) {
delete (organized[hash] as any)[episodeId]
autoRemoved++
logger.info('organize', `Désimport auto : fichier introuvable "${destPath}"`)
}
}
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
}
if (result.added > 0 || removed > 0 || autoRemoved > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
}
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
return result
}
/**
* Lance l'auto-organisation dans un worker thread.
* listFn est appelée dans le thread principal (accès qBittorrent),
* puis les données sont passées au worker.
*/
// ── Auto-import ───────────────────────────────────────────────
export async function autoOrganizeAll(
listFn : () => Promise<any[]>,
onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
listFn : () => Promise<any[]>,
seriesData : any[],
onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
): Promise<void> {
const { autoImport } = readSettings()
if (!autoImport) {
logger.debug('organize', 'Import automatique désactivé — skip')
return
}
if (workerRunning) {
logger.debug('organize', 'Worker déjà en cours, skip')
logger.debug('organize', 'Worker déjà en cours — skip')
return
}
@@ -120,34 +251,68 @@ export async function autoOrganizeAll(
try {
torrents = await listFn()
} catch (err) {
logger.error('organize', "Impossible de récupérer la liste des torrents: " + (err instanceof Error ? err.message : String(err)))
logger.error('organize', `Impossible de récupérer la liste des torrents : ${err instanceof Error ? err.message : err}`)
return
}
if (torrents.length === 0) return
const isFirstRun = _prevStates.size === 0
const seedingCount = torrents.filter(t => t.state === 'seeding').length
const newlySeeding = torrents.filter(t => {
const prev = _prevStates.get(t.hash)
return t.state === 'seeding' && (prev === undefined || prev !== 'seeding')
})
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 {}
const hasUnorganized = torrents.some(t =>
t.state === 'seeding' && !organized[t.hash?.toLowerCase()]
)
if (!isFirstRun && newlySeeding.length === 0 && !hasUnorganized) return
if (seedingCount === 0) return
logger.info('organize', `Lancement worker (${seedingCount} torrents en seeding)`)
if (newlySeeding.length > 0 && !isFirstRun) {
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}"`)
}
} else {
logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
}
workerRunning = true
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
const worker = new Worker(workerPath)
const worker = new Worker(resolveWorkerPath())
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'debug'|'info'|'warn'|'error']('organize', msg.msg)
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'result') {
logger.info('organize', `${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
if (msg.errors.length > 0) {
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) {
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)`)
} else {
logger.debug('organize', `"${msg.name}" — rien à importer (${msg.skipped} skippé(s))`)
}
onResult?.({
hash : msg.hash,
name : msg.name,
done : msg.done,
skipped : msg.skipped,
errors : msg.errors.length,
errorFiles : msg.errors,
hash : msg.hash,
name : msg.name,
done : msg.done,
skipped : msg.skipped,
errors : msg.errors.length,
errorFiles: msg.errors,
})
} else if (msg.type === 'done') {
logger.debug('organize', 'Worker terminé')
@@ -157,53 +322,242 @@ export async function autoOrganizeAll(
})
worker.on('error', (err) => {
logger.error('organize', `Worker erreur: ${err instanceof Error ? err.message : err}`)
logger.error('organize', `Worker erreur : ${err.message}`)
workerRunning = false
})
worker.on('exit', (code) => {
if (code !== 0) logger.error('organize', `Worker exit code ${code}`)
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
else logger.debug('organize', `Worker terminé (code ${code})`)
workerRunning = false
})
// Envoyer les torrents au worker
worker.postMessage({ type: 'run', torrents })
worker.postMessage({ type: 'run', torrents, seriesData })
}
/**
* Organisation manuelle d'un torrent (appelée depuis l'API /organize).
* Lance aussi dans un worker thread.
*/
export async function organizeTorrent(
hash : string,
name : string,
savePath: string
): Promise<OrganizeResult> {
return new Promise((resolve, reject) => {
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
const worker = new Worker(workerPath)
// ── 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.
// On envoie un seul torrent "fictif seeding"
export async function migrateOrganizedEpisodeIds(
organizedPath: string,
seriesData : any[]
): Promise<{ updated: number; orphaned: number }> {
// 1. Construire les tables de lookup depuis le catalogue actuel
// hashEpMap : infohash → Map<"S:E", new_episode_id>
const hashEpMap = new Map<string, Map<string, number>>()
const validIds = new Set<number>()
for (const sd of seriesData) {
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
validIds.add(ep.id)
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const h = p.infohash.toLowerCase()
const key = `${season.season_number}:${ep.episode_number}`
if (!hashEpMap.has(h)) hashEpMap.set(h, new Map())
hashEpMap.get(h)!.set(key, ep.id)
}
}
}
}
// 2. Charger organized.json
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch { return { updated: 0, orphaned: 0 } }
let updated = 0
let orphaned = 0
let changed = false
for (const [hash, episodes] of Object.entries(organized)) {
if (hash === 'manual') continue
const epMap = hashEpMap.get(hash.toLowerCase())
const newEpisodes: Record<string, any> = {}
for (const [oldIdStr, entry] of Object.entries(episodes as Record<string, any>)) {
const oldId = Number(oldIdStr)
if (validIds.has(oldId)) {
// ID encore valide, aucun changement
newEpisodes[oldIdStr] = entry
continue
}
// ID orphelin — chercher le nouvel ID via (hash, saison, épisode)
const key = `${entry.season}:${entry.episode}`
const newId = epMap?.get(key)
if (newId && newId !== oldId) {
// Vérifier qu'on n'écrase pas une entrée déjà présente avec le nouvel ID
if (newEpisodes[String(newId)] || (episodes as any)[String(newId)]) {
// Le nouvel ID est déjà présent → l'ancien est un orphelin inutile, on le supprime simplement
changed = true
logger.info('organize', `Migration ID : ep ${oldId} orphelin supprimé (${newId} déjà présent) (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
} else {
newEpisodes[String(newId)] = { ...entry, episode_id: newId }
updated++
changed = true
logger.info('organize', `Migration ID : ep ${oldId} → ${newId} (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
}
} else {
// Introuvable — on conserve pour ne pas perdre l'entrée
orphaned++
newEpisodes[oldIdStr] = entry
logger.warn('organize', `Migration ID : ep ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
}
}
organized[hash] = newEpisodes
// Nettoyer les hash vides
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
if (changed) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`)
} else {
logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`)
}
return { updated, orphaned }
}
// ── Sync des noms de fichiers après mise à jour du scraper ────
export async function syncFilenameChanges(
seriesData : any[],
organizedPath: string,
): Promise<{ renamed: number; errors: number }> {
const { nfoSupport } = readSettings()
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch { return { renamed: 0, errors: 0 } }
let renamed = 0
let errors = 0
let changed = false
for (const sd of seriesData) {
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
let orgHash: string | null = null
for (const [hash, eps] of Object.entries(organized)) {
if ((eps as any)[String(ep.id)]) {
orgEntry = (eps as any)[String(ep.id)]
orgHash = hash
break
}
}
if (!orgEntry || !orgHash) continue
const currentName = orgEntry.dest_path
? path.basename(orgEntry.dest_path)
: orgEntry.dest_filename
const srcExt = path.extname(currentName)
// Priorité aux valeurs du path entry correspondant au hash organisé
const matchedPathEntry = (ep.paths ?? []).find((p: any) =>
typeof p === 'object' && p.infohash?.toLowerCase() === orgHash
)
const rawFmt = matchedPathEntry?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathEntry?.nfo_filename ?? ep.nfo_filename
const expectedName: string = nfoSupport
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
if (expectedName === currentName) continue
const oldPath = orgEntry.dest_path
if (!oldPath || !fs.existsSync(oldPath)) continue
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (fs.existsSync(newPath)) {
logger.warn('organize', `Sync rename : "${expectedName}" existe déjà — skip`)
continue
}
fs.renameSync(oldPath, newPath)
;(organized[orgHash] as any)[String(ep.id)] = {
...orgEntry,
dest_filename: expectedName,
dest_path : newPath,
at : new Date().toISOString(),
}
renamed++
changed = true
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
} catch (err) {
errors++
logger.error('organize', `Sync rename échoué pour ep ${ep.id} : ${err instanceof Error ? err.message : err}`)
}
}
}
}
if (changed)
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
if (renamed > 0 || errors > 0)
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
return { renamed, errors }
}
// ── Import manuel ─────────────────────────────────────────────
export async function organizeTorrent(
hash : string,
name : string,
savePath : string,
seriesData: any[]
): Promise<OrganizeResult> {
logger.info('organize', `Import manuel 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: [] }
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'debug'|'info'|'warn'|'error']('organize', msg.msg)
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'result') {
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)`)
for (const e of msg.errors) {
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)`)
}
} else if (msg.type === 'done') {
worker.terminate()
resolve(result)
}
})
worker.on('error', (err) => { worker.terminate(); reject(err) })
worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) })
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] })
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
})
}
+73
View File
@@ -0,0 +1,73 @@
import { Router } from 'express'
import path from 'path'
import fs from 'fs'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { dispatchDownload, dispatchList, dispatchRemove } from '../torrent-clients/index.js'
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
import { extractTorrentsFromSerieData, buildResolvedEpisodes } from '../lib/serie-helpers.js'
import { recentOrganized } from '../lib/notifs.js'
const router = Router()
router.post('/download', requireAuth, async (req, res) => {
// Préférer le fichier .torrent au magnet : métadonnées immédiates, pas de dépendance DHT/trackers
const torrent_url: string | null = req.body.torrent_url ?? null
const magnet : string | null = req.body.magnet ?? null
const url = torrent_url ?? magnet
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
const file_index: number | null = req.body.file_index != null ? Number(req.body.file_index) : null
const file_path : string | null = req.body.file_path ?? null
const infohash : string | null = req.body.infohash ?? null
logger.info('api', `Téléchargement demandé via ${torrent_url ? '.torrent' : 'magnet'}${file_path ? ` (${file_path})` : ''}`)
const results = await dispatchDownload(url, { file_index, file_path, infohash, magnet })
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
})
router.delete('/torrent/:hash', requireAuth, async (req, res) => {
const hash = String(req.params.hash)
const deleteFiles = req.query.deleteFiles === 'true'
if (!hash) { res.status(400).json({ error: 'hash requis' }); return }
try {
const result = await dispatchRemove(hash, deleteFiles)
res.json(result)
} catch (err) {
logger.error('api', `Suppression torrent échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
router.get('/downloads', requireAuth, async (_req, res) => {
try {
const { category } = readSettings()
const infohashMap = await readInfohashMap()
const torrents = await dispatchList(category ?? 'fankai', infohashMap)
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const enriched = await Promise.all(torrents.map(async (t: any) => {
const orgFiles = organized[t.hash] ?? {}
let totalFiles = 1
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 }
}
} 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 ?? [] }
}))
res.json(enriched)
} catch (err) {
logger.error('api', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
export default router
+451
View File
@@ -0,0 +1,451 @@
import { Router } from 'express'
import path from 'path'
import fs from 'fs'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { getGitlabTitle } from '../gitlab-map.js'
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js'
import { resolveEpNaming, computeExpectedName } from '../lib/serie-helpers.js'
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
import { dispatchRemove } from '../torrent-clients/index.js'
const router = Router()
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
function loadOrganizedJson(): Record<string, Record<string, any>> {
try { if (fs.existsSync(ORGANIZED_PATH)) return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
return {}
}
function saveOrganizedJson(data: Record<string, Record<string, any>>) {
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
// ── Import manuel ──────────────────────────────────────────────
router.post('/manual-import', requireAuth, async (req, res) => {
const { serie_id, items } = req.body
if (!serie_id || !Array.isArray(items) || items.length === 0) {
res.status(400).json({ error: 'serie_id et items requis' }); return
}
const { mediaPath, organizeMode, nfoSupport } = readSettings()
const organized = loadOrganizedJson()
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 episodeIndex = new Map<number, { ep: any; season: any }>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
episodeIndex.set(ep.id, { ep, season })
}
}
const done: number[] = []
const errors: { file: string; error: string }[] = []
for (const item of items) {
const { file_path, episode_id, hash } = item
if (!file_path || !episode_id) { errors.push({ file: file_path ?? '?', error: 'Paramètres manquants' }); continue }
const found = episodeIndex.get(Number(episode_id))
if (!found) { errors.push({ file: file_path, error: `Épisode ${episode_id} introuvable` }); continue }
const { ep, season } = found
const srcFilename = path.basename(file_path)
const srcExt = path.extname(srcFilename)
const { formatted_name: rawFmt, nfo_filename: rawNfo, original_filename: rawOrig } = resolveEpNaming(ep, hash)
const fmtName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null
const resolvedName = nfoSupport
? (rawNfo ?? rawOrig ?? srcFilename)
: (fmtName ?? rawNfo ?? rawOrig ?? srcFilename)
const resolvedExt = path.extname(resolvedName)
const destFilename = resolvedExt && resolvedExt !== srcExt
? resolvedName.slice(0, -resolvedExt.length) + srcExt
: resolvedName
const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}`
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
const destPath = path.join(destDir, destFilename)
try {
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
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)) {
fs.renameSync(file_path, destPath)
logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
} else if (!isInSeriePath && !fs.existsSync(destPath)) {
if (organizeMode === 'hardlink') {
try { fs.linkSync(file_path, destPath) }
catch { await fs.promises.copyFile(file_path, destPath) }
} else if (organizeMode === 'move') {
try { fs.renameSync(file_path, destPath) }
catch { await fs.promises.copyFile(file_path, destPath); await fs.promises.unlink(file_path) }
} else {
await fs.promises.copyFile(file_path, destPath)
}
logger.info('api', `Import manuel : "${srcFilename}" → "${destPath}"`)
}
} else {
logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`)
}
const torrentHash = String(hash || '').toLowerCase() || 'manual'
if (!organized[torrentHash]) organized[torrentHash] = {}
organized[torrentHash][String(episode_id)] = {
at: new Date().toISOString(), season: season.season_number,
episode: ep.episode_number, episode_id: ep.id,
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
}
done.push(episode_id)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
errors.push({ file: srcFilename, error: msg })
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
}
}
saveOrganizedJson(organized)
if (nfoSupport && done.length > 0) {
const gitlabTitle = getGitlabTitle(serieTitle)
;(async () => {
try {
let files: any[] = [], page = 1
while (true) {
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
if (!Array.isArray(batch) || batch.length === 0) break
files.push(...batch); if (batch.length < 100) break; page++
}
for (const entry of files.filter((f: any) => f.type === 'blob')) {
const rel = entry.path.replace(`pack/${gitlabTitle}/`, '')
const dest = path.join(mediaPath, serieTitle, rel)
if (fs.existsSync(dest)) continue
const raw = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${rel.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
if (!raw.ok) continue
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.writeFileSync(dest, Buffer.from(await raw.arrayBuffer()))
}
logger.info('api', `NFO téléchargés pour "${gitlabTitle}" (import manuel)`)
} catch (err) {
logger.warn('api', `Échec téléchargement NFO pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
}
})()
}
res.json({ ok: true, done: done.length, errors })
})
// ── Récap organisés d'une série ────────────────────────────────
router.get('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId)
try {
const sd = await readSerieData(serieId)
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const organized = loadOrganizedJson()
const { nfoSupport } = readSettings()
const result: Record<string, any> = {}
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let entry: any = null
let entryHash = 'manual'
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const h = p.infohash.toLowerCase()
const e = (organized[h] ?? {})[String(ep.id)]
if (e) { entry = e; entryHash = h; break }
}
if (!entry && organized['manual']?.[String(ep.id)]) {
entry = organized['manual'][String(ep.id)]
}
if (!entry) continue
const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport)
result[String(ep.id)] = { ...entry, hash: entryHash, needs_rename: needsRename }
}
}
res.json(result)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Rename épisode ─────────────────────────────────────────────
router.post('/rename-episode', requireAuth, async (req, res) => {
const { serie_id, episode_id, torrent_hash } = req.body
if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return }
const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serie_id))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
let foundEp: any = null
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (ep.id === Number(episode_id)) { foundEp = { ep, season }; break }
}
if (foundEp) break
}
if (!foundEp) { res.status(404).json({ error: 'Épisode introuvable' }); return }
const { ep, season } = foundEp
const hash = torrent_hash?.toLowerCase() ?? 'manual'
let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
if (!orgEntry) {
for (const [h, eps] of Object.entries(organized)) {
if (eps[String(episode_id)]) { orgEntry = eps[String(episode_id)]; entryHash = h; break }
}
}
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
const { currentName, expectedName: newName, needsRename } = computeExpectedName(ep, orgEntry, entryHash, nfoSupport)
if (!needsRename) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); return }
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), newName)
try {
if (!fs.existsSync(oldPath)) throw new Error('Fichier source introuvable sur le disque')
if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
fs.renameSync(oldPath, newPath)
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
saveOrganizedJson(organized)
logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Rename échoué pour ep ${episode_id} : ${msg}`)
res.status(500).json({ error: msg })
}
})
// ── Désimport série complète ───────────────────────────────────
router.delete('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const episodeIds = new Set<string>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id))
}
let removed = 0
const errors: string[] = []
const emptyHashes: string[] = []
for (const [hash, episodes] of Object.entries(organized)) {
for (const epId of Object.keys(episodes)) {
if (!episodeIds.has(epId)) continue
const entry = episodes[epId]
if (deleteFile && entry?.dest_path && fs.existsSync(entry.dest_path)) {
try { fs.unlinkSync(entry.dest_path) } catch { errors.push(entry.dest_path) }
}
delete organized[hash][epId]
removed++
}
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
}
saveOrganizedJson(organized)
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
}
logger.info('api', `Désimport série ${serieId} — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
res.json({ ok: true, removed, errors })
})
// ── Désimport saison ───────────────────────────────────────────
router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const seasonId = Number(req.params.seasonId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const season = sd.seasons?.find((s: any) => s.id === seasonId)
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? [])
let removed = 0
const errors: string[] = []
const emptyHashes: string[] = []
for (const [hash, episodes] of Object.entries(organized)) {
for (const epId of Object.keys(episodes)) {
if (!episodeIds.has(epId)) continue
const entry = episodes[epId]
if (deleteFile && entry?.dest_path && fs.existsSync(entry.dest_path)) {
try { fs.unlinkSync(entry.dest_path) } catch { errors.push(entry.dest_path) }
}
delete organized[hash][epId]
removed++
}
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
}
saveOrganizedJson(organized)
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
}
logger.info('api', `Désimport saison ${seasonId} (série ${serieId}) — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
res.json({ ok: true, removed, errors })
})
// ── Désimport épisode ──────────────────────────────────────────
router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const episodeId = String(req.params.episodeId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
let foundHash: string | null = null
let foundEntry: any = null
for (const [hash, episodes] of Object.entries(organized)) {
if (episodes[episodeId]) { foundHash = hash; foundEntry = episodes[episodeId]; break }
}
if (!foundHash || !foundEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
try {
if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) {
fs.unlinkSync(foundEntry.dest_path)
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`)
}
delete organized[foundHash][episodeId]
const torrentEmpty = Object.keys(organized[foundHash]).length === 0
if (torrentEmpty) delete organized[foundHash]
saveOrganizedJson(organized)
// Si plus aucun épisode de ce torrent n'est importé → retirer le torrent du client
if (deleteFile && torrentEmpty && foundHash !== 'manual') {
dispatchRemove(foundHash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${foundHash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
logger.info('api', `Désimport ep ${episodeId}${deleteFile && torrentEmpty ? ` + torrent ${foundHash.slice(0, 8)}… retiré du client` : ''}`)
res.json({ ok: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Désimport échoué : ${msg}`)
res.status(500).json({ error: msg })
}
})
// ── Récap global des imports ───────────────────────────────────
router.get('/organized-summary', requireAuth, async (_req, res) => {
try {
const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const seriesData = await loadEnrichedSeriesData()
const result: any[] = []
for (const sd of seriesData) {
const rawTitle = sd.title ?? sd.show_title ?? ''
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
const episodes: any[] = []
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
let orgHash: string | null = null
// Même stratégie que organized/:serieId : on cherche via ep.paths d'abord
// pour garantir le bon hash → bon formatted_name dans resolveEpNaming
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const h = p.infohash.toLowerCase()
const e = (organized[h] ?? {})[String(ep.id)]
if (e) { orgEntry = e; orgHash = h; break }
}
if (!orgEntry && organized['manual']?.[String(ep.id)]) {
orgEntry = organized['manual'][String(ep.id)]; orgHash = 'manual'
}
if (!orgEntry) continue
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
episodes.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, title: ep.title, current_name: currentName, expected_name: expectedName, dest_path: orgEntry.dest_path, torrent_hash: orgHash, needs_rename: needsRename, file_exists: fileExists })
}
}
if (episodes.length > 0) {
result.push({ serie_id: sd.id, serie_title: rawTitle, serie_title_clean: serieTitle, total: episodes.length, needs_rename: episodes.filter(e => e.needs_rename).length, episodes })
}
}
res.json({ series: result, nfo_support: nfoSupport })
} catch (err) {
logger.error('api', `organized-summary échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Rename en masse ────────────────────────────────────────────
router.post('/rename-all', requireAuth, async (req, res) => {
const { serie_id } = req.body
const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const seriesData = await loadEnrichedSeriesData()
const done: number[] = []
const errors: { episode_id: number; error: string }[] = []
for (const sd of seriesData) {
if (serie_id && sd.id !== Number(serie_id)) continue
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
let orgHash: string | null = null
for (const [hash, eps] of Object.entries(organized)) {
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
}
if (!orgEntry || !orgHash) continue
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
if (!needsRename) continue
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (!fs.existsSync(oldPath)) {
const msg = `Fichier introuvable : "${oldPath}"`
logger.error('api', `Rename masse ep ${ep.id} — ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue
}
if (fs.existsSync(newPath)) {
const msg = `Fichier existant : "${expectedName}"`
logger.warn('api', `Rename masse ep ${ep.id} — ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue
}
fs.renameSync(oldPath, newPath)
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
done.push(ep.id)
logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Rename masse ep ${ep.id} — exception : ${msg}`)
errors.push({ episode_id: ep.id, error: msg })
}
}
}
}
saveOrganizedJson(organized)
res.json({ ok: true, done: done.length, errors })
})
// ── Purge NFO / images des dossiers série ─────────────────────
router.post('/purge-nfo', requireAuth, (req, res) => {
const { mediaPath } = readSettings()
if (!mediaPath || !fs.existsSync(mediaPath)) {
res.status(400).json({ error: 'Chemin médiathèque non configuré ou introuvable' }); return
}
const NFO_EXTS = new Set(['.nfo', '.png', '.jpg', '.jpeg', '.tbn', '.xml'])
let deleted = 0
const errors: string[] = []
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full)
} else if (entry.isFile() && NFO_EXTS.has(path.extname(entry.name).toLowerCase())) {
try {
fs.unlinkSync(full)
deleted++
logger.debug('api', `Purge NFO : supprimé "${full}"`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Purge NFO : impossible de supprimer "${full}" : ${msg}`)
errors.push(full)
}
}
}
}
walk(mediaPath)
logger.info('api', `Purge NFO — ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
res.json({ ok: true, deleted, errors })
})
export default router
+18
View File
@@ -0,0 +1,18 @@
import { Router } from 'express'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { checkNfoUpdates, recentNfoUpdates } from '../lib/nfo.js'
const router = Router()
router.get('/nfo-updates/recent', requireAuth, (_req, res) => {
res.json(recentNfoUpdates)
})
router.post('/nfo-updates/check', requireAuth, async (_req, res) => {
logger.info('nfo-update', 'Vérification manuelle des MAJ NFO lancée')
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif manuelle échouée : ${err instanceof Error ? err.message : err}`))
res.json({ ok: true, message: 'Vérification lancée en arrière-plan' })
})
export default router
+50
View File
@@ -0,0 +1,50 @@
import { Router } from 'express'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
import { recentOrganized, pushNotif } from '../lib/notifs.js'
import { DATA_DIR } from '../config.js'
import path from 'path'
const router = Router()
router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
try {
const seriesData = await loadEnrichedSeriesData()
const organizedPath = path.join(DATA_DIR, 'organized.json')
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
res.json({ ok: true, ...result })
} catch (err) {
logger.error('api', `Migration IDs échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
router.get('/organize/recent', requireAuth, (_req, res) => {
res.json(recentOrganized)
})
router.post('/organize/recent/clear', requireAuth, (_req, res) => {
recentOrganized.length = 0
logger.info('api', 'Historique des imports effacé')
res.json({ ok: true })
})
router.post('/organize', requireAuth, async (req, res) => {
const { hash, name, save_path } = req.body
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
try {
const seriesData = await loadEnrichedSeriesData()
const result = await organizeTorrent(hash, name, save_path, seriesData)
if (result.done > 0 || result.errors.length > 0) {
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
}
res.json(result)
} catch (err) {
logger.error('api', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
export default router
+198
View File
@@ -0,0 +1,198 @@
import { Router } from 'express'
import { Agent, fetch as undiciFetch } from 'undici'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
const router = Router()
const PLEX_TV_API = 'https://plex.tv/api/v2'
// ── Fetch avec bypass SSL pour les serveurs locaux ────────────────────────────
function getServerFetch(serverUrl: string) {
return (url: string, opts: any = {}) =>
undiciFetch(url, {
...opts,
dispatcher: new Agent({ connect: { rejectUnauthorized: false } }),
})
}
// ── Fetch vers plex.tv (certificat valide, pas besoin de bypass) ──────────────
async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
const res = await fetch(url, {
...options,
headers: {
'Accept' : 'application/json',
'X-Plex-Client-Identifier': 'fankarr',
'X-Plex-Product' : 'FanKarr',
'X-Plex-Version' : '1.0',
...(options.headers ?? {}),
},
})
if (!res.ok) throw new Error(`Plex ${res.status}: ${await res.text()}`)
const text = await res.text()
return text ? JSON.parse(text) : {}
}
function mapServers(resources: any[]) {
return (Array.isArray(resources) ? resources : [])
.filter((r: any) => r.product === 'Plex Media Server')
.map((r: any) => ({
name : r.name,
owned : r.owned,
connections: (r.connections ?? []).map((c: any) => ({ uri: c.uri, local: c.local, relay: c.relay })),
}))
}
// POST /api/plex/connect — auth plex.tv + liste serveurs
router.post('/plex/connect', requireAuth, async (req, res) => {
const { username, password, code } = req.body
if (!username || !password) { res.status(400).json({ error: 'Email et mot de passe requis' }); return }
try {
const params = new URLSearchParams({ login: username, password })
if (code) params.set('verificationCode', code)
const authData = await plexFetch(`${PLEX_TV_API}/users/signin`, {
method : 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body : params.toString(),
})
const token = authData.authToken ?? authData.user?.authToken
if (!token) throw new Error('Token non reçu')
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
const servers = mapServers(resources)
logger.info('plex', `Auth réussie pour ${username} — ${servers.length} serveur(s)`)
res.json({ ok: true, token, servers })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
if (msg.includes('401')) {
res.status(401).json({ error: '2FA requis ou identifiants incorrects', requires2FA: true })
} else {
logger.error('plex', `Auth échouée : ${msg}`)
res.status(401).json({ error: 'Authentification échouée — vérifiez vos identifiants' })
}
}
})
// POST /api/plex/oauth/start — démarre le flow OAuth
router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
try {
const data = await plexFetch(`${PLEX_TV_API}/pins`, {
method : 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body : new URLSearchParams({ strong: 'true', 'X-Plex-Client-Identifier': 'fankarr' }).toString(),
})
const pinId = data.id
const pinCode = data.code
if (!pinId || !pinCode) throw new Error('Pin non reçu')
const authUrl = `https://app.plex.tv/auth#?clientID=fankarr&code=${pinCode}&context%5Bdevice%5D%5Bproduct%5D=FanKarr&forwardUrl=https%3A%2F%2Fplex.tv`
logger.info('plex', `OAuth démarré — pinId ${pinId}`)
res.json({ ok: true, pinId, pinCode, authUrl })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
logger.error('plex', `OAuth start échoué : ${msg}`)
res.status(500).json({ error: msg })
}
})
// GET /api/plex/oauth/poll/:pinId — vérifie si l'OAuth est complété
router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
const pinId = String(req.params.pinId)
try {
const data = await plexFetch(`${PLEX_TV_API}/pins/${pinId}?X-Plex-Client-Identifier=fankarr`)
const token = data.authToken
if (!token) { res.json({ ok: false, pending: true }); return }
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
const servers = mapServers(resources)
logger.info('plex', `OAuth complété — ${servers.length} serveur(s)`)
res.json({ ok: true, pending: false, token, servers })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
res.status(500).json({ error: msg })
}
})
// POST /api/plex/setup — enregistre l'agent Fankai + crée la bibliothèque
router.post('/plex/setup', requireAuth, async (req, res) => {
const { token, serverUrl, libraryName, libraryPath } = req.body
if (!token || !serverUrl || !libraryName || !libraryPath) {
res.status(400).json({ error: 'token, serverUrl, libraryName, libraryPath requis' }); return
}
// Fetch avec SSL bypass pour les appels vers le serveur Plex local
const serverFetch = getServerFetch(serverUrl)
const headers: Record<string, string> = { 'X-Plex-Token': token, 'Accept': 'application/json' }
const TARGET_AGENT_URI = 'https://metadata.fankai.fr/plex'
const steps: { step: string; ok: boolean; message: string }[] = []
let agentIdentifier = 'tv.plex.agents.custom.fankai'
let groupId: string | null = null
let agentSetupOk = false
// ── Étape 1 : Enregistrer le provider Fankai ─────────────
try {
const providersRes = await serverFetch(`${serverUrl}/media/providers/metadata`, { headers })
if (providersRes.ok) {
const data = await providersRes.json() as any
const providers: any[] = data?.MediaContainer?.MetadataAgentProvider ?? []
let provider = providers.find((p: any) => p.uri === TARGET_AGENT_URI)
if (!provider) {
const r = await serverFetch(`${serverUrl}/media/providers/metadata?uri=${encodeURIComponent(TARGET_AGENT_URI)}`, { method: 'POST', headers })
if (r.ok) provider = ((await r.json()) as any)?.MediaContainer?.MetadataAgentProvider?.[0]
}
if (provider) {
agentIdentifier = provider.identifier ?? agentIdentifier
steps.push({ step: 'provider', ok: true, message: `Provider enregistré (${agentIdentifier})` })
}
const groupsRes = await serverFetch(`${serverUrl}/media/providers/metadata/group`, { headers })
if (groupsRes.ok) {
const gdata = await groupsRes.json() as any
const groups: any[] = gdata?.MediaContainer?.MetadataAgentProviderGroup ?? []
let group = groups.find((g: any) => g.primaryIdentifier === agentIdentifier)
if (!group) {
const r = await serverFetch(
`${serverUrl}/media/providers/metadata/group?title=Fankai&primaryIdentifier=${encodeURIComponent(agentIdentifier)}`,
{ method: 'POST', headers },
)
if (r.ok) group = ((await r.json()) as any)?.MediaContainer?.MetadataAgentProviderGroup?.[0]
}
if (group) {
groupId = String(group.id ?? '')
steps.push({ step: 'group', ok: true, message: `Groupe d'agents créé (ID: ${groupId})` })
agentSetupOk = true
}
}
} else {
const status = providersRes.status
steps.push({ step: 'agent', ok: false, message: `Agent non configuré (HTTP ${status} — Plex < 1.43 ?) — setup manuel requis` })
logger.warn('plex', `Setup agent Fankai — HTTP ${status} depuis ${serverUrl}`)
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
steps.push({ step: 'agent', ok: false, message: `Agent non configuré — setup manuel requis` })
logger.warn('plex', `Setup agent Fankai échoué : ${msg}`)
}
// ── Étape 2 : Créer la bibliothèque ──────────────────────
try {
const params = new URLSearchParams({
type : 'show',
name : libraryName,
agent : agentIdentifier,
scanner : 'Plex TV Series',
language: 'fr-FR',
location: libraryPath,
})
if (groupId) params.set('metadataAgentProviderGroupId', groupId)
const libRes = await serverFetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers })
if (!libRes.ok) throw new Error(`HTTP ${libRes.status}: ${await libRes.text()}`)
steps.push({ step: 'library', ok: true, message: `Bibliothèque "${libraryName}" créée` })
logger.info('plex', `Bibliothèque "${libraryName}" créée sur ${serverUrl}`)
res.json({ ok: true, agentSetupOk, steps, manualSetup: !agentSetupOk })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
steps.push({ step: 'library', ok: false, message: `Création bibliothèque échouée : ${msg}` })
logger.error('plex', `Création bibliothèque échouée : ${msg}`)
res.status(500).json({ ok: false, steps, error: msg })
}
})
export default router
+195
View File
@@ -0,0 +1,195 @@
import { Router } from 'express'
import path from 'path'
import fs from 'fs'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { dispatchList } from '../torrent-clients/index.js'
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
import {
fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode,
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
deduplicateEpisodes,
} from '../lib/serie-helpers.js'
const router = Router()
router.get('/series', requireAuth, async (_req, res) => {
try {
const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()])
const availableSet = new Set<number>(availableIds)
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const activeTorrents = new Set<string>()
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
const serieDataMap = new Map<number, any>()
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
res.json({ series: seriesRaw.map((serie: any) => {
const serieData = serieDataMap.get(serie.id) ?? null
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) }
})})
} catch (err) {
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
router.get('/series/:id', requireAuth, async (req, res) => {
const id = Number(req.params.id)
try {
const [serieRaw, seasonsData, serieData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`), readSerieData(id)])
const serie = { ...normalizeSerie(serieRaw), wiki: serieData?.wiki ?? null }
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
return { ...normalizeSeason(season), episodes }
}))
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const availableEpisodeIds = new Set<number>()
const episodeTorrentMap : Record<number, any[]> = {}
const seasonTorrentMapBySn : Record<number, any[]> = {}
const integraleTorrents : any[] = []
const organizedEpisodeIds = new Set<number>()
const manualOrg = organized['manual'] ?? {}
if (serieData) {
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
}
}
}
if (serieData) {
// Lookup rapide hash → torrent pack (intégrale ou saison)
const torrentByHash = new Map<string, { torrent_url: string; magnet: string; type: string; raw: string; fankai: boolean; torrent_name: string | null }>()
for (const t of (serieData.torrents ?? []))
if (t.infohash) torrentByHash.set(t.infohash.toLowerCase(), { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title ?? '', fankai: t.fankai ?? true, torrent_name: t.torrent_name ?? null })
for (const sd_s of (serieData.seasons ?? []))
for (const t of (sd_s.torrents ?? []))
if (t.infohash) torrentByHash.set(t.infohash.toLowerCase(), { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title ?? '', fankai: t.fankai ?? true, torrent_name: t.torrent_name ?? null })
for (const t of (serieData.torrents ?? [])) {
integraleTorrents.push({ ...t, raw: t.title })
const resolved = buildResolvedEpisodes(serieData, t.infohash)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const ep of resolved) {
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
}
}
for (const season of serieData.seasons ?? []) {
for (const t of (season.torrents ?? [])) {
if (!seasonTorrentMapBySn[season.season_number]) seasonTorrentMapBySn[season.season_number] = []
seasonTorrentMapBySn[season.season_number].push({ torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, type: 'pack_saison', raw: t.title, torrent_name: t.torrent_name ?? null, manual: t.manual ?? false })
const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const ep of resolved) {
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
}
}
for (const ep of season.episodes ?? []) {
for (const t of (ep.torrents ?? [])) {
if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = []
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
episodeTorrentMap[ep.id].push({ torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, type: 'episode', raw: t.title, torrent_name: t.torrent_name ?? null, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null, formatted_name: pathEntry?.formatted_name ?? ep.formatted_name ?? null })
availableEpisodeIds.add(ep.id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
const isOrg = orgFiles[String(ep.id)] !== undefined
if (!isOrg) {
const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
const filename = match ? match.path.split('/').pop() : null
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
} else {
organizedEpisodeIds.add(ep.id)
}
}
// Fallback : si l'épisode n'a pas de torrent individuel,
// on crée un torrent synthétique depuis ep.paths (fichier dans un pack).
// On ajoute TOUTES les entrées valides (un pack par path unique).
if (!episodeTorrentMap[ep.id] || episodeTorrentMap[ep.id].length === 0) {
const seenPackHashes = new Set<string>()
for (const pathEntry of (ep.paths ?? [])) {
if (typeof pathEntry !== 'object' || !pathEntry.infohash) continue
const hashKey = pathEntry.infohash.toLowerCase()
if (seenPackHashes.has(hashKey)) continue
const pack = torrentByHash.get(hashKey)
if (!pack) continue
seenPackHashes.add(hashKey)
if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = []
episodeTorrentMap[ep.id].push({ torrent_url: pack.torrent_url, magnet: pack.magnet, infohash: hashKey, type: pack.type, raw: pack.raw, torrent_name: pack.torrent_name ?? null, manual: false, fankai: pack.fankai, file_index: pathEntry.file_index ?? null, file_path: pathEntry.path ?? null, formatted_name: pathEntry.formatted_name ?? ep.formatted_name ?? null })
availableEpisodeIds.add(ep.id)
}
}
}
}
}
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
const rawEps = season.episodes.map((ep: any) => {
const epTorrents = episodeTorrentMap[ep.id] ?? []
const epTorrent = epTorrents[0] ?? null
let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null
if (fankai === null && serieData) {
outer: for (const sd_season of serieData.seasons ?? []) {
for (const sdEp of sd_season.episodes ?? []) {
if (sdEp.id !== ep.id) continue
for (const p of sdEp.paths ?? []) {
const ih = typeof p === 'object' ? p.infohash?.toLowerCase() : null
if (!ih) continue
for (const t of serieData.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } }
for (const s of serieData.seasons ?? []) { for (const t of s.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } } }
}
}
}
}
const resolvedFankai = fankai ?? true
return {
...ep,
available: availableEpisodeIds.has(ep.id),
torrent: epTorrent ? { ...epTorrent, fankai: resolvedFankai } : null,
torrents: epTorrents.map((t: any) => ({ ...t, fankai: resolvedFankai ?? t.fankai ?? true })),
fankai,
organized: organizedEpisodeIds.has(ep.id),
}
})
// Dédoublonnage : plusieurs variantes (x264/x265…) du même episode_number
// sont fusionnées en un seul épisode avec la liste de torrents combinée
const eps = deduplicateEpisodes(rawEps)
const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).length
const epTotal = eps.length
const orgState = orgCount === 0
? 'none'
: orgCount >= epTotal
? 'complete'
: total > 0 && orgCount >= total
? 'complete'
: 'partial'
const seasonTorrents = seasonTorrentMapBySn[season.season_number] ?? []
return {
...season,
torrent: seasonTorrents[0] ?? null,
torrents: seasonTorrents,
organized_state: orgState,
organized_count: orgCount,
episodes: eps,
}
})
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) })
} catch (err) {
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
export default router
+29
View File
@@ -0,0 +1,29 @@
import { Router } from 'express'
import { requireAuth } from '../auth.js'
import { readSettings, writeSettings } from '../settings.js'
import { logger } from '../logger.js'
const router = Router()
router.get('/settings', requireAuth, (_req, res) => {
res.json(readSettings())
})
router.post('/settings', requireAuth, (req, res) => {
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing } = 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
const updated = writeSettings(patch)
logger.info('api', 'Paramètres mis à jour')
res.json(updated)
})
export default router
+194
View File
@@ -0,0 +1,194 @@
import { Router } from 'express'
import path from 'path'
import fs from 'fs'
import { requireAuth } from '../auth.js'
import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
import { DATA_DIR, BASE_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { systemInfo } from '../system.js'
import { scanMediaPath, syncFilenameChanges } from '../organize.js'
import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js'
import { cacheSize } from '../lib/github-cache.js'
import { recentOrganized } from '../lib/notifs.js'
import { workerRunning } from '../organize.js'
const router = Router()
// ── Logs ───────────────────────────────────────────────────────
router.get('/logs', requireAuth, (req, res) => {
const limit = Number(req.query.limit) || 100
const level = (req.query.level as string) || 'all'
const source = (req.query.source as string) || undefined
res.json({ entries: readLogs({ limit, level: level as any, source }), size: logsFileSize() })
})
router.post('/logs/clear', requireAuth, (_req, res) => {
clearLogs()
logger.info('api', 'Logs effacés')
res.json({ ok: true })
})
// ── Système ────────────────────────────────────────────────────
router.get('/system/info', systemInfo)
router.get('/system', requireAuth, (_req, res) => {
res.json({ isDocker: fs.existsSync('/.dockerenv') })
})
router.get('/version', (_req, res) => {
try {
const versionPath = path.join(BASE_DIR, 'version.txt')
const version = fs.existsSync(versionPath) ? fs.readFileSync(versionPath, 'utf-8').trim() : 'dev'
res.json({ version })
} catch {
res.json({ version: 'dev' })
}
})
// ── Parcours fichiers ──────────────────────────────────────────
router.get('/browse', requireAuth, (req, res) => {
const isWindows = process.platform === 'win32'
const dirPath = (req.query.path as string) || '/'
// ── Racine virtuelle Windows : liste les lecteurs disponibles ──
if (isWindows && dirPath === '/') {
const drives: string[] = []
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
const d = `${letter}:\\`
try { fs.readdirSync(d); drives.push(d) } catch {}
}
res.json({ path: '/', parent: null, dirs: drives, drivesRoot: true })
return
}
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b))
// Sur Windows, remonter à '/' quand on est à la racine d'un lecteur (ex: C:\)
let parent: string | null
if (dirPath === '/') {
parent = null
} else if (isWindows && /^[A-Za-z]:\\?$/.test(dirPath)) {
parent = '/'
} else {
const up = path.dirname(dirPath)
parent = up === dirPath ? null : up // dirname de C:\ retourne C:\ → null
}
res.json({ path: dirPath, parent, dirs })
} catch (err) {
logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
}
})
router.get('/browse-files', requireAuth, (req, res) => {
const dirPath = (req.query.path as string) || '/'
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
try {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
logger.info('api', `Dossier créé : ${dirPath}`)
}
const files: { name: string; path: string; size: number }[] = []
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
catch { return }
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full)
} else if (entry.isFile() && VIDEO_EXTS.has(path.extname(entry.name).toLowerCase())) {
try {
const stat = fs.statSync(full)
files.push({ name: entry.name, path: full, size: stat.size })
} catch {}
}
}
}
walk(dirPath)
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
res.json({ path: dirPath, files })
} catch (err) {
logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
}
})
// ── Catalogue ──────────────────────────────────────────────────
router.get('/torrents/status', requireAuth, async (_req, res) => {
try {
const available = await readAvailable()
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
} catch { res.json({ exists: false, count: 0, empty: true }) }
})
router.post('/update', requireAuth, async (_req, res) => {
try {
cacheClear()
logger.info('api', 'Cache GitHub vidé — rechargement forcé')
const availableIds = await readAvailable()
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
res.json({ ok: true, count: availableIds.length })
;(async () => {
try {
const seriesData = await loadEnrichedSeriesData()
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
if (renamed > 0)
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
} catch (err) {
logger.warn('api', `Sync noms après mise à jour échoué : ${err instanceof Error ? err.message : err}`)
}
})()
} catch (err) {
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
router.post('/scan', requireAuth, async (_req, res) => {
try {
const { mediaPath } = readSettings()
logger.info('api', 'Scan médiathèque manuel lancé')
const seriesData = await loadEnrichedSeriesData()
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
res.json({ ok: true, ...result })
} catch (err) {
logger.error('api', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Debug ──────────────────────────────────────────────────────
router.get('/debug/stats', requireAuth, (req, res) => {
const { devMode } = readSettings()
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
const mem = process.memoryUsage()
const uptimeS = Math.floor(process.uptime())
const h = Math.floor(uptimeS / 3600)
const m = Math.floor((uptimeS % 3600) / 60)
const s = uptimeS % 60
let organizedCount = 0
try {
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath)) {
const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
organizedCount = Object.values(org).reduce((acc: number, episodes: any) => acc + Object.keys(episodes).length, 0)
}
} catch {}
res.json({
memory : { heapUsed: Math.round(mem.heapUsed / 1024 / 1024), heapTotal: Math.round(mem.heapTotal / 1024 / 1024), rss: Math.round(mem.rss / 1024 / 1024) },
cache : { entries: cacheSize(), ttlHours: 6 },
uptime : `${h}h ${m}m ${s}s`,
uptimeSeconds: uptimeS,
worker : { running: workerRunning },
organized: { trackedFiles: organizedCount },
requests : { notifs: recentOrganized.length },
})
})
export default router
+86
View File
@@ -0,0 +1,86 @@
import { Router } from 'express'
import { requireAuth } from '../auth.js'
import {
getDriver, getAvailableClients,
listClients, addClient, removeClient, getClient,
sanitizeClient, updateClient,
} from '../torrent-clients/index.js'
const router = Router()
router.get('/torrent-clients/available', requireAuth, (_req, res) => {
res.json(getAvailableClients())
})
router.get('/torrent-clients', requireAuth, (_req, res) => {
res.json(listClients().map((c: any) => sanitizeClient(c)))
})
router.post('/torrent-clients', requireAuth, (req, res) => {
const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
res.json(sanitizeClient(addClient(name, type, config)))
})
router.delete('/torrent-clients/:uuid', requireAuth, (req, res) => {
const ok = removeClient(String(req.params.uuid))
if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return }
res.json({ ok: true })
})
router.put('/torrent-clients/:uuid', requireAuth, (req, res) => {
const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
const oldClient = getClient(String(req.params.uuid))
if (oldClient) {
const driver = getDriver(type)
if (driver) {
for (const field of driver.definition.fields) {
if (field.type === 'password' && config[field.key] === '••••••••') {
config[field.key] = oldClient.config[field.key]
}
}
}
}
const updated = updateClient(String(req.params.uuid), name, type, config)
if (!updated) { res.status(404).json({ error: 'Client introuvable' }); return }
res.json(sanitizeClient(updated))
})
router.post('/torrent-clients/test-config', requireAuth, async (req, res) => {
const { type, config, uuid } = req.body
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
const driver = getDriver(type)
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
if (uuid) {
const oldClient = getClient(uuid)
if (oldClient) {
for (const field of driver.definition.fields) {
if (field.type === 'password' && config[field.key] === '••••••••') {
config[field.key] = oldClient.config[field.key]
}
}
}
}
res.json(await driver.test(config))
})
router.post('/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type)
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
res.json(await driver.test(client.config))
})
router.get('/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) => {
const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type)
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
res.json(await driver.healthcheck(client.config))
})
export default router
+2 -1
View File
@@ -9,8 +9,9 @@
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import { DATA_DIR } from './config.js'
const SECRET_PATH = path.join(process.cwd(), 'data', 'secret.key')
const SECRET_PATH = path.join(DATA_DIR, 'secret.key')
function generateSecret(): string {
return crypto.randomBytes(48).toString('hex')
+18 -11
View File
@@ -1,22 +1,29 @@
import fs from 'fs'
import path from 'path'
import { DATA_DIR } from './config.js'
const DATA_PATH = path.join(process.cwd(), 'data', 'settings.json')
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
export interface Settings {
mediaPath : string
completePath : string
organizeMode : 'hardlink' | 'move'
category : string
nfoSupport : boolean // téléchargement NFO/images depuis GitLab Fankai Pack
mediaPath : string
completePath : string
organizeMode : 'hardlink' | 'copy' | 'move' // ← ajouter 'copy'
category : string
nfoSupport : boolean
autoImport : boolean
devMode : boolean
deleteTorrentOnMove : boolean // ← ajouter
}
const defaults: Settings = {
mediaPath : '',
completePath : '',
organizeMode : 'hardlink',
category : 'fankai',
nfoSupport : false,
mediaPath : '',
completePath: '',
organizeMode: 'hardlink',
category : 'fankai',
nfoSupport : false,
autoImport : true,
devMode : false,
deleteTorrentOnMove: false,
}
export function readSettings(): Settings {
+15
View File
@@ -0,0 +1,15 @@
import fs from 'fs'
import os from 'os'
import type { Request, Response } from 'express'
// GET /api/system/info
export function systemInfo(_req: Request, res: Response): void {
const isTermux = fs.existsSync('/data/data/com.termux')
const platform = os.platform()
res.json({
platform,
isTermux,
defaultPath: isTermux ? '/sdcard/' : '/',
})
}
+226 -39
View File
@@ -2,13 +2,13 @@
* Torrent Client Registry
* =======================
* Interface commune + registry de tous les clients disponibles.
* Chaque client expose son schema de configuration (FieldDef[])
* pour que le frontend génère le formulaire dynamiquement.
*/
import { randomUUID } from 'crypto'
import fs from 'fs'
import path from 'path'
import { DATA_DIR } from '../config.js'
import { logger } from '../logger.js'
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -21,42 +21,58 @@ export interface FieldDef {
default? : string | number
}
/** Définition statique d'un type de client (ce qu'il expose au front) */
export interface TorrentClientDefinition {
id : string // "qbittorrent", "transmission", etc.
label : string // "qBittorrent"
fields : FieldDef[] // schema du formulaire
id : string
label : string
fields : FieldDef[]
}
/** Instance sauvegardée d'un client configuré par l'utilisateur */
export interface SavedClient {
uuid : string // identifiant unique de cette instance
name : string // nom custom donné par l'utilisateur
type : string // id du TorrentClientDefinition
uuid : string
name : string
type : string
config: Record<string, string | number>
}
/** État normalisé d'un torrent (commun à tous les clients) */
export interface TorrentFileProgress {
index : number
name ?: string // chemin relatif dans le torrent (ex: "Pack S1/Episode1.mkv") — optionnel selon le client
progress: number // 0–1
priority: number // 0 = do not download, 1 = normal, 6 = high, 7 = max
}
export interface TorrentInfo {
hash : string
name : string
state : 'downloading' | 'seeding' | 'paused' | 'checking' | 'error' | 'unknown'
progress : number // 0-100
size : number // bytes
downloaded: number // bytes
speed : number // bytes/s
eta : number // secondes, -1 si inconnu
save_path : string // dossier de complétion
progress : number
size : number
downloaded: number
uploaded : number
ratio : number
speed : number
upspeed : number
eta : number
save_path : string
category : string
files ?: TorrentFileProgress[]
}
export interface DownloadOptions {
file_index?: number | null
file_path ?: string | null
infohash ?: string | null // hash direct depuis les données API (plus fiable que parser le magnet)
magnet ?: string | null // fallback pour extraire le hash si infohash absent
}
/** Interface que chaque client doit implémenter */
export interface TorrentClientDriver {
definition : TorrentClientDefinition
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
add : (config: Record<string, string | number>, url: string) => Promise<void>
add : (config: Record<string, string | number>, url: string, options?: DownloadOptions) => Promise<void>
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
getFiles ?: (config: Record<string, string | number>, hash: string) => Promise<TorrentFileProgress[]>
}
// ─── Registry ─────────────────────────────────────────────────────────────────
@@ -65,6 +81,7 @@ 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}`)
}
export function getDriver(type: string): TorrentClientDriver | undefined {
@@ -75,9 +92,9 @@ export function getAvailableClients(): TorrentClientDefinition[] {
return [...drivers.values()].map(d => d.definition)
}
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
// ─── Persistence ──────────────────────────────────────────────────────────────
const CLIENTS_PATH = path.join(process.cwd(), 'data', 'torrent_clients.json')
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
function loadClients(): SavedClient[] {
try {
@@ -102,14 +119,17 @@ export function addClient(name: string, type: string, config: Record<string, str
const client: SavedClient = { uuid: randomUUID(), name, type, config }
clients.push(client)
saveClients(clients)
logger.info('torrent-clients', `Client ajouté : "${name}" (${type})`)
return client
}
export function removeClient(uuid: string): boolean {
const clients = loadClients()
const clients = loadClients()
const target = clients.find(c => c.uuid === uuid)
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})`)
return true
}
@@ -117,7 +137,16 @@ export function getClient(uuid: string): SavedClient | undefined {
return loadClients().find(c => c.uuid === uuid)
}
/** Masque les champs password pour l'envoi au front */
export function updateClient(uuid: string, name: string, type: string, config: Record<string, string | number>): 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})`)
return clients[index]
}
export function sanitizeClient(client: SavedClient, definition?: TorrentClientDefinition): SavedClient {
if (!definition) definition = getDriver(client.type)?.definition
if (!definition) return client
@@ -130,54 +159,212 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
return { ...client, config }
}
// ─── Dispatch download vers tous les clients ──────────────────────────────────
// ─── Backoff par client ───────────────────────────────────────────────────────
// Si un client échoue à l'auth, on le met en cooldown pour éviter le ban IP
export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
interface BackoffEntry {
failCount : number
nextRetry : number // timestamp ms
}
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 {
const entry = _backoff.get(uuid)
if (!entry) return false
return Date.now() < entry.nextRetry
}
function cooldownRemaining(uuid: string): string {
const entry = _backoff.get(uuid)
if (!entry) return ''
const remaining = Math.max(0, entry.nextRetry - Date.now())
const m = Math.ceil(remaining / 60_000)
return m > 0 ? `${m}min` : ''
}
function registerFailure(uuid: string, clientName: string) {
const entry = _backoff.get(uuid) ?? { failCount: 0, nextRetry: 0 }
entry.failCount = Math.min(entry.failCount + 1, BACKOFF_DELAYS.length - 1) + (entry.failCount === 0 ? 0 : 0)
const delay = BACKOFF_DELAYS[Math.min(entry.failCount - 1, BACKOFF_DELAYS.length - 1)]
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)`)
}
function registerSuccess(uuid: string) {
_backoff.delete(uuid)
}
// Exposé pour l'API de status
export function getClientCooldowns(): Record<string, { failCount: number; nextRetry: number }> {
const result: Record<string, { failCount: number; nextRetry: number }> = {}
for (const [uuid, entry] of _backoff.entries()) {
if (Date.now() < entry.nextRetry) result[uuid] = entry
}
return result
}
// ─── Remote path remapping ────────────────────────────────────────────────────
// 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
function remapSavePath(savePath: string, remotePath: string, localPath: string): string {
const remote = remotePath.replace(/\/+$/, '')
const local = localPath.replace(/\/+$/, '')
if (!remote || !local) return savePath
if (!savePath.startsWith(remote)) return savePath
return local + savePath.slice(remote.length)
}
// ─── Dispatch download ─────────────────────────────────────────────────────────
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')
return []
}
const results = []
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` })
continue
}
try {
await driver.add(client.config, url)
await driver.add(client.config, url, options)
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
results.push({ uuid: client.uuid, name: client.name, ok: true })
} catch (err) {
results.push({
uuid : client.uuid,
name : client.name,
ok : false,
error: err instanceof Error ? err.message : 'Erreur inconnue'
})
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('torrent-clients', `Échec envoi vers "${client.name}" : ${msg}`)
results.push({ uuid: client.uuid, name: client.name, ok: false, error: msg })
}
}
return results
}
// ─── Dispatch list vers tous les clients ─────────────────────────────────────
// ─── Dispatch remove ───────────────────────────────────────────────────────────
export async function dispatchList(category?: string): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
export async function dispatchRemove(hash: string, deleteFiles = false): Promise<{ ok: boolean; error?: string }> {
const clients = loadClients()
const results: (TorrentInfo & { client_uuid: string; client_name: string })[] = []
let found = false
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver) continue
try {
// La catégorie du client est prioritaire sur la catégorie globale
await driver.remove(client.config, hash, deleteFiles)
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
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}`)
}
}
return found ? { ok: true } : { ok: false, error: 'Torrent introuvable dans les clients' }
}
// Map inversée : titre normalisé → infohash (pour matcher 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)) {
index.set(title.toLowerCase().trim(), hash.toLowerCase())
}
return index
}
export async function dispatchList(
category? : string,
infohashMap? : Record<string, string>,
): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
const clients = loadClients()
const results: (TorrentInfo & { client_uuid: string; client_name: string })[] = []
const titleIndex = infohashMap ? buildTitleIndex(infohashMap) : null
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver) continue
if (isInCooldown(client.uuid)) {
const remaining = cooldownRemaining(client.uuid)
logger.debug('torrent-clients', `Client "${client.name}" en cooldown — retry dans ${remaining}`)
continue
}
try {
const clientCategory = (client.config.category as string) || category
const torrents = await driver.list(client.config, clientCategory)
registerSuccess(client.uuid)
const remotePath = String(client.config.remotePath ?? '')
const localPath = String(client.config.localPath ?? '')
const hasRemap = remotePath && localPath
for (const t of torrents) {
results.push({ ...t, client_uuid: client.uuid, client_name: client.name })
const save_path = hasRemap
? 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
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)}…`)
hash = resolved
}
}
results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
}
} catch (err) {
console.error(`[torrent-clients] list() échoué pour ${client.name}:`, 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')
if (isAuthError) registerFailure(client.uuid, client.name)
}
}
return results
}
// ─── Dispatch file progress ────────────────────────────────────────────────────
// Retourne la liste des fichiers d'un torrent avec leur progression (0–1).
// clientUuid optionnel : si fourni, interroge uniquement ce client.
export async function dispatchGetFiles(
hash : string,
clientUuid?: string,
): Promise<TorrentFileProgress[]> {
const clients = clientUuid
? loadClients().filter(c => c.uuid === clientUuid)
: loadClients()
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver?.getFiles) continue
try {
const files = await driver.getFiles(client.config, hash)
if (files.length > 0) return files
} catch {}
}
return []
}
+313 -40
View File
@@ -1,19 +1,29 @@
/**
* qBittorrent Driver
* Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication)
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
// Mapping état qBittorrent → état normalisé
function mapState(state: string): TorrentInfo['state'] {
if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading'
if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding'
if (['pausedDL', 'pausedUP'].includes(state)) return 'paused'
if (['pausedDL', 'pausedUP', 'stoppedDL', 'stoppedUP'].includes(state)) return 'paused'
if (['checkingDL', 'checkingUP', 'checkingResumeData'].includes(state)) return 'checking'
if (state === 'error' || state === 'missingFiles') return 'error'
return 'unknown'
}
// Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID
async function qbAuth(config: Record<string, string | number>): Promise<Record<string, string>> {
if (config.apiKey) {
return { Authorization: `Bearer ${config.apiKey}` }
}
const cookie = await qbLogin(config)
return { Cookie: cookie }
}
async function qbLogin(config: Record<string, string | number>): Promise<string> {
const form = new URLSearchParams()
form.append('username', String(config.username ?? ''))
@@ -26,12 +36,119 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
if (!res.ok) throw new Error(`Login échoué : ${text.trim() || res.status}`)
const cookie = res.headers.get('set-cookie') ?? ''
const sid = cookie.match(/SID=([^;]+)/)?.[1]
if (!sid) throw new Error('Cookie SID introuvable')
return sid
const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0].trim()
if (!cookie) throw new Error('Cookie de session introuvable')
return cookie
}
async function qbTorrentExists(config: Record<string, string | number>, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
try {
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, {
headers: authHeaders,
})
if (!res.ok) return false
const list = await res.json()
return Array.isArray(list) && list.length > 0
} catch {
return false
}
}
/**
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
* applique les priorités, puis reprend le torrent.
*/
async function qbApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
resume : boolean,
): Promise<void> {
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
try {
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: authH,
})
if (!res.ok) continue
const files: any[] = await res.json()
if (!Array.isArray(files) || files.length === 0) continue
if (fileIndex >= files.length) {
throw new Error(`Index fichier ${fileIndex} hors limites (torrent a ${files.length} fichiers)`)
}
// Si tous les fichiers sont à priorité normale (état initial du torrent),
// on applique le filtrage complet : on désactive tout sauf la cible.
// Si certains sont déjà à 0 (filtrage déjà effectué pour un épisode précédent),
// on se contente d'activer la nouvelle cible sans toucher aux autres.
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
if (isInitialState) {
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
if (unwanted.length > 0) {
const body = new URLSearchParams({ hash, id: unwanted.join('|'), priority: '0' })
const r = await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers })
if (!r.ok) { logger.warn('qbittorrent', `filePrio désactivation échouée (${r.status}) — retry`); continue }
}
}
// Activer le fichier cible (dans tous les cas)
const r2 = await fetch(`${config.url}/api/v2/torrents/filePrio`, {
method: 'POST',
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
headers,
})
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
// Vérification : le fichier cible est bien à priorité > 0
const verif = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
if (verif.ok) {
const verifiedFiles: any[] = await verif.json()
const target = verifiedFiles[fileIndex]
if (!target || (target.priority ?? 0) === 0) {
logger.warn('qbittorrent', `Fichier ${fileIndex} toujours à priorité 0 après application — retry`)
continue
}
}
// 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)}…`)
return
} catch (err) {
if (err instanceof Error && err.message.includes('hors limites')) throw err
}
}
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
if (resume) {
try {
const authH = await qbAuth(config)
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
const body = new URLSearchParams({ hashes: hash })
await Promise.allSettled([
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body, headers }),
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body, headers }),
])
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
}
const QB: TorrentClientDriver = {
@@ -39,72 +156,228 @@ 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: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'apiKey', label: 'Clé API (≥5.2.0)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
],
},
async test(config) {
try {
await qbLogin(config)
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/app/version`, { headers: authH })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : 'Erreur inconnue' }
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('qbittorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const sid = await qbLogin(config)
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/app/version`, {
headers: { Cookie: `SID=${sid}` }
headers: authH,
})
if (!res.ok) return { online: false }
return { online: true, version: (await res.text()).trim() }
} catch {
const version = (await res.text()).trim()
logger.debug('qbittorrent', `Healthcheck OK — version ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('qbittorrent', `Healthcheck échoué : ${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}` }
headers: authH,
})
if (!res.ok) throw new Error(`qB list échoué: ${res.status}`)
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
const data: any[] = await res.json()
return data.map(t => ({
hash : t.hash,
name : t.name,
state : mapState(t.state),
progress : Math.round(t.progress * 100),
size : t.size,
downloaded: t.downloaded,
speed : t.dlspeed,
eta : t.eta ?? -1,
save_path : t.save_path,
category : t.category ?? '',
} satisfies TorrentInfo))
// Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
const fileMap = new Map<string, any[]>()
await Promise.all(data.map(async (t: any) => {
try {
const r = await fetch(`${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
headers: authH,
})
if (r.ok) fileMap.set(t.hash, await r.json())
} catch {}
}))
return data.map(t => {
const fileInfo = fileMap.get(t.hash) ?? []
return {
hash : t.hash,
name : t.name,
state : mapState(t.state),
progress : Math.round(t.progress * 100),
size : t.size,
downloaded: t.downloaded,
uploaded : t.uploaded ?? 0,
ratio : Math.round((t.ratio ?? 0) * 100) / 100,
speed : t.dlspeed,
upspeed : t.upspeed ?? 0,
eta : t.eta ?? -1,
save_path : t.save_path,
category : t.category ?? '',
files : fileInfo.length > 0
? fileInfo.map((f: any, i: number) => ({
index : i,
name : String(f.name ?? ''),
progress: f.progress ?? 0, // 0–1
priority: f.priority ?? 1,
}))
: undefined,
} satisfies TorrentInfo
})
},
async add(config, url) {
const sid = await qbLogin(config)
async getFiles(config, hash) {
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: authH,
})
if (!res.ok) return []
const data: any[] = await res.json()
return data.map((f: any, i: number) => ({
index : i,
name : String(f.name ?? ''),
progress: f.progress ?? 0,
priority: f.priority ?? 1,
}))
},
async add(config, url, options?: DownloadOptions) {
const authH = await qbAuth(config)
// Priorité : infohash direct (depuis les données API) > parsing du magnet/URL
const hash = (options?.infohash?.toLowerCase() ?? null)
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| null
if (options?.file_index != null) {
if (hash) {
const exists = await qbTorrentExists(config, authH, hash)
if (exists) {
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
qbApplyFilePriority(config, hash, options.file_index, false).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
}
// Snapshot des hashes avant ajout (pour retrouver le nouveau si hash inconnu)
let knownHashes: Set<string> = new Set()
if (!hash) {
try {
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: authH })
if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
} catch {}
}
// Stratégie d'ajout selon le type de lien :
// .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)
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))
if (isMagnet) {
form.append('urls', url)
} else {
// FanKarr télécharge le .torrent et l'envoie directement à qBit
logger.debug('qbittorrent', `Téléchargement .torrent via FanKarr : ${url.slice(0, 120)}`)
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } })
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent (${torrentRes.status}) : ${url}`)
const torrentBytes = await torrentRes.arrayBuffer()
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
}
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: form, headers: authH,
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
if (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}`)
)
} else {
// Hash inconnu (pas d'infohash, pas de magnet) → retrouve le nouveau torrent par diff de liste
logger.info('qbittorrent', `Torrent ajouté sans hash connu — recherche dans la liste (sélection fichier ${options.file_index})`)
const fileIndex = options.file_index
;(async () => {
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const ah = await qbAuth(config)
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: ah })
if (!lr.ok) continue
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
if (!newT) continue
const resolvedHash = String(newT.hash).toLowerCase()
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
await qbApplyFilePriority(config, resolvedHash, fileIndex, true)
return
} catch {}
}
logger.warn('qbittorrent', `Impossible de résoudre le hash pour appliquer la priorité fichier ${fileIndex}`)
})().catch(() => {})
}
return
}
// Pas de sélection de fichier → ajout normal
const form = new FormData()
form.append('urls', url)
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method : 'POST',
body : form,
headers: { Cookie: `SID=${sid}` },
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: form, headers: authH,
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
logger.info('qbittorrent', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
const authH = await qbAuth(config)
const form = new FormData()
form.append('hashes', hash)
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
method : 'POST',
body : form,
headers: authH,
})
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
export default QB
export default QB
+254
View File
@@ -0,0 +1,254 @@
/**
* Real-Debrid Driver
*
* Real-Debrid est un service debrid cloud : les torrents téléchargent sur leurs serveurs,
* puis les fichiers sont accessibles via des liens directs (HTTPS).
*
* Workflow FanKarr :
* 1. add() → POST /torrents/addMagnet puis selectFiles si file_index fourni
* 2. list() → GET /torrents (filtre par status=downloaded pour state='seeding')
* 3. remove() → DELETE /torrents/delete/{id}
*
* Le save_path de chaque torrent est construit à partir du localPath configuré
* (dossier où l'utilisateur télécharge ses fichiers RD via FUSE, WebDAV ou autre).
*/
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
function rdHeaders(config: Record<string, string | number>) {
return {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
}
}
async function rdFetch(
config : Record<string, string | number>,
method : 'GET' | 'POST' | 'DELETE',
path : string,
body ?: URLSearchParams,
): Promise<any> {
const res = await fetch(`${RD_BASE}${path}`, {
method,
headers: rdHeaders(config),
body : body,
})
if (res.status === 204) return null
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Real-Debrid HTTP ${res.status} : ${text}`)
}
return res.json()
}
function mapStatus(status: string): TorrentInfo['state'] {
switch (status) {
case 'downloaded': return 'seeding'
case 'downloading':
case 'queued':
case 'compressing':
case 'uploading':
case 'magnet_conversion': return 'downloading'
case 'waiting_files_selection': return 'paused'
case 'magnet_error':
case 'error':
case 'virus':
case 'dead': return 'error'
default: return 'unknown'
}
}
const rdDriver: TorrentClientDriver = {
definition: {
id : 'real-debrid',
label : 'Real-Debrid',
fields: [
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
{ key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/real-debrid', required: false },
],
},
async test(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
logger.info('real-debrid', `Connexion réussie — utilisateur : ${user.username}`)
return { ok: true, message: `Connecté en tant que ${user.username}` }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('real-debrid', `Test de connexion échoué : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
return { online: true, version: `RD — ${user.username}` }
} catch {
return { online: false }
}
},
async add(config, url, options?: DownloadOptions) {
const isMagnet = url.startsWith('magnet:')
const hash = (options?.infohash?.toLowerCase() ?? null)
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| null
// Vérifier si le torrent est déjà présent sur RD
if (hash) {
try {
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const existing = list.find((t: any) => t.hash?.toLowerCase() === hash)
if (existing) {
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur RD`)
if (options?.file_index != null && existing.status === 'waiting_files_selection') {
const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`)
.then((info: any) => info.files ?? [])
const fileId = files[options.file_index]?.id
if (fileId) {
await rdFetch(config, 'POST', `/torrents/selectFiles/${existing.id}`,
new URLSearchParams({ files: String(fileId) }))
}
}
return
}
} catch {}
}
let torrentId: string
if (isMagnet) {
const data = await rdFetch(config, 'POST', '/torrents/addMagnet',
new URLSearchParams({ magnet: url }))
torrentId = data.id
logger.info('real-debrid', `Magnet ajouté → ID ${torrentId}`)
} else {
// URL de fichier .torrent : télécharger puis uploader
const torrentRes = await fetch(url)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`)
const torrentBuf = await torrentRes.arrayBuffer()
const formData = new FormData()
formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
const uploadRes = await fetch(`${RD_BASE}/torrents/addTorrent`, {
method : 'PUT',
headers: { Authorization: `Bearer ${config.apiKey}` },
body : formData,
})
if (!uploadRes.ok) throw new Error(`Ajout .torrent échoué : ${uploadRes.status}`)
const data = await uploadRes.json()
torrentId = data.id
logger.info('real-debrid', `.torrent uploadé → ID ${torrentId}`)
}
// Sélection des fichiers
if (options?.file_index != null) {
// Attendre que la liste des fichiers soit disponible (état waiting_files_selection)
let files: any[] = []
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
if (info.status === 'waiting_files_selection' && Array.isArray(info.files) && info.files.length > 0) {
files = info.files
break
}
if (info.status === 'downloading' || info.status === 'downloaded') break
} catch {}
}
if (files.length > 0 && options.file_index < files.length) {
const fileId = files[options.file_index]?.id
if (fileId) {
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: String(fileId) }))
logger.info('real-debrid', `Fichier ${options.file_index} sélectionné sur torrent RD ${torrentId}`)
}
} else if (files.length > 0) {
// Sélectionner tous les fichiers si index hors limites
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: 'all' }))
}
} else {
// Pas de sélection spécifique : tous les fichiers
// Attendre waiting_files_selection pour envoyer "all"
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
if (info.status === 'waiting_files_selection') {
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: 'all' }))
break
}
if (info.status !== 'magnet_conversion') break
} catch {}
}
}
},
async list(config, _category?) {
const torrents: any[] = await rdFetch(config, 'GET', '/torrents')
const localBase = String(config.localPath ?? '').replace(/\/+$/, '')
return torrents.map((t: any): TorrentInfo => {
const progress = typeof t.progress === 'number' ? t.progress / 100 : 0
// Construire un save_path "virtuel" à partir du localPath configuré + nom du torrent
const save_path = localBase ? `${localBase}/${t.filename ?? t.id}` : ''
// Les liens sont les fichiers téléchargés (disponibles si status=downloaded)
const files: TorrentFileProgress[] | undefined =
Array.isArray(t.files) && t.files.length > 0
? t.files.map((f: any, i: number) => ({
index : i,
name : String(f.path ?? f.name ?? ''),
progress: f.selected ? progress : 0,
}))
: undefined
return {
hash : String(t.hash ?? t.id ?? '').toLowerCase(),
name : String(t.filename ?? t.id ?? ''),
state : mapStatus(t.status ?? ''),
progress : Math.round(progress * 100),
size : typeof t.bytes === 'number' ? t.bytes : 0,
downloaded: typeof t.bytes === 'number' ? Math.round(t.bytes * progress) : 0,
uploaded : 0,
ratio : 0,
speed : typeof t.speed === 'number' ? t.speed : 0,
upspeed : 0,
eta : -1,
save_path,
category : '',
files,
}
})
},
async remove(config, hash, _deleteFiles?) {
// Retrouver l'ID RD depuis le hash
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur Real-Debrid`)
await rdFetch(config, 'DELETE', `/torrents/delete/${target.id}`)
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… supprimé de Real-Debrid`)
},
async getFiles(config, hash) {
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) return []
const info = await rdFetch(config, 'GET', `/torrents/info/${target.id}`)
const overallProgress = typeof info.progress === 'number' ? info.progress / 100 : 0
if (!Array.isArray(info.files)) return []
return info.files.map((f: any, i: number) => ({
index : i,
name : String(f.path ?? f.name ?? ''),
progress: f.selected ? overallProgress : 0,
}))
},
}
export default rdDriver
+347
View File
@@ -0,0 +1,347 @@
/**
* rTorrent Driver
* ===============
* Communique via XML-RPC (HTTP POST).
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
// ─── XML-RPC helpers ──────────────────────────────────────────
function xmlValue(val: any): string {
if (typeof val === 'string') return `<value><string>${val}</string></value>`
if (typeof val === 'number') return `<value><i8>${val}</i8></value>`
if (typeof val === 'boolean') return `<value><boolean>${val ? 1 : 0}</boolean></value>`
if (Array.isArray(val)) return `<value><array><data>${val.map(xmlValue).join('')}</data></array></value>`
return `<value><string>${String(val)}</string></value>`
}
function xmlCall(method: string, params: any[] = []): string {
return `<?xml version="1.0"?>
<methodCall>
<methodName>${method}</methodName>
<params>${params.map(p => `<param>${xmlValue(p)}</param>`).join('')}</params>
</methodCall>`
}
function parseXmlValue(node: Element): any {
const child = node.firstElementChild
if (!child) return node.textContent ?? ''
switch (child.tagName) {
case 'string' : return child.textContent ?? ''
case 'int' :
case 'i4' :
case 'i8' : return parseInt(child.textContent ?? '0', 10)
case 'double' : return parseFloat(child.textContent ?? '0')
case 'boolean' : return child.textContent === '1'
case 'array' : {
const data = child.querySelector('data')
if (!data) return []
return Array.from(data.children).map(parseXmlValue)
}
case 'struct' : {
const result: Record<string, any> = {}
for (const member of Array.from(child.querySelectorAll(':scope > member'))) {
const name = member.querySelector(':scope > name')?.textContent ?? ''
const value = member.querySelector(':scope > value')
if (name && value) result[name] = parseXmlValue(value)
}
return result
}
default: return child.textContent ?? ''
}
}
async function rpcCall(
config : Record<string, string | number>,
method : string,
params : any[] = [],
): Promise<any> {
const body = xmlCall(method, params)
const headers : Record<string, string> = { 'Content-Type': 'text/xml' }
if (config.username && config.password) {
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
}
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 text = await res.text()
// Parse XML — Node.js n'a pas DOMParser natif, on fait un parse minimaliste
const faultMatch = text.match(/<name>faultString<\/name>\s*<value><string>([^<]*)<\/string>/)
if (faultMatch) throw new Error(`rTorrent fault : ${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
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()))
}
return parseInnerValue(inner)
}
function parseInnerValue(inner: string): any {
if (inner.startsWith('<array>')) {
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
return items.map(m => parseInnerValue(m[1].trim()))
}
const strMatch = inner.match(/^<string>([\s\S]*?)<\/string>$/)
if (strMatch) return strMatch[1]
const intMatch = inner.match(/^<(?:i4|i8|int)>([\s\S]*?)<\/(?:i4|i8|int)>$/)
if (intMatch) return parseInt(intMatch[1], 10)
// Valeur brute sans tag
if (!inner.includes('<')) return inner
return inner
}
// multicall pour récupérer plusieurs champs en une requête
async function d_multicall(
config : Record<string, string | number>,
view : string,
methods: string[],
): Promise<any[][]> {
const params = [view, ...methods.map(m => `${m}=`)]
const result = await rpcCall(config, 'd.multicall2', ['', ...params])
return Array.isArray(result) ? result : []
}
async function rtTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
try {
const rows = await d_multicall(config, 'main', ['d.hash'])
return rows.some(r => String(r[0]).toLowerCase() === hash.toLowerCase())
} catch {
return false
}
}
/**
* Attend les métadonnées via polling rapide (300 ms), applique les priorités.
* Le torrent est ajouté avec load.start — il fetch les métadonnées activement via DHT/peers.
* Pendant le fetch (état "métadonnées en cours"), aucun byte de fichier n'est téléchargé.
* On set les priorités dès que les fichiers sont disponibles, avant que le download réel commence.
*/
async function rtApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
): Promise<void> {
// 100 tentatives × 300 ms = 30 s max
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 300))
try {
// Récupérer taille ET priorité courante de chaque fichier
const files: any[] = await rpcCall(config, 'f.multicall', [hash, '', 'f.size_bytes=', 'f.priority='])
const fileCount = Array.isArray(files) ? files.length : 0
if (fileCount === 0) continue
// Si tous les fichiers sont à priorité normale (état initial),
// on filtre tout sauf la cible. Sinon on active juste la cible.
const isInitialState = files.every((f: any) => Number(f[1]) > 0)
for (let i = 0; i < fileCount; i++) {
if (i === fileIndex) {
await rpcCall(config, 'f.priority.set', [hash, i, 1])
} else if (isInitialState) {
await rpcCall(config, 'f.priority.set', [hash, i, 0])
}
// Si pas initial state : on ne touche pas aux autres fichiers
}
logger.info('rtorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
}
// ─── State mapping ────────────────────────────────────────────
// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1)
// + is_open (0/1)
function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] {
if (isChecking) return 'checking'
if (!isOpen) return 'paused'
if (!isActive) return 'paused'
if (isComplete) return 'seeding'
return 'downloading'
}
const RT: TorrentClientDriver = {
definition: {
id : 'rtorrent',
label : 'rTorrent',
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: '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 },
],
},
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}`)
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}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const version = await rpcCall(config, 'system.client_version')
logger.debug('rtorrent', `Healthcheck OK — version ${version}`)
return { online: true, version: String(version) }
} catch (err) {
logger.debug('rtorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const methods = [
'd.hash',
'd.name',
'd.is_open',
'd.is_active',
'd.is_hash_checking',
'd.complete',
'd.size_bytes',
'd.bytes_done',
'd.up.total', // total uploadé
'd.ratio', // ratio x1000
'd.down.rate',
'd.up.rate',
'd.left_bytes',
'd.directory',
'd.custom1',
]
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='])
if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files)
} catch {}
}))
return rows
.map(r => {
const [hash, name, isOpen, isActive, isChecking, isComplete,
size, downloaded, uploadedTotal, ratioRaw,
dlSpeed, ulSpeed, leftBytes, directory, label] = r
const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0
const eta = Number(dlSpeed) > 0 && Number(leftBytes) > 0 ? Math.round(Number(leftBytes) / Number(dlSpeed)) : -1
const hashLower = String(hash).toLowerCase()
const fileInfo = fileMap.get(hashLower) ?? []
return {
hash : hashLower,
name : String(name),
state : mapState(Number(isOpen), Number(isActive), Number(isChecking), Number(isComplete)),
progress,
size : Number(size),
downloaded: Number(downloaded),
uploaded : Number(uploadedTotal ?? 0),
ratio : Math.round((Number(ratioRaw ?? 0) / 1000) * 100) / 100,
speed : Number(dlSpeed),
upspeed : Number(ulSpeed ?? 0),
eta,
save_path : String(directory),
category : String(label ?? ''),
files : fileInfo.length > 0
? fileInfo.map((f: any, i: number) => ({
index : i,
name : String(f[2] ?? ''),
progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0,
priority: 1,
}))
: undefined,
} satisfies TorrentInfo
})
.filter(t => {
if (!category) return true
return t.category === category
})
},
async add(config, url, options?: DownloadOptions) {
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
const hash = hashMatch?.[1]?.toUpperCase() ?? null
if (options?.file_index != null && hash) {
const exists = await rtTorrentExists(config, hash)
if (exists) {
// Torrent déjà présent → mettre à jour la priorité directement (async OK)
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
// Nouveau torrent → load.start (fetch métadonnées via DHT/peers actif)
// Pendant le fetch des métadonnées, aucun byte de fichier n'est téléchargé.
// On poll à 300 ms et on set les priorités dès que les fichiers sont disponibles.
const args: any[] = ['', url]
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
if (config.category) args.push(`d.custom1.set=${config.category}`)
await rpcCall(config, 'load.start', args)
logger.info('rtorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
// Pas de sélection de fichier → ajout et démarrage normal
const args: any[] = ['', url]
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
if (config.category) args.push(`d.custom1.set=${config.category}`)
await rpcCall(config, 'load.start', args)
logger.info('rtorrent', `Torrent ajouté avec succès${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 {
await rpcCall(config, 'd.erase', [h])
}
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
export default RT
+178
View File
@@ -0,0 +1,178 @@
/**
* Synology Download Station Driver
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
// Synology Download Station task status codes
function mapState(status: string): TorrentInfo['state'] {
if (status === 'downloading') return 'downloading'
if (status === 'seeding' || status === 'finished') return 'seeding'
if (status === 'paused') return 'paused'
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
if (status === 'hash_checking') return 'checking'
if (status === 'error') return 'error'
return 'unknown'
}
// Authentification DSM — retourne un SID
async function dsLogin(config: Record<string, string | number>): Promise<string> {
const params = new URLSearchParams({
api : 'SYNO.API.Auth',
version: '3',
method : 'login',
account: String(config.username ?? ''),
passwd : String(config.password ?? ''),
session: 'DownloadStation',
format : 'sid',
})
const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`Auth échouée : ${JSON.stringify(data.error)}`)
return data.data.sid
}
async function dsRequest(
config : Record<string, string | number>,
api : string,
method : string,
version: string,
extra : Record<string, string> = {},
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 data = await res.json()
if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`)
return data.data
}
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 },
],
},
async test(config) {
try {
await dsLogin(config)
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}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const sid = await dsLogin(config)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Info',
version: '2',
method : 'getinfo',
_sid : sid,
})
const res = await fetch(`${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}`)
return { online: false }
}
},
async list(config, category) {
const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
{ additional: 'transfer,detail' }, 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
})
},
async add(config, url, options?: DownloadOptions) {
if (options?.file_index != null) logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
const sid = await dsLogin(config)
const extra: Record<string, string> = { uri: url }
if (config.savePath) extra.destination = String(config.savePath)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'create',
_sid : sid,
...extra,
})
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
},
async remove(config, hash, deleteFiles = false) {
const sid = await dsLogin(config)
// Synology utilise son ID interne — on cherche via la liste
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 res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
logger.info('synology-ds', `Torrent ${found.id} supprimé`)
},
}
export default DS
+209
View File
@@ -0,0 +1,209 @@
/**
* Transmission Driver
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } 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
if (status === 4 || status === 3) return 'downloading'
if (status === 6 || status === 5) return 'seeding'
if (status === 0) return 'paused'
if (status === 1 || status === 2) return 'checking'
return 'unknown'
}
async function trRequest(
config: Record<string, string | number>,
method: string,
args: Record<string, unknown> = {},
sessionId = ''
): Promise<{ result: string; arguments?: any; sessionId?: string }> {
const headers: Record<string, string> = {
'Content-Type' : 'application/json',
'X-Transmission-Session-Id': sessionId,
}
if (config.username && config.password) {
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
}
const res = await fetch(`${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
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')
return trRequest(config, method, args, newSessionId)
}
if (!res.ok) throw new Error(`Transmission HTTP ${res.status}`)
const data = await res.json()
if (data.result !== 'success') throw new Error(`Transmission erreur : ${data.result}`)
return { ...data, sessionId }
}
async function trGetIdByHash(config: Record<string, string | number>, hash: string): Promise<number | null> {
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
const torrents = data.arguments?.torrents ?? []
return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null
}
/**
* Attend que les métadonnées soient disponibles (polling 500 ms),
* puis applique les priorités : fichier cible voulu, tous les autres ignorés.
*/
async function trApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
): Promise<void> {
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const id = await trGetIdByHash(config, hash)
if (id == null) continue
const data = await trRequest(config, 'torrent-get', { ids: [id], fields: ['files'] })
const files = data.arguments?.torrents?.[0]?.files
if (!Array.isArray(files) || files.length === 0) continue
const unwanted = files.map((_: any, i: number) => i).filter((i: number) => i !== fileIndex)
await trRequest(config, 'torrent-set', {
ids : [id],
'files-wanted' : [fileIndex],
'files-unwanted' : unwanted,
})
logger.info('transmission', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
}
const TR: TorrentClientDriver = {
definition: {
id : 'transmission',
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: '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 },
],
},
async test(config) {
try {
await trRequest(config, 'session-get')
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}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const data = await trRequest(config, 'session-get')
const version = data.arguments?.version ?? 'inconnue'
logger.debug('transmission', `Healthcheck OK — version ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('transmission', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const fields = [
'hashString', 'name', 'status', 'percentDone', 'totalSize',
'downloadedEver', 'uploadedEver', 'uploadRatio',
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
'files', // bytesCompleted + length par fichier (fileStats n'a pas 'length')
]
const data = await trRequest(config, 'torrent-get', { fields })
const torrents: any[] = data.arguments?.torrents ?? []
return torrents
.filter(t => {
if (!category) return true
return t.labels?.includes(category)
})
.map(t => ({
hash : t.hashString,
name : t.name,
state : mapState(t.status),
progress : Math.round(t.percentDone * 100),
size : t.totalSize,
downloaded: t.downloadedEver,
uploaded : t.uploadedEver ?? 0,
ratio : Math.round((t.uploadRatio ?? 0) * 100) / 100,
speed : t.rateDownload,
upspeed : t.rateUpload ?? 0,
eta : t.eta ?? -1,
save_path : t.downloadDir,
category : t.labels?.[0] ?? '',
files : Array.isArray(t.files) && t.files.length > 0
? t.files.map((f: any, i: number) => ({
index : i,
name : String(f.name ?? ''),
progress: f.length > 0 ? f.bytesCompleted / f.length : 0,
}))
: undefined,
} satisfies TorrentInfo))
},
async add(config, url, options?: DownloadOptions) {
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
const hash = hashMatch?.[1]?.toLowerCase() ?? null
const args: Record<string, unknown> = { filename: url }
if (config.savePath) args['download-dir'] = String(config.savePath)
if (config.category) args['labels'] = [String(config.category)]
const res = await trRequest(config, 'torrent-add', args)
if (options?.file_index != null && hash) {
const isDuplicate = !!res.arguments?.['torrent-duplicate']
if (isDuplicate) {
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
} else {
logger.info('transmission', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
}
trApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('transmission', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
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
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())
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
await trRequest(config, 'torrent-remove', { ids: [found.id], 'delete-local-data': deleteFiles })
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
export default TR
+262
View File
@@ -0,0 +1,262 @@
/**
* uTorrent Driver
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } 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
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
if (status & 1 && status & 8) return 'downloading'
if (status & 64) return 'downloading' // queued
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> {
try {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getfiles', hash: hash.toUpperCase() }, session)
const files = data?.files?.[1]
return Array.isArray(files) && files.length > 0
} catch { return false }
}
/**
* Attend que les métadonnées soient disponibles (polling), puis applique les priorités :
* tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal).
*/
async function utApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
): Promise<void> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const HASH = hash.toUpperCase()
// 100 tentatives × 500 ms = 50 s max
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getfiles', hash: HASH }, session)
const files = data?.files?.[1] as any[][] | undefined
if (!Array.isArray(files) || files.length === 0) continue
// Passer tous les fichiers à priorité 0 (skip)
const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' })
for (let i = 0; i < files.length; i++) skipQs.append('f', String(i))
await fetch(`${config.url}/gui/?${skipQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
// Activer le fichier cible (priorité 2 = normal)
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
await fetch(`${config.url}/gui/?${selectQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
logger.info('utorrent', `Fichier ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`)
}
async function utGetSession(config: Record<string, string | number>): Promise<UTSession> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const headers : Record<string, string> = {
'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 text = await res.text()
const match = text.match(/<div[^>]+id=['"]token['"][^>]*>([^<]+)</)
if (!match) throw new Error('Token CSRF introuvable dans la réponse')
const token = match[1].trim()
const cookie = res.headers.get('set-cookie') ?? ''
return { token, cookie }
}
async function utRequest(
config : Record<string, string | number>,
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}`, {
headers: {
'Authorization': `Basic ${auth}`,
'Cookie' : session.cookie,
},
})
if (!res.ok) throw new Error(`uTorrent 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]
const [hash, status, name, size, progress, downloaded, uploaded, ratio, ulSpeed, dlSpeed, eta, label] = t
return {
hash : hash.toLowerCase(),
name,
state : progress >= 1000 ? 'seeding' : mapState(status),
progress : Math.min(100, Math.round(progress / 10)),
size,
downloaded,
uploaded : uploaded ?? 0,
ratio : Math.round(((ratio ?? 0) / 1000) * 100) / 100, // uTorrent ratio est x1000
speed : dlSpeed ?? 0,
upspeed : ulSpeed ?? 0,
eta : eta ?? -1,
save_path : '',
category : label ?? '',
}
}
const UT: TorrentClientDriver = {
definition: {
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: '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 },
],
},
async test(config) {
try {
await utGetSession(config)
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}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
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}`)
return { online: true, version }
} catch (err) {
logger.debug('utorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const session = await utGetSession(config)
const data = await utRequest(config, { list: '1' }, session)
const torrents: any[][] = data?.torrents ?? []
return torrents
.filter(t => {
if (!category) return true
return t[11] === category // index 11 = label
})
.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] ?? ''
return info
})
},
async add(config, url, options?: DownloadOptions) {
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
const hash = hashMatch?.[1]?.toLowerCase() ?? null
if (options?.file_index != null && hash) {
const exists = await utTorrentExists(config, hash)
if (exists) {
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
utApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
}
const session = await utGetSession(config)
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url })
if (config.savePath) qs.set('path', String(config.savePath))
const res = await fetch(`${config.url}/gui/?${qs}`, {
method : 'GET',
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`)
if (options?.file_index != null && hash) {
logger.info('utorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
utApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
} else {
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
}
// Appliquer le label/catégorie si défini
if (config.category) {
setTimeout(async () => {
try {
const s2 = await utGetSession(config)
const data = await utRequest(config, { list: '1' }, s2)
const torrents: any[][] = data?.torrents ?? []
const target = torrents.find(t => !t[11])
if (target) {
await utRequest(config, {
action: 'setprops',
hash : target[0],
s : 'label',
v : String(config.category),
}, s2)
}
} catch {}
}, 3000)
}
},
async remove(config, hash, deleteFiles = false) {
const s = await utGetSession(config)
await utRequest(config, {
action: deleteFiles ? 'removedata' : 'remove',
hash : hash.toUpperCase(),
}, s)
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
export default UT
+2 -8
View File
@@ -1,12 +1,6 @@
<template>
<div class="min-h-screen bg-[#080c10] text-[#c8d8e8] font-mono">
<RouterView v-slot="{ Component }">
<keep-alive include="CatalogView">
<component :is="Component" />
</keep-alive>
</RouterView>
<ToastContainer />
</div>
<RouterView />
<ToastContainer />
</template>
<script setup lang="ts">
+149 -1
View File
@@ -1 +1,149 @@
@import "tailwindcss";
@import "tailwindcss";
/* ─── Tokens custom consommables via Tailwind v4 ─── */
@theme {
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-dim: var(--accent-dim);
--color-accent-muted: var(--accent-muted);
--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);
--color-primary: var(--text-primary);
--color-secondary: var(--text-secondary);
--color-muted: var(--text-muted);
--color-gold: var(--gold);
}
/* ─── 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);
--bg-shell: #111116;
--bg-sidebar: #0d0d14;
--bg-main: #16161e;
--bg-card: #111116;
--bg-hover: #1c1c28;
--bg-active: #22223a;
--border: #2a2a3a;
--border-light: #1e1e2e;
--text-primary: #e8e8f8;
--text-secondary: #9898b8;
--text-muted: #55556a;
--gold: #f5c842;
}
/* ─── 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;
}
/* ─── Base globale ─── */
*, *::before, *::after { box-sizing: border-box; }
html, body, #app {
height: 100%;
margin: 0;
padding: 0;
}
body {
background: var(--bg-main);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width: 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 {
.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;
}
.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;
}
.btn-ghost {
@apply text-muted px-2 py-1 text-sm rounded-md
hover:text-primary hover:bg-hover transition-colors cursor-pointer;
}
.settings-card {
@apply bg-card border border-border rounded-xl p-4;
}
.settings-label {
@apply text-xs text-muted block;
}
.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;
}
}
+96 -80
View File
@@ -1,74 +1,82 @@
<template>
<!-- Overlay -->
<div class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')">
<div class="bg-[#0d1219] border border-[#1e2d3d] w-full max-w-lg flex flex-col" style="max-height: 70vh">
<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">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-[#1e2d3d] shrink-0">
<div>
<div class="text-[10px] text-[#e8513a] tracking-widest mb-0.5">CHOISIR UN DOSSIER</div>
<div class="text-xs text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</div>
<!-- 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>
<button @click="$emit('cancel')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
</div>
<button @click="$emit('cancel')" class="text-[#5a7a94] hover:text-white transition-colors cursor-pointer text-lg leading-none">✕</button>
</div>
<!-- Chemin éditable -->
<div class="px-5 py-3 border-b border-[#1e2d3d] shrink-0 flex gap-2">
<input
v-model="inputPath"
@keydown.enter="navigateTo(inputPath)"
class="flex-1 bg-[#121920] border border-[#1e2d3d] text-xs text-white px-3 py-2 font-mono outline-none focus:border-[#e8513a] transition-colors"
placeholder="/"
/>
<button @click="navigateTo(inputPath)"
class="border border-[#1e2d3d] text-[#5a7a94] px-3 py-2 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
OK
</button>
</div>
<!-- Navigation retour -->
<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-[#5a7a94] hover:text-white transition-colors cursor-pointer border-b border-[#1e2d3d]">
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
..
</button>
</div>
<!-- Liste dossiers -->
<div class="overflow-y-auto flex-1">
<div v-if="loading" class="flex items-center justify-center py-10 text-[#5a7a94] text-xs">
<div class="w-4 h-4 border border-white/10 border-t-[#e8513a] rounded-full animate-spin mr-2"/>
Chargement...
<!-- 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>
</button>
<input
v-model="inputPath"
@keydown.enter="navigateTo(inputPath)"
class="settings-input flex-1 font-mono text-xs"
placeholder="/"
/>
<button @click="navigateTo(inputPath)" class="btn-secondary px-3 text-xs">
OK
</button>
</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-[#5a7a94]">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-[#c8d8e8] hover:bg-white/5 transition-colors cursor-pointer border-b border-[#1e2d3d]/50 text-left"
>
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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>
{{ dir }}
</button>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-5 py-4 border-t border-[#1e2d3d] shrink-0">
<span class="text-[10px] text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</span>
<button @click="$emit('select', current)"
class="bg-[#e8513a] text-white px-5 py-2 text-xs tracking-widest hover:opacity-85 transition-opacity cursor-pointer">
CHOISIR
</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"
>
<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>
..
</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 class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
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>
<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"
>
<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>
{{ 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 -->
<button @click="selectCurrent()" class="btn-primary">
Choisir
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
@@ -77,15 +85,25 @@ import { ref, watch } from 'vue'
const props = defineProps<{ initialPath?: string }>()
const emit = defineEmits<{ select: [path: string]; cancel: [] }>()
const current = ref(props.initialPath || '/')
const inputPath = ref(current.value)
const dirs = ref<string[]>([])
const parent = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
const current = ref(props.initialPath || '/')
const inputPath = ref(current.value)
const dirs = ref<string[]>([])
const parent = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
const drivesRoot = ref(false) // true quand la liste affiche les lecteurs Windows
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
function selectCurrent() {
emit('select', current.value.replace(/\/+$/, '') || '/')
}
function joinPath(base: string, name: string): string {
return base === '/' ? `/${name}` : `${base}/${name}`
// Lecteur Windows complet (ex: "C:\") → chemin absolu, pas de jointure
if (/^[A-Za-z]:\\$/.test(name)) return name
if (base === '/') return `/${name}`
const cleanBase = base.replace(/[\\/]+$/, '')
return `${cleanBase}/${name}`
}
async function navigateTo(p: string) {
@@ -95,15 +113,15 @@ async function navigateTo(p: string) {
try {
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
if (!res.ok) {
const data = await res.json()
error.value = data.error ?? 'Erreur'
error.value = (await res.json()).error ?? 'Erreur'
return
}
const data = await res.json()
current.value = data.path
inputPath.value = data.path
parent.value = data.parent
dirs.value = data.dirs
const data = await res.json()
current.value = data.path
inputPath.value = data.path
parent.value = data.parent
dirs.value = data.dirs
drivesRoot.value = data.drivesRoot ?? false
} catch {
error.value = 'Impossible de lire ce dossier'
} finally {
@@ -111,7 +129,5 @@ async function navigateTo(p: string) {
}
}
watch(() => props.initialPath, v => {
if (v) navigateTo(v)
}, { immediate: true })
watch(() => props.initialPath, v => { if (v) navigateTo(v) }, { immediate: true })
</script>
+336
View File
@@ -0,0 +1,336 @@
<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">
<!-- 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>
<button @click="$emit('close')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</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>
</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>
</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>
</div>
<!-- Liste fichiers -->
<template v-else-if="items.length > 0">
<!-- 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>
</p>
<button @click="scan" class="text-xs text-muted hover:text-primary transition-colors">↺ Rescanner</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="item.alreadyImported
? 'border-blue-500/20 bg-blue-500/5'
: item.episode_id
? 'border-green-500/20 bg-green-500/5'
: 'border-border bg-shell'"
>
<!-- 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>
<!-- 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">
{{ 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>
</p>
</div>
<!-- Sélecteur épisode -->
<select
v-model="items[i].episode_id"
class="settings-input text-xs py-1 max-w-[260px] shrink-0"
@change="onEpisodeChange(i)"
>
<option :value="null">— Non associé —</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"
:key="ep.id"
: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) ? '✓' : '' }}
</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>.
</p>
<div class="flex gap-2">
<button @click="$emit('close')" class="btn-secondary">Annuler</button>
<button
@click="doImport"
:disabled="newMatchedCount === 0 || importing"
class="btn-primary"
>
{{ importing ? 'Import…' : `Importer ${newMatchedCount} fichier${newMatchedCount > 1 ? 's' : ''}` }}
</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' : '' }}
</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>
<button @click="$emit('close'); $emit('imported')" class="btn-primary">Fermer</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const props = defineProps<{
serieId : number
serieName : string
seasons : any[]
organized : Record<string, any>
initialPath: string
}>()
const emit = defineEmits<{
close : []
imported: []
}>()
// ─── State ────────────────────────────────────────────────────
const step = ref<'list' | 'done'>('list')
const scanning = ref(false)
const importing = ref(false)
const importError = ref('')
const scanError = ref('')
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
// Dossier de la série dans la médiathèque
const serieTitle = computed(() =>
props.serieName.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
)
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
}
const items = ref<FileItem[]>([])
// ─── Computed ──────────────────────────────────────────────────
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 ──────────────────────────────────────────────────
function isEpisodeOrganized(epId: number): boolean {
return !!props.organized?.[String(epId)]
}
function isEpisodeAlreadyUsed(epId: number, currentIndex: number): boolean {
return items.value.some((item, i) => i !== currentIndex && item.episode_id === epId)
}
function getHashForEpisode(episodeId: number): string | null {
for (const season of props.seasons) {
for (const ep of season.episodes) {
if (ep.id !== episodeId) continue
for (const p of ep.paths ?? []) {
if (typeof p === 'object' && p.infohash) return p.infohash.toLowerCase()
}
}
}
return null
}
function buildOrganizedByPath(): Map<string, number> {
const index = new Map<string, number>()
for (const [epIdStr, entry] of Object.entries(props.organized)) {
if (entry?.dest_path) index.set(entry.dest_path, Number(epIdStr))
}
return index
}
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]}`
}
function onEpisodeChange(i: number) {
items.value[i].hash = getHashForEpisode(Number(items.value[i].episode_id))
items.value[i].alreadyImported = false
}
// ─── Actions ──────────────────────────────────────────────────
async function scan() {
scanning.value = true
scanError.value = ''
items.value = []
try {
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'
return
}
const data = await res.json()
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 }
}
const match = autoMatch(f.name)
return { file: f, ...match, alreadyImported: false, willRename: match.episode_id !== null }
})
} catch {
scanError.value = 'Impossible de contacter le serveur'
} finally {
scanning.value = false
}
}
async function doImport() {
importing.value = true
importError.value = ''
const payload = items.value
.filter(i => i.episode_id !== null && !i.alreadyImported)
.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'
importing.value = false
return
}
try {
const res = await fetch('/api/manual-import', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ serie_id: props.serieId, items: payload }),
})
const data = await res.json()
if (!res.ok) { importError.value = data.error ?? 'Erreur inconnue'; return }
importResult.value = { done: data.done, errors: data.errors ?? [] }
step.value = 'done'
} catch {
importError.value = 'Impossible de contacter le serveur'
} finally {
importing.value = false
}
}
onMounted(scan)
</script>
+32 -12
View File
@@ -1,22 +1,42 @@
<template>
<Teleport to="body">
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2">
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
<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="{
'border-[#3ae87a] text-[#3ae87a]': t.type === 'success',
'border-[#e8513a] text-[#e8513a]': t.type === 'error',
'border-[#3a8fe8] text-[#3a8fe8]': t.type === 'info',
'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',
}"
class="bg-[#0d1219] border px-4 py-3 text-xs tracking-wider cursor-pointer flex items-center gap-3 min-w-64"
>
<span v-if="t.type === 'success'">✓</span>
<span v-else-if="t.type === 'error'">✕</span>
<span v-else>·</span>
{{ t.message }}
<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="flex-1 text-xs 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="{
'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',
}"
>
×{{ t.count }}
</span>
</div>
</TransitionGroup>
</div>
@@ -30,7 +50,7 @@ const { toasts, remove } = useToast()
<style scoped>
.toast-enter-active { transition: all 0.2s ease; }
.toast-leave-active { transition: all 0.2s ease; }
.toast-enter-from { opacity: 0; transform: translateX(20px); }
.toast-leave-to { opacity: 0; transform: translateX(20px); }
.toast-leave-active { transition: all 0.15s ease; }
.toast-enter-from { opacity: 0; transform: translateY(8px) scale(0.97); }
.toast-leave-to { opacity: 0; transform: translateY(4px) scale(0.97); }
</style>
@@ -0,0 +1,122 @@
<template>
<div>
<!-- Barre search + tri + colonnes -->
<div class="flex items-center gap-2 mb-4">
<div class="relative flex-1 max-w-xs">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
<input
:value="search"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
type="text" placeholder="Rechercher..."
class="settings-input pl-9 w-full py-1.5 text-sm"
/>
</div>
<!-- Tri -->
<div class="relative shrink-0" ref="sortRef">
<button @click="sortOpen = !sortOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="activeSort !== 'none' ? 'border-accent text-accent' : ''">
<ArrowUpDown :size="14" />
<span class="hidden md:inline text-xs">Trier</span>
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
</button>
<div v-if="sortOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl">
<button v-for="s in sortOptions" :key="s.value" @click="handleSort(s.value)"
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'">
{{ s.label }}
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
</button>
</div>
</div>
<!-- Colonnes -->
<div class="relative shrink-0" ref="colRef">
<button @click="colOpen = !colOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="colOpen ? 'border-accent text-accent' : ''">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="hidden md:inline text-xs">Colonnes</span>
</button>
<div v-if="colOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-52 flex flex-col gap-0.5 shadow-xl">
<p class="text-[10px] text-muted px-3 py-1">Colonnes affichées</p>
<label v-for="col in columnOptions" :key="col.key" class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs cursor-pointer hover:bg-hover transition-colors">
<input type="checkbox" :checked="columns[col.key]" @change="emit('toggle-col', col.key)" class="accent-accent" />
{{ col.label }}
</label>
</div>
</div>
</div>
<!-- Onglets + filtres -->
<div class="flex items-center justify-between mb-4">
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
<button @click="emit('update:activeTab', 'active')" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'active' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
En cours
<span v-if="activeCount > 0" class="ml-1.5 text-[10px] text-accent">{{ activeCount }}</span>
</button>
<button @click="emit('update:activeTab', 'done')" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'done' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
Terminés
<span v-if="doneCount > 0" class="ml-1.5 text-[10px] text-accent">{{ doneCount }}</span>
</button>
</div>
<div v-if="activeTab === 'done'" class="flex items-center gap-3">
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-muted">Seeds uniquement</span>
<button @click="emit('update:seedingOnly', !seedingOnly)" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="seedingOnly ? 'bg-green-500' : 'bg-border'">
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="seedingOnly ? 'translate-x-[18px]' : 'translate-x-0'" />
</button>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-muted">Masquer importés</span>
<button @click="emit('update:hideImported', !hideImported)" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="hideImported ? 'bg-accent' : 'bg-border'">
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'" />
</button>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Search, ArrowUpDown } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
const props = defineProps<{
search : string
activeSort : string
sortDir : 'asc' | 'desc'
activeTab : 'active' | 'done'
seedingOnly : boolean
hideImported: boolean
columns : Record<string, boolean>
activeCount : number
doneCount : number
columnOptions: { key: string; label: string }[]
sortOptions : { label: string; value: string }[]
}>()
const emit = defineEmits<{
'update:search' : [value: string]
'update:activeTab' : [value: 'active' | 'done']
'update:seedingOnly': [value: boolean]
'update:hideImported': [value: boolean]
'sort' : [value: string]
'toggle-col' : [key: string]
}>()
const sortOpen = ref(false)
const colOpen = ref(false)
const sortRef = ref<HTMLElement | null>(null)
const colRef = ref<HTMLElement | null>(null)
onClickOutside(sortRef, () => { sortOpen.value = false })
onClickOutside(colRef, () => { colOpen.value = false })
function handleSort(val: string) {
emit('sort', val)
sortOpen.value = false
}
</script>
+157
View File
@@ -0,0 +1,157 @@
<template>
<div class="settings-card flex flex-col gap-3">
<!-- Nom + état -->
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<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>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-semibold text-primary">{{ clampedProgress }}%</p>
<p v-if="columns.size" class="text-xs text-muted mt-0.5">{{ formatSize(torrent.downloaded) }} / {{ formatSize(torrent.size) }}</p>
</div>
</div>
<!-- Barre de progression -->
<div class="h-1 bg-border rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500"
:class="torrent.state === 'seeding' || torrent.state === 'unknown' ? 'bg-green-500' : torrent.state === 'error' ? 'bg-red-500' : torrent.state === 'paused' ? 'bg-muted' : 'bg-accent'"
:style="{ width: `${clampedProgress}%` }" />
</div>
<!-- Infos + actions -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 text-xs text-muted">
<span v-if="torrent.state === 'downloading'">↓ {{ formatSpeed(torrent.speed) }}</span>
<span v-if="torrent.state === 'downloading' && torrent.eta > 0">ETA {{ formatEta(torrent.eta) }}</span>
<template v-if="torrent.state === 'seeding'">
<span class="text-green-500">Complété</span>
<span v-if="columns.ratio" class="font-mono" :class="torrent.ratio >= 1 ? 'text-green-400' : torrent.ratio >= 0.5 ? 'text-yellow-500' : 'text-muted'">R {{ torrent.ratio?.toFixed(2) ?? '0.00' }}</span>
<span v-if="columns.uploaded">↑ {{ formatSize(torrent.uploaded ?? 0) }}</span>
<span v-if="columns.upspeed && torrent.upspeed > 0">{{ formatSpeed(torrent.upspeed) }}</span>
</template>
<span v-if="torrent.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
</div>
<div class="flex items-center gap-2">
<!-- Actions terminés -->
<template v-if="torrent.state === 'seeding' || torrent.state === 'unknown'">
<!-- Erreurs -->
<div v-if="torrent.errorFiles?.length > 0" class="relative group/err">
<span class="text-xs px-2 py-0.5 rounded border border-red-500/40 text-red-400 cursor-default">{{ torrent.errorFiles.length }} erreur{{ torrent.errorFiles.length > 1 ? 's' : '' }}</span>
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
<div class="bg-card border border-red-500/30 rounded-lg p-3 text-xs shadow-xl">
<p class="text-red-400 mb-2 font-medium">Fichiers en erreur</p>
<div v-for="e in torrent.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
<p class="text-primary truncate">{{ e.file }}</p>
<p class="text-muted">{{ e.error }}</p>
</div>
</div>
</div>
</div>
<!-- Badge import -->
<span class="text-xs px-2 py-0.5 rounded border"
:class="torrent.organizeState === 'done' ? 'border-green-500/40 text-green-500' : torrent.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' : 'border-border text-muted'">
{{ torrent.organizeState === 'done'
? `Importé (${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total})`
: torrent.organizeState === 'partial'
? `En cours (${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total})`
: 'Non importé' }}
</span>
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing" class="btn-secondary text-xs py-1">
{{ 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>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
torrent : any
tab : 'active' | 'done'
columns : Record<string, boolean>
importing : boolean
deleting : boolean
showConfirmDelete: boolean
}>()
const emit = defineEmits<{
import : [torrent: any]
delete : [torrent: any, withFiles: boolean]
'toggle-confirm': [hash: string | null]
}>()
const withFiles = ref(false)
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.torrent.progress ?? 0)))
function stateBadge(state: string) {
return ({
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
paused : { label: 'En pause', class: 'border-border text-muted' },
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
} as Record<string, any>)[state] ?? { label: 'Inconnu', class: 'border-border text-muted' }
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
function formatSpeed(bps: number) { return `${formatSize(bps)}/s` }
function formatEta(seconds: number): string {
if (seconds < 0 || seconds > 86400 * 7) return '∞'
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
return `${s}s`
}
</script>
+138
View File
@@ -0,0 +1,138 @@
<template>
<aside class="flex flex-col h-full bg-sidebar border-r border-border">
<!-- 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
</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>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 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" />
<SidebarNavItem v-else :item="item" />
</template>
</nav>
<!-- Nouvelle version disponible -->
<a
v-if="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"
>
<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>
</a>
<!-- Version actuelle -->
<div class="px-3 pb-1 pt-0.5">
<p class="text-[10px] text-muted/50 font-mono">{{ currentVersion }}</p>
</div>
<!-- Thème -->
<ThemeSwitcher />
</aside>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { LogOut } from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
import SidebarNavItem from './SidebarNavItem.vue'
import ThemeSwitcher from './ThemeSwitcher.vue'
import type { NavItem } from '@/types/nav'
defineProps<{ items: NavItem[] }>()
const router = useRouter()
const auth = useAuthStore()
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 GITHUB_REPO = 'masutayunikon/FanKarr'
function parseVersion(v: string): number[] {
return v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
}
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
}
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 {}
}
async function logout() {
await auth.logout()
router.push('/auth')
}
onMounted(() => {
checkForUpdates()
// Revérifier toutes les 6h
setInterval(checkForUpdates, 6 * 60 * 60 * 1000)
})
</script>
+102
View File
@@ -0,0 +1,102 @@
<template>
<div>
<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()"
>
<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>
</template>
<script setup lang="ts">
import { ref, computed, watch } 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
}
</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
@@ -0,0 +1,45 @@
<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>
+74
View File
@@ -0,0 +1,74 @@
<template>
<div class="relative">
<div v-if="serie.fanart_image" class="absolute inset-0 h-64 overflow-hidden pointer-events-none">
<img
:src="serie.fanart_image"
class="w-full h-full object-cover opacity-15"
@error="(e) => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-main" />
</div>
<div class="relative px-4 md:px-8 pt-6 pb-10 flex items-start gap-6 min-h-[200px]">
<!-- Poster -->
<div class="shrink-0 w-32 aspect-[2/3] rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
<img v-if="serie.poster_image" :src="serie.poster_image" class="w-full h-full object-cover" />
<div v-else class="w-full aspect-[2/3] bg-card flex items-center justify-center text-muted">
<Tv :size="28" />
</div>
</div>
<!-- Infos -->
<div class="flex flex-col gap-3 justify-end min-w-0">
<RouterLink to="/series" class="inline-flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-fit">
<ArrowLeft :size="13" />
Séries
</RouterLink>
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ serie.title }}</h1>
<!-- Métadonnées -->
<div class="flex flex-wrap items-center gap-2">
<span v-if="serie.year" class="text-sm text-muted">{{ serie.year }}</span>
<span
v-if="serie.status"
class="text-xs px-2 py-0.5 rounded-full border"
:class="serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/15 text-green-400 border-green-500/20'
: 'bg-hover text-muted border-border'"
>{{ serie.status }}</span>
</div>
<p
v-if="serie.plot"
@click="expanded = !expanded"
class="text-sm text-secondary leading-relaxed max-w-2xl cursor-pointer select-none transition-all"
:class="expanded ? '' : 'line-clamp-3'"
>
{{ serie.plot }}
</p>
<!-- Lien wiki -->
<a
v-if="serie.wiki"
:href="serie.wiki"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors w-fit"
>
<ExternalLink :size="12" />
Wiki
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { ArrowLeft, Tv, ExternalLink } from 'lucide-vue-next'
defineProps<{ serie: any }>()
const expanded = ref(false)
</script>
+672
View File
@@ -0,0 +1,672 @@
<template>
<div class="bg-card border border-border rounded-xl">
<!-- Header saison -->
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
<div class="flex items-center gap-3">
<button class="text-muted hover:text-primary transition-colors" @click="emit('toggle', season.id)">
<ChevronUp :size="15" class="transition-transform duration-200" :class="collapsed ? 'rotate-180' : ''" />
</button>
<div>
<h2 class="text-sm font-semibold text-primary">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted font-normal ml-1">
— {{ season.title }}
</span>
</h2>
<p class="text-xs text-muted mt-0.5">
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
<template v-if="availableCount > 0"> · {{ availableCount }}/{{ season.episodes.length }} dispo</template>
<span
v-if="season.organized_state !== 'none'"
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px]"
:class="season.organized_state === 'complete' ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-500'"
>
{{ season.organized_state === 'complete'
? `✓ ${season.organized_count} importé${season.organized_count > 1 ? 's' : ''}`
: `${season.organized_count}/${availableCount || season.episodes.length} importés` }}
</span>
</p>
</div>
</div>
<!-- Boutons saison -->
<div class="flex items-center gap-2">
<!-- Corbeille saison -->
<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>
<button
v-else
@click="emit('downloadSeason', season)"
:disabled="downloadingSeason"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border bg-accent-muted text-accent border-accent/20 hover:bg-accent/20 transition"
>
<component :is="downloadingSeason ? loaderIcon : downloadIcon" />
{{ downloadingSeason ? 'Envoi…' : 'Saison' }}
</button>
</template>
<!-- Pack saison unique -->
<button
v-if="season.torrents.length === 1"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="canDownloadSeason
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
: 'text-muted border-border cursor-not-allowed opacity-50'"
:disabled="!canDownloadSeason"
@click="canDownloadSeason && emit('download', `season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<component :is="seasonBtnIcon" />
{{ seasonBtnLabel }}
</button>
<!-- Dropdown packs saison multiples -->
<div v-if="season.torrents.length > 1" class="relative" @click.stop>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
@click="seasonMenuOpen = !seasonMenuOpen"
>
<component :is="downloadIcon" />
Saison ▾
</button>
<div v-if="seasonMenuOpen" class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
<button
v-for="(t, i) in season.torrents"
:key="i"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
:class="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
:disabled="isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)"
@click="!isDownloaded(`season-${season.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `season-${season.id}-${i}`, t.torrent_url, t.magnet), seasonMenuOpen = false)"
>
<component :is="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
{{ groupLabels(season.torrents)[i] }}
</button>
</div>
</div>
</div>
</div>
<!-- Progression saison -->
<div v-if="seasonProgress && seasonProgress.progress < 100" class="px-5 pb-3 -mt-1">
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${seasonProgress.progress}%` }" />
</div>
<p class="text-[11px] text-muted mt-1">{{ seasonProgress.progress }}%</p>
</div>
<!-- Épisodes -->
<div v-if="!collapsed" class="divide-y divide-border/50">
<div
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">
<button
v-for="(t, i) in ep.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(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
:disabled="isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)"
@click="!isDownloaded(`ep-${ep.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `ep-${ep.id}-${i}`, t.torrent_url, t.magnet, t.file_index ?? null, t.file_path ?? null, t.infohash ?? null), epOptionsOpen = null)"
>
<component :is="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
{{ groupLabels(ep.torrents)[i] }}
</button>
</div>
</div>
<!-- Actions épisode importé -->
<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
@click.stop="openUnimportModal(ep, season)"
:disabled="epActionLoading[ep.id]"
class="w-6 h-6 flex items-center justify-center rounded text-red-400 hover:bg-red-500/10 transition-colors"
title="Désimporter l'épisode"
>
<Trash2 :size="12" />
</button>
</template>
<!-- Chevron synopsis (à droite, visible si l'épisode a un plot) -->
<button
v-if="ep.plot"
@click.stop="togglePlot(ep.id)"
class="w-6 h-6 flex items-center justify-center rounded transition-colors shrink-0"
:class="plotOpen === ep.id ? 'text-accent' : 'text-muted hover:text-primary'"
title="Voir le synopsis"
>
<ChevronUp :size="14" class="transition-transform duration-200" :class="plotOpen === ep.id ? '' : 'rotate-180'" />
</button>
</div>
<!-- Barre progression épisode -->
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="mt-1.5 sm:pl-[92px]">
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${epProgress(ep)!.progress}%` }" />
</div>
</div>
<!-- Synopsis -->
<div v-if="plotOpen === ep.id && ep.plot" class="mt-2 sm:pl-[92px]">
<p class="text-xs text-muted leading-relaxed" style="white-space: pre-line">{{ parseEpChap(ep.plot).cleanPlot }}</p>
</div>
</div>
</div>
</div>
<!-- Modal désimport épisode -->
<Teleport to="body">
<div v-if="unimportModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportModal = false">
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter l'épisode</h3>
<p class="text-xs text-muted mb-4">L'épisode sera retiré de la bibliothèque.</p>
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
<input type="checkbox" v-model="deleteFileOnUnimport" class="accent-red-500" />
<span class="text-xs text-muted">Supprimer le fichier du disque</span>
</label>
<div class="flex gap-2 justify-end">
<button @click="unimportModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
<button @click="confirmUnimport" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
{{ deleteFileOnUnimport ? 'Supprimer' : 'Désimporter' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Modal désimport saison -->
<Teleport to="body">
<div v-if="unimportSeasonModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportSeasonModal = false">
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter la saison</h3>
<p class="text-xs text-muted mb-4">Tous les épisodes importés de cette saison seront retirés de la bibliothèque.</p>
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
<input type="checkbox" v-model="deleteSeasonFiles" class="accent-red-500" />
<span class="text-xs text-muted">Supprimer les fichiers du disque</span>
</label>
<div class="flex gap-2 justify-end">
<button @click="unimportSeasonModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
<button @click="confirmUnimportSeason" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
{{ deleteSeasonFiles ? 'Supprimer' : 'Désimporter' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
import { ChevronUp, Download, Loader, Check, X, Trash2 } from 'lucide-vue-next'
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[] }
const props = defineProps<{
season : any
collapsed : boolean
activeTorrents : ActiveTorrent[]
downloading : string[]
downloaded : string[]
organizedByEpisode: Record<string, any>
epActionLoading : Record<number, boolean>
downloadingSeason ?: boolean
nfoSupport ?: boolean
}>()
const emit = defineEmits<{
toggle : [id: number]
download : [key: string, url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null]
downloadSeason : [season: any, packHash?: string]
renameEpisode : [ep: any, season: any]
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
unimportSeason : [season: any, deleteFiles: boolean]
}>()
const epOptionsOpen = ref<number | null>(null)
const plotOpen = ref<number | null>(null)
const seasonMenuOpen = ref(false)
const unimportModal = ref(false)
const deleteFileOnUnimport = ref(false)
const unimportEpTarget = ref<{ ep: any; season: any } | null>(null)
const unimportSeasonModal = ref(false)
const deleteSeasonFiles = ref(false)
const downloadIcon = h(Download, { size: 12 })
const loaderIcon = h(Loader, { size: 12, class: 'animate-spin' })
const checkIcon = h(Check, { size: 12 })
// ── Helpers hash / progress ────────────────────────────────────
function extractHash(torrent: any): string | null {
// Préférer l'infohash direct (présent même si le magnet est absent)
if (torrent?.infohash) return torrent.infohash.toLowerCase()
if (!torrent?.magnet) return null
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
return m ? m[1].toLowerCase() : null
}
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
if (!hash) return null
return props.activeTorrents.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
}
function isDownloading(key: string) { return props.downloading.includes(key) }
function isDownloaded(key: string) { return props.downloaded.includes(key) }
function isAlreadyQueued(torrent: any): boolean {
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas :
// le pack peut être actif sans que CE fichier spécifique soit en téléchargement.
// On s'appuie uniquement sur downloading/downloaded pour ces cas.
if (torrent?.file_index != null) return false
const hash = extractHash(torrent)
if (!hash) return false
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
// ── Computed saison ────────────────────────────────────────────
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
const seasonProgress = computed(() => {
if (props.season.torrents && props.season.torrents.length > 1) {
for (const t of props.season.torrents) {
const prog = torrentProgress(extractHash(t))
if (prog) return prog
}
return null
}
return torrentProgress(extractHash(props.season.torrent))
})
const hasDownloadable = computed(() =>
props.season.episodes.some((ep: any) =>
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)
)
)
const canDownloadSeason = computed(() =>
!!props.season.torrent && !isDownloading(`season-${props.season.id}`) && !isDownloaded(`season-${props.season.id}`) && !isAlreadyQueued(props.season.torrent) && props.season.organized_state !== 'complete'
)
const seasonBtnLabel = computed(() => {
if (props.season.organized_state === 'complete') return 'Importé'
if (isAlreadyQueued(props.season.torrent)) return 'Déjà ajouté'
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyé'
return 'Saison entière'
})
const seasonBtnIcon = computed(() => {
if (props.season.organized_state === 'complete' || isDownloaded(`season-${props.season.id}`) || isAlreadyQueued(props.season.torrent)) return h(Check, { size: 12 })
if (isDownloading(`season-${props.season.id}`)) return h(Loader, { size: 12, class: 'animate-spin' })
if (!props.season.torrent) return h(X, { size: 12 })
return h(Download, { size: 12 })
})
// ── État épisode ───────────────────────────────────────────────
// Vérifie si n'importe quelle clé de téléchargement pour cet épisode est marquée done/downloading
// (couvre ep-{id} pour torrent unique ET ep-{id}-{i} pour multi-torrent)
function epAnyDownloaded(ep: any): boolean {
if (isDownloaded(`ep-${ep.id}`)) return true
const count = ep.torrents?.length ?? 0
for (let i = 0; i < count; i++) { if (isDownloaded(`ep-${ep.id}-${i}`)) return true }
return false
}
function epAnyDownloading(ep: any): boolean {
if (isDownloading(`ep-${ep.id}`)) return true
const count = ep.torrents?.length ?? 0
for (let i = 0; i < count; i++) { if (isDownloading(`ep-${ep.id}-${i}`)) return true }
return false
}
function epProgress(ep: any): ActiveTorrent | null {
if (ep.organized) return null
// Chercher parmi toutes les options de torrent, pas seulement ep.torrent (= torrents[0])
const candidates: Array<{ torrent: any; key0: string; keyI: string }> = []
if (ep.torrents?.length > 0) {
ep.torrents.forEach((t: any, i: number) => candidates.push({ torrent: t, key0: `ep-${ep.id}`, keyI: `ep-${ep.id}-${i}` }))
} else if (ep.torrent) {
candidates.push({ torrent: ep.torrent, key0: `ep-${ep.id}`, keyI: `ep-${ep.id}` })
}
for (const { torrent: t, key0, keyI } of candidates) {
const active = torrentProgress(extractHash(t))
if (!active) continue
if (t.file_index != null) {
const sessionDl = isDownloaded(key0) || isDownloaded(keyI)
if (active.files && active.files.length > 0) {
const file = active.files.find(f => f.index === t.file_index)
// Avec données par fichier on se fie à la progression réelle du fichier précis.
// Si le fichier est à 100% sans session active → déjà téléchargé hors session
// (ex : désimporté puis re-consulté) → ne pas afficher le spinner.
if (file != null && file.priority !== 0 && (sessionDl || (file.progress > 0 && file.progress < 1))) {
return { ...active, progress: Math.round(file.progress * 100) }
}
if (!sessionDl) continue
} else {
// Pas de données par fichier : ignorer si pas de session active
if (!sessionDl) continue
return { ...active, progress: active.state === 'seeding' ? 100 : active.progress }
}
}
return active
}
return null
}
function handleEpBtnClick(ep: any) {
if (ep.organized) return
if (ep.torrents && ep.torrents.length > 1) {
// Plusieurs options → toggle dropdown
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
return
}
// Option unique → téléchargement direct
if (ep.torrent && !isAlreadyQueued(ep.torrent)) {
emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet, ep.torrent.file_index ?? null, ep.torrent.file_path ?? null, ep.torrent.infohash ?? null)
}
}
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
// Vert uniquement quand importé dans la bibliothèque
if (ep.organized) return 'done'
// Spinner si : envoyé au client, déjà en cours dans le client, ou fichier en cours/terminé
if (epAnyDownloaded(ep) || epAnyDownloading(ep) || isAlreadyQueued(ep.torrent)) return 'loading'
const prog = epProgress(ep)
if (prog) return 'loading'
if (!ep.torrent || !ep.available) return 'unavailable'
return 'idle'
}
function epStateIcon(ep: any) {
const state = epState(ep)
if (state === 'done') return h(Check, { size: 14 })
if (state === 'loading') return h(Loader, { size: 14, class: 'animate-spin' })
if (state === 'unavailable') return h(X, { size: 14 })
return h(Download, { size: 14 })
}
function epBtnClass(ep: any): string {
const state = epState(ep)
if (state === 'done') return 'border-green-500/30 text-green-500 bg-green-500/10 cursor-default'
if (state === 'loading') return 'border-accent/30 text-accent bg-accent-muted cursor-default'
if (state === 'unavailable') return 'border-border text-muted opacity-40 cursor-not-allowed'
return 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'
}
// ── Rename helpers ─────────────────────────────────────────────
function epExpectedName(ep: any): string {
const entry = props.organizedByEpisode[String(ep.id)]
if (!entry) return ''
const srcExt = entry.dest_filename ? '.' + entry.dest_filename.split('.').pop() : '.mkv'
if (props.nfoSupport) {
return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename
} else {
// Préférer le formatted_name du torrent qui correspond au fichier importé.
// On essaie de matcher par hash (si l'entrée organized l'expose), sinon on
// cherche le premier torrent qui a un formatted_name.
const usedHash = entry.hash?.toLowerCase() ?? null
const matchedName = usedHash
? (ep.torrents ?? []).find((t: any) => t.infohash === usedHash)?.formatted_name
: null
const name = matchedName
?? (ep.torrents ?? []).find((t: any) => t.formatted_name)?.formatted_name
?? ep.formatted_name
return name?.trim() ? name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : entry.dest_filename
}
}
function epNeedsRename(ep: any): boolean {
const entry = props.organizedByEpisode[String(ep.id)]
if (!entry) return false
return entry.needs_rename === true
}
// ── Extraction épisodes / chapitres depuis le synopsis ─────────
// Cache léger pour éviter de recalculer pour chaque accès template (badges + synopsis)
const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>()
/**
* Extrait les plages d'épisodes et chapitres depuis un synopsis.
* Ex: "...épisodes 628 à 634 soit les chapitres 700 à 706."
* → { episodes: "628–634", chapters: "700–706", cleanPlot: "..." }
* La phrase (ou ligne) contenant ces infos est retirée du synopsis affiché.
*/
function _parseEpChap(plot: string | null | undefined): {
episodes: string | null
chapters: string | null
cleanPlot: string
} {
const text = plot ?? ''
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
const episodes = epMatch
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`)
: null
const chapters = chapMatch
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
: null
// Retirer les lignes contenant les références d'épisodes ou chapitres
let cleanPlot = text
if (episodes || chapters) {
cleanPlot = text
.split(/\r?\n/)
.filter(line => !/[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+/i.test(line))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
return { episodes, chapters, cleanPlot }
}
function parseEpChap(plot: string | null | undefined) {
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
const result = _parseEpChap(plot)
_epChapCache.set(plot, result)
return result
}
// ── Détection langue ───────────────────────────────────────────
function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
const sources: string[] = [
ep.formatted_name ?? '',
ep.torrent?.torrent_name ?? '',
ep.torrent?.raw ?? '',
...((ep.torrents ?? []) as any[]).map((t: any) => `${t.torrent_name ?? ''} ${t.raw ?? ''}`),
]
for (const s of sources) {
if (!s) continue
if (/\bMULTI\b/i.test(s)) return 'MULTI'
if (/\bVOSTFR\b/i.test(s)) return 'VOSTFR'
}
return null
}
// ── Labels de groupe ───────────────────────────────────────────
// Si tous les torrent_name sont présents ET distincts → on les utilise.
// Sinon → titre Nyaa brut (raw), plus informatif quand les noms se ressemblent.
function labelFromRaw(raw: string, index: number): string {
if (!raw) return `Option ${index + 1}`
return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
}
function groupLabels(torrents: any[]): string[] {
const names = torrents.map((t: any) => t.torrent_name ?? null)
const allPresent = names.every(n => n !== null && String(n).trim() !== '')
const allUnique = allPresent && new Set(names).size === names.length
if (allUnique) return names as string[]
// Fallback : titre Nyaa brut
return torrents.map((t: any, i: number) => labelFromRaw(t.raw ?? '', i))
}
// Packs parents distincts couvrant cette saison (quand pas de pack saison explicite)
// Permet le dropdown "Saison ▾" quand une saison est couverte par plusieurs intégrales
const uniquePackOptions = computed(() => {
if (props.season.torrents?.length) return []
const seen = new Set<string>()
const raw: { infohash: string; torrent: 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 (raw.length <= 1) return []
const labels = groupLabels(raw.map(r => r.torrent))
return raw.map((r, i) => ({ infohash: r.infohash, label: labels[i] }))
})
function formatDate(d: string): string {
if (!d) return ''
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
}
function formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return ''
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return m > 0 ? `${h}h ${m}min` : `${h}h`
return `${m} min`
}
function togglePlot(epId: number) {
plotOpen.value = plotOpen.value === epId ? null : epId
}
function openUnimportModal(ep: any, season: any) {
unimportEpTarget.value = { ep, season }
deleteFileOnUnimport.value = false
unimportModal.value = true
}
function confirmUnimport() {
if (!unimportEpTarget.value) return
emit('unimportEpisode', unimportEpTarget.value.ep, unimportEpTarget.value.season, deleteFileOnUnimport.value)
unimportModal.value = false
unimportEpTarget.value = null
}
function openUnimportSeasonModal() {
deleteSeasonFiles.value = false
unimportSeasonModal.value = true
}
function confirmUnimportSeason() {
emit('unimportSeason', props.season, deleteSeasonFiles.value)
unimportSeasonModal.value = false
}
function closeAllMenus() {
epOptionsOpen.value = null
seasonMenuOpen.value = false
}
onMounted(() => document.addEventListener('click', closeAllMenus))
onUnmounted(() => document.removeEventListener('click', closeAllMenus))
defineExpose({ closeEpMenu: closeAllMenus })
</script>
+152
View File
@@ -0,0 +1,152 @@
<template>
<div>
<!-- Toolbar -->
<div class="shrink-0 px-4 md:px-6 py-3 border-b border-border flex flex-col gap-2">
<!-- Ligne 1 : recherche + filtres -->
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
<input
:value="search"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
type="text"
placeholder="Rechercher…"
class="settings-input pl-9 w-full py-1.5 text-sm"
/>
</div>
<!-- Filtres -->
<div class="relative shrink-0" ref="filterRef">
<button
@click="filterOpen = !filterOpen"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 relative"
:class="hasActiveFilter ? 'border-accent text-accent' : ''"
title="Filtres"
>
<SlidersHorizontal :size="15" />
<span class="hidden md:inline text-xs">Filtres</span>
<ChevronDown class="hidden md:inline transition-transform" :size="12" :class="filterOpen ? 'rotate-180' : ''" />
<span v-if="hasActiveFilter" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
</button>
<div
v-if="filterOpen"
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-3 z-20 w-52 flex flex-col gap-3 shadow-xl"
>
<div>
<p class="text-xs text-muted mb-1.5">Disponibilité</p>
<div class="flex flex-col gap-1">
<button
v-for="f in filtersDisponibilite" :key="f.value"
@click="emit('update:activeFilter', f.value)"
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
{{ f.label }}
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
</button>
</div>
</div>
<div class="border-t border-border pt-3">
<p class="text-xs text-muted mb-1.5">État import</p>
<div class="flex flex-col gap-1">
<button
v-for="f in filtersImport" :key="f.value"
@click="emit('update:activeFilter', f.value)"
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full shrink-0" :style="{ background: f.color }" />
{{ f.label }}
</div>
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
</button>
</div>
</div>
<button
v-if="hasActiveFilter"
@click="emit('update:activeFilter', 'all')"
class="text-xs text-muted hover:text-primary transition-colors text-left pt-1 border-t border-border"
>
Effacer les filtres
</button>
</div>
</div>
</div>
<!-- Ligne 2 : tri + taille + count -->
<div class="flex items-center gap-2 flex-wrap">
<div class="flex items-center gap-1">
<button
v-for="s in sortOptions" :key="s.value"
@click="emit('update:activeSort', s.value)"
class="px-2.5 py-1 text-xs rounded-lg border transition-colors"
:class="activeSort === s.value
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ s.label }}
</button>
</div>
<div class="flex items-center gap-1 border-l border-border pl-2 ml-auto">
<button
v-for="(_, size) in posterSizes" :key="size"
@click="emit('update:posterSize', size)"
class="w-7 h-7 text-[10px] rounded-md border transition-colors flex items-center justify-center"
:class="posterSize === size
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ size }}
</button>
</div>
<span class="text-xs text-muted">{{ count }} série{{ count > 1 ? 's' : '' }}</span>
</div>
</div>
<!-- Légende barres -->
<div class="shrink-0 px-4 md:px-6 py-2 flex items-center gap-4 border-b border-border">
<div v-for="l in legend" :key="l.label" class="flex items-center gap-1.5">
<span class="w-3 h-1.5 rounded-full" :style="{ background: l.color }" />
<span class="text-[11px] text-muted">{{ l.label }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
defineProps<{
search : string
activeFilter : string
activeSort : string
posterSize : string
posterSizes : Record<string, string>
count : number
hasActiveFilter : boolean
filtersDisponibilite: { label: string; value: string }[]
filtersImport : { label: string; value: string; color: string }[]
sortOptions : { label: string; value: string }[]
legend : { label: string; color: string }[]
}>()
const emit = defineEmits<{
'update:search' : [value: string]
'update:activeFilter': [value: string]
'update:activeSort' : [value: string]
'update:posterSize' : [value: string]
}>()
const filterOpen = ref(false)
const filterRef = ref<HTMLElement | null>(null)
onClickOutside(filterRef, () => { filterOpen.value = false })
</script>
+304
View File
@@ -0,0 +1,304 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="emit('close')">
<div class="bg-card border border-border rounded-xl w-full max-w-lg flex flex-col" style="max-height: 85vh">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div>
<h3 class="text-sm font-semibold text-primary">Configuration Plex</h3>
<p class="text-xs text-muted mt-0.5">
{{ plexStep === 'auth' ? 'Connectez votre compte Plex' :
plexStep === 'server' ? 'Choisissez votre serveur Plex' :
plexStep === 'library' ? 'Créez la bibliothèque Fankai' :
plexStep === 'done' ? 'Configuration terminée' :
'Erreur' }}
</p>
</div>
<button @click="emit('close')" class="text-muted hover:text-primary text-lg leading-none">✕</button>
</div>
<!-- Étape 1 : Auth -->
<div v-if="plexStep === 'auth'" class="flex flex-col gap-4 px-6 py-5">
<div v-if="!plexAuthMethod" class="flex flex-col gap-3">
<button
@click="startOAuth" :disabled="plexLoading"
class="flex items-center gap-3 p-4 rounded-lg border border-border hover:border-accent hover:bg-accent-muted transition-colors text-left"
>
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#E5A50A" opacity=".2"/>
<path d="M12 6v6l4 2" stroke="#E5A50A" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-primary">{{ plexLoading ? 'Ouverture…' : 'Connexion via Plex' }}</p>
<p class="text-xs text-muted">Google, Apple, ou compte Plex</p>
</div>
</button>
<button
@click="plexAuthMethod = 'credentials'"
class="flex items-center gap-3 p-4 rounded-lg border border-border hover:border-secondary transition-colors text-left"
>
<div class="w-8 h-8 rounded-lg bg-hover flex items-center justify-center shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-primary">Email / Mot de passe</p>
<p class="text-xs text-muted">Connexion directe avec vos identifiants</p>
</div>
</button>
</div>
<!-- OAuth en attente -->
<div v-else-if="plexAuthMethod === 'oauth'" class="flex flex-col items-center gap-4 py-4 text-center">
<div class="w-10 h-10 border-2 border-border border-t-accent rounded-full animate-spin" />
<div>
<p class="text-sm text-primary font-medium">En attente de la connexion Plex…</p>
<p class="text-xs text-muted mt-1">Connectez-vous sur la page Plex qui s'est ouverte dans votre navigateur</p>
</div>
<button @click="cancelOAuth" class="text-xs text-muted hover:text-primary transition-colors">Annuler</button>
</div>
<!-- Formulaire credentials -->
<template v-else-if="plexAuthMethod === 'credentials'">
<div>
<label class="settings-label mb-1 block">Adresse e-mail Plex</label>
<input v-model="plexForm.username" type="email" class="settings-input w-full" placeholder="email@example.com" />
</div>
<div>
<label class="settings-label mb-1 block">Mot de passe</label>
<input v-model="plexForm.password" type="password" class="settings-input w-full" placeholder="••••••••" @keyup.enter="plexConnect" />
</div>
<div v-if="plexNeeds2FA">
<label class="settings-label mb-1 block">Code 2FA</label>
<input v-model="plexForm.code" type="text" class="settings-input w-full" placeholder="123456" maxlength="6" @keyup.enter="plexConnect" />
</div>
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
<div class="flex gap-2">
<button @click="plexConnect" :disabled="plexLoading || !plexForm.username || !plexForm.password" class="btn-primary">
{{ plexLoading ? 'Connexion…' : 'Se connecter' }}
</button>
<button @click="plexAuthMethod = null" class="btn-secondary">Retour</button>
</div>
</template>
<p v-if="plexError && !plexAuthMethod" class="text-xs text-red-400">{{ plexError }}</p>
<button v-if="!plexAuthMethod" @click="emit('close')" class="btn-secondary self-start">Annuler</button>
</div>
<!-- Étape 2 : Choix serveur -->
<div v-else-if="plexStep === 'server'" class="flex flex-col gap-3 px-6 py-5">
<p class="text-xs text-muted">{{ plexServers.length }} serveur{{ plexServers.length > 1 ? 's' : '' }} trouvé{{ plexServers.length > 1 ? 's' : '' }}</p>
<div
v-for="(server, i) in plexServers" :key="i"
@click="selectServer(server)"
class="flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors"
:class="plexSelectedServer === server ? 'border-accent bg-accent-muted' : 'border-border hover:border-secondary'"
>
<div>
<p class="text-sm text-primary font-medium">{{ server.name }}</p>
<p class="text-xs text-muted">{{ server.owned ? 'Propriétaire' : 'Partagé' }} · {{ server.connections.length }} connexion{{ server.connections.length > 1 ? 's' : '' }}</p>
</div>
<svg v-if="plexSelectedServer === server" width="14" height="14" viewBox="0 0 24 24" stroke="#e5a50a" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
<div class="flex gap-2 mt-1">
<button @click="plexConnectServer" :disabled="!plexSelectedServer || plexLoading" class="btn-primary">
{{ plexLoading ? 'Connexion…' : 'Continuer' }}
</button>
<button @click="plexStep = 'auth'" class="btn-secondary">Retour</button>
</div>
</div>
<!-- Étape 3 : Bibliothèque -->
<div v-else-if="plexStep === 'library'" class="flex flex-col gap-4 px-6 py-5">
<div>
<label class="settings-label mb-1 block">Nom de la bibliothèque</label>
<input v-model="plexForm.libraryName" type="text" class="settings-input w-full" placeholder="Fankai" />
</div>
<div>
<label class="settings-label mb-1 block">Chemin de la médiathèque</label>
<p class="text-xs text-muted mb-1.5">Chemin tel que vu par votre serveur Plex</p>
<input v-model="plexForm.libraryPath" type="text" class="settings-input w-full font-mono" placeholder="/data/media/fankai" />
</div>
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
<div class="flex gap-2">
<button @click="plexSetup" :disabled="plexLoading || !plexForm.libraryName || !plexForm.libraryPath" class="btn-primary">
{{ plexLoading ? 'Création…' : 'Créer la bibliothèque' }}
</button>
<button @click="plexStep = 'server'" class="btn-secondary">Retour</button>
</div>
</div>
<!-- Étape 4 : Résultat -->
<div v-else-if="plexStep === 'done'" class="flex flex-col gap-4 px-6 py-5">
<div class="flex flex-col gap-2">
<div v-for="s in plexSteps" :key="s.step" class="flex items-center gap-2.5">
<div class="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
:class="s.ok ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'">
<svg v-if="s.ok" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2" fill="none"><line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/></svg>
</div>
<p class="text-xs" :class="s.ok ? 'text-primary' : 'text-red-400'">{{ s.message }}</p>
</div>
</div>
<div v-if="plexManualSetup" class="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-3">
<p class="text-xs text-yellow-400 font-medium mb-2">⚠ Configuration manuelle de l'agent requise (Plex &lt; 1.43)</p>
<ol class="text-xs text-muted space-y-1 list-decimal list-inside">
<li>Rendez-vous sur la page <span class="text-primary">Metadata Agent</span> de Plex</li>
<li>Dans <span class="text-primary">Metadata Provider</span>, ajoutez : <code class="bg-hover px-1 rounded">https://metadata.fankai.fr/plex</code></li>
<li>Dans <span class="text-primary">Metadata Agent</span>, créez un agent avec ce provider</li>
<li>Dans votre bibliothèque → ⋯ → <span class="text-primary">Manage Library → Edit → Advanced → Agent</span>, sélectionnez l'agent Fankai</li>
<li>Rafraîchissez les métadonnées</li>
</ol>
</div>
<button @click="emit('close')" class="btn-primary">Fermer</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
const props = defineProps<{ mediaPath: string }>()
const emit = defineEmits<{ close: [] }>()
const plexStep = ref<'auth' | 'server' | 'library' | 'done'>('auth')
const plexLoading = ref(false)
const plexError = ref('')
const plexNeeds2FA = ref(false)
const plexToken = ref('')
const plexServers = ref<any[]>([])
const plexSelectedServer = ref<any>(null)
const plexServerUrl = ref('')
const plexManualSetup = ref(false)
const plexSteps = ref<{ step: string; ok: boolean; message: string }[]>([])
const plexAuthMethod = ref<'oauth' | 'credentials' | null>(null)
const plexOAuthPinId = ref<string | null>(null)
let plexOAuthTimer: ReturnType<typeof setInterval> | null = null
const plexForm = ref({
username : '',
password : '',
code : '',
libraryName: 'Fankai',
libraryPath: props.mediaPath,
})
// ── OAuth ─────────────────────────────────────────────────────
async function startOAuth() {
plexError.value = ''
plexLoading.value = true
try {
const res = await fetch('/api/plex/oauth/start', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { plexError.value = data.error ?? 'Erreur OAuth'; return }
plexOAuthPinId.value = String(data.pinId)
plexAuthMethod.value = 'oauth'
window.open(data.authUrl, '_blank')
let attempts = 0
plexOAuthTimer = setInterval(async () => {
attempts++
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai expiré — réessayez'; return }
try {
const r = await fetch(`/api/plex/oauth/poll/${plexOAuthPinId.value}`, { credentials: 'include' })
const d = await r.json()
if (d.ok && !d.pending) {
cancelOAuth()
plexToken.value = d.token
plexServers.value = d.servers
if (d.servers.length === 1) { plexSelectedServer.value = d.servers[0]; await plexConnectServer() }
else plexStep.value = 'server'
}
} catch {}
}, 2000)
} catch {
plexError.value = "Impossible de démarrer l'authentification"
} finally {
plexLoading.value = false
}
}
function cancelOAuth() {
if (plexOAuthTimer) { clearInterval(plexOAuthTimer); plexOAuthTimer = null }
plexOAuthPinId.value = null
if (plexAuthMethod.value === 'oauth') plexAuthMethod.value = null
}
// ── Credentials ───────────────────────────────────────────────
async function plexConnect() {
plexError.value = ''
plexLoading.value = true
try {
const res = await fetch('/api/plex/connect', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ username: plexForm.value.username, password: plexForm.value.password, code: plexForm.value.code || undefined }),
})
const data = await res.json()
if (!res.ok) {
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez votre code 2FA' }
else plexError.value = data.error ?? 'Erreur de connexion'
return
}
plexToken.value = data.token
plexServers.value = data.servers
if (data.servers.length === 1) { plexSelectedServer.value = data.servers[0]; await plexConnectServer() }
else plexStep.value = 'server'
} catch {
plexError.value = 'Impossible de contacter le serveur'
} finally {
plexLoading.value = false
}
}
function selectServer(server: any) { plexSelectedServer.value = server }
async function plexConnectServer() {
if (!plexSelectedServer.value) return
plexError.value = ''
plexLoading.value = true
try {
const conns = plexSelectedServer.value.connections ?? []
const conn = conns.find((c: any) => c.local && !c.relay) ?? conns.find((c: any) => !c.local && !c.relay) ?? conns.find((c: any) => c.relay)
plexServerUrl.value = conn?.uri ?? ''
if (!plexServerUrl.value) throw new Error('Aucune URL de connexion disponible')
plexForm.value.libraryPath = props.mediaPath
plexStep.value = 'library'
} catch (err) {
plexError.value = err instanceof Error ? err.message : 'Erreur'
} finally {
plexLoading.value = false
}
}
async function plexSetup() {
plexError.value = ''
plexLoading.value = true
try {
const res = await fetch('/api/plex/setup', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ token: plexToken.value, serverUrl: plexServerUrl.value, libraryName: plexForm.value.libraryName, libraryPath: plexForm.value.libraryPath }),
})
const data = await res.json()
plexSteps.value = data.steps ?? []
plexManualSetup.value = data.manualSetup ?? false
plexStep.value = 'done'
if (!data.ok) plexError.value = data.error ?? 'Erreur lors du setup'
} catch {
plexError.value = 'Impossible de contacter le serveur'
} finally {
plexLoading.value = false
}
}
onUnmounted(() => cancelOAuth())
</script>
@@ -0,0 +1,23 @@
<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>
<button
@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'"
>
<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'"
/>
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{ modelValue: boolean; label: string; description?: string }>()
defineEmits(['update:modelValue'])
</script>
+26
View File
@@ -0,0 +1,26 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'fankarr-poster-size'
const sizes = {
S : '120px',
M : '150px',
L : '180px',
XL : '220px',
} as const
export type PosterSize = keyof typeof sizes
const current = ref<PosterSize>(
(localStorage.getItem(STORAGE_KEY) as PosterSize) ?? 'M'
)
watch(current, (val) => localStorage.setItem(STORAGE_KEY, val))
export function usePosterSize() {
return {
current,
sizes,
minmax: () => `minmax(${sizes[current.value]}, 1fr)`,
}
}
+43 -14
View File
@@ -1,25 +1,54 @@
import { ref } from 'vue'
interface Toast {
id: number
message: string
type: 'success' | 'error' | 'info'
id : number
message : string
type : 'success' | 'error' | 'info'
count : number
timer : ReturnType<typeof setTimeout>
}
const toasts = ref<Toast[]>([])
let nextId = 0
const DURATION = 3500
const GROUP_WINDOW = 800 // ms — toasts du même type dans cette fenêtre sont groupés
function add(message: string, type: Toast['type'] = 'info') {
const now = Date.now()
// Cherche un toast récent du même type à grouper
const existing = toasts.value.find(t => t.type === type)
if (existing) {
// Grouper — incrémenter le count et mettre à jour le message
clearTimeout(existing.timer)
existing.count++
existing.message = buildGroupMessage(type, existing.count)
existing.timer = setTimeout(() => remove(existing.id), DURATION)
return
}
// Nouveau toast
const id = nextId++
const timer = setTimeout(() => remove(id), DURATION)
toasts.value.push({ id, message, type, count: 1, timer })
}
function buildGroupMessage(type: Toast['type'], count: number): string {
if (type === 'success') return `${count} opérations réussies`
if (type === 'error') return `${count} erreurs`
return `${count} notifications`
}
function remove(id: number) {
const i = toasts.value.findIndex(t => t.id === id)
if (i !== -1) {
clearTimeout(toasts.value[i].timer)
toasts.value.splice(i, 1)
}
}
export function useToast() {
function add(message: string, type: Toast['type'] = 'info', duration = 3000) {
const id = nextId++
toasts.value.push({ id, message, type })
setTimeout(() => remove(id), duration)
}
function remove(id: number) {
const idx = toasts.value.findIndex(t => t.id === id)
if (idx !== -1) toasts.value.splice(idx, 1)
}
return { toasts, add, remove }
}
+89
View File
@@ -0,0 +1,89 @@
<template>
<div class="flex h-screen bg-shell overflow-hidden">
<div class="hidden md:flex w-56 shrink-0">
<SidebarNav :items="navItems" class="w-full" />
</div>
<Transition name="fade">
<div v-if="mobileOpen" class="fixed inset-0 bg-black/60 z-40 md:hidden" @click="mobileOpen = false" />
</Transition>
<Transition name="slide-left">
<div v-if="mobileOpen" class="fixed top-0 left-0 h-full w-56 z-50 md:hidden">
<SidebarNav :items="navItems" class="w-full" />
</div>
</Transition>
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-sidebar shrink-0 relative z-10">
<button @click="mobileOpen = true" class="text-muted hover:text-primary transition-colors">
<Menu :size="20" />
</button>
<span class="text-sm font-medium text-primary">FanKarr</span>
</header>
<main id="main-scroll" class="flex-1 overflow-y-auto bg-main">
<RouterView v-slot="{ Component }">
<keep-alive include="SeriesView">
<component :is="Component" class="h-full" />
</keep-alive>
</RouterView>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Menu } from 'lucide-vue-next'
import SidebarNav from '@/components/nav/SidebarNav.vue'
import { useDownloadsStore } from '@/stores/downloads'
import type { NavItem } from '@/types/nav'
const mobileOpen = ref(false)
const dlStore = useDownloadsStore()
const navItems = computed<NavItem[]>(() => [
{
label: 'Dashboard',
icon : 'LayoutDashboard',
to : '/dashboard',
},
{
label : 'Médiathèque',
icon : 'Tv',
to : '/series',
children: [
{ label: 'Séries', to: '/series' },
],
},
{
label: 'Activité',
icon : 'Activity',
to : '/activity',
badge: dlStore.activeCount > 0 ? dlStore.activeCount : undefined,
},
{ separator: true },
{
label : 'Paramètres',
icon : 'Settings',
to : '/settings',
children: [
{ label: 'Clients de téléchargement', to: '/settings/download-client' },
{ label: 'Gestion des médias', to: '/settings/media-management' },
{ label: 'Management des séries', to: '/settings/import-management' },
{ label: 'Catalogue Fankai', to: '/settings/catalogue' },
{ label: 'Journaux', to: '/settings/logs' },
{ label: 'Avancé', to: '/settings/advanced' },
],
},
])
</script>
<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-left-enter-active, .slide-left-leave-active { transition: transform 0.25s ease; }
.slide-left-enter-from, .slide-left-leave-to { transform: translateX(-100%); }
</style>
+84 -23
View File
@@ -1,42 +1,103 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AppLayout from '@/layouts/AppLayout.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
// ─── Page publique hors layout ───────────────────────────
{
path: '/auth',
component: () => import('@/views/AuthView.vue'),
meta: { public: true }
meta: { public: true },
},
// ─── App principale avec layout sidebar ──────────────────
{
path: '/',
name: 'catalog',
component: () => import('@/views/CatalogView.vue')
component: AppLayout,
children: [
// Redirections des anciennes URLs
{ path: '', redirect: '/series' },
{ path: 'downloads', redirect: '/activity' },
// Dashboard
{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
},
// Médiathèque
{
path: 'series',
name: 'series',
component: () => import('@/views/SeriesView.vue'),
},
{
path: 'series/:id',
name: 'serie-detail',
component: () => import('@/views/SerieView.vue'),
},
// Activité (ex-downloads)
{
path: 'activity',
name: 'activity',
component: () => import('@/views/DownloadsView.vue'),
},
// Paramètres — layout imbriqué avec sous-routes
{
path: 'settings',
component: () => import('@/views/settings/SettingsLayout.vue'),
children: [
{ path: '', redirect: '/settings/download-client' },
{
path: 'download-client',
name: 'settings-download',
component: () => import('@/views/settings/DownloadClientView.vue'),
},
{
path: 'media-management',
name: 'settings-media',
component: () => import('@/views/settings/MediaManagementView.vue'),
},
{
path: 'import-management',
name: 'setting-import',
component: () => import('@/views/settings/ImportManagementView.vue'),
},
{
path: 'catalogue',
name: 'settings-catalogue',
component: () => import('@/views/settings/CatalogueView.vue'),
},
{
path: 'logs',
name: 'settings-logs',
component: () => import('@/views/settings/LogsView.vue'), // ← nouveau chemin
},
{
path: 'advanced',
name: 'settings-advanced',
component: () => import('@/views/settings/AdvancedView.vue'),
},
],
},
],
},
{
path: '/series/:id',
component: () => import('@/views/SerieView.vue')
},
{
path: '/downloads',
component: () => import('@/views/DownloadsView.vue')
},
{
path: '/settings',
component: () => import('@/views/SettingsView.vue')
},
{
path: '/logs',
component: () => import('@/views/LogsView.vue')
}
],
scrollBehavior(to, _from, savedPosition) {
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
return { top: 0 }
}
// Retour depuis le détail vers la liste — ne pas scroller en haut
if (to.name === 'series' && from.name === 'serie-detail') return false
return { el: '#main-scroll', top: 0 }
},
})
// ─── Guard auth (inchangé) ────────────────────────────────────
router.beforeEach(async (to) => {
const auth = useAuthStore()
@@ -49,7 +110,7 @@ router.beforeEach(async (to) => {
}
if (to.path === '/auth' && auth.loggedIn) {
return '/'
return '/series'
}
})
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useDownloadsStore = defineStore('downloads', () => {
const activeCount = ref(0)
async function refresh() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (!res.ok) return
const torrents = await res.json()
activeCount.value = torrents.filter((t: any) => t.state === 'downloading').length
} catch {
activeCount.value = 0
}
}
return { activeCount, refresh }
})
+2 -2
View File
@@ -100,12 +100,12 @@ export const useSeriesStore = defineStore('series', () => {
}
}
async function download(torrent_url: string | null, magnet: string | null): Promise<{ success: boolean; error?: string }> {
async function download(torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null): Promise<{ success: boolean; error?: string }> {
try {
const res = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ torrent_url, magnet })
body: JSON.stringify({ torrent_url, magnet, file_index: file_index ?? null, file_path: file_path ?? null, infohash: infohash ?? null })
})
const data = await res.json()
if (res.ok) return { success: true }
+13
View File
@@ -0,0 +1,13 @@
export interface NavChild {
label: string
to: string
}
export interface NavItem {
label? : string
icon? : string
to? : string
children? : NavChild[]
separator?: true
badge? : string | number // ← badge optionnel
}
+34 -68
View File
@@ -1,69 +1,49 @@
<template>
<div class="min-h-screen bg-[#080c10] flex items-center justify-center px-4">
<div class="min-h-screen bg-shell flex items-center justify-center px-4">
<div class="w-full max-w-sm">
<!-- Logo -->
<div class="flex items-baseline gap-1 mb-8 justify-center">
<span class="font-sans text-3xl font-black text-white tracking-tight">Fank</span>
<span class="font-sans text-3xl font-black text-[#e8513a] tracking-tight">Arr</span>
<div class="flex items-center justify-center gap-2.5 mb-8">
<svg class="w-8 h-8" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
</svg>
<span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span>
</div>
<!-- Card -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-8">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">
{{ isSetup ? 'PREMIÈRE CONNEXION' : 'CONNEXION' }}
<div class="settings-card p-8 flex flex-col gap-5">
<div>
<p class="text-xs text-accent font-medium mb-1">
{{ isSetup ? 'Première connexion' : 'Connexion' }}
</p>
<h1 class="text-lg font-semibold text-primary">
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
</h1>
</div>
<h1 class="text-lg font-black text-white font-sans mb-6">
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
</h1>
<!-- Form -->
<div class="flex flex-col gap-4">
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">IDENTIFIANT</label>
<input
v-model="username"
type="text"
placeholder="admin"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
@keyup.enter="submit"
/>
<label class="settings-label mb-1.5">Identifiant</label>
<input v-model="username" type="text" placeholder="admin" class="settings-input" @keyup.enter="submit" />
</div>
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">MOT DE PASSE</label>
<input
v-model="password"
type="password"
placeholder="••••••••"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
@keyup.enter="submit"
/>
<label class="settings-label mb-1.5">Mot de passe</label>
<input v-model="password" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
</div>
<div v-if="isSetup">
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">CONFIRMER</label>
<input
v-model="confirm"
type="password"
placeholder="••••••••"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
@keyup.enter="submit"
/>
<label class="settings-label mb-1.5">Confirmer</label>
<input v-model="confirm" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
</div>
<!-- Error -->
<div v-if="error" class="text-[#e8513a] text-xs">{{ error }}</div>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<!-- Submit -->
<button
@click="submit"
:disabled="submitting"
class="w-full bg-[#e8513a] text-white py-2.5 text-xs tracking-widest font-bold hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer mt-2"
>
{{ submitting ? '...' : isSetup ? 'CRÉER LE COMPTE' : 'SE CONNECTER' }}
<button @click="submit" :disabled="submitting" class="btn-primary w-full py-2.5 mt-1">
{{ submitting ? '...' : isSetup ? 'Créer le compte' : 'Se connecter' }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -74,42 +54,28 @@ import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const auth = useAuthStore()
const username = ref('')
const password = ref('')
const confirm = ref('')
const error = ref<string | null>(null)
const username = ref('')
const password = ref('')
const confirm = ref('')
const error = ref<string | null>(null)
const submitting = ref(false)
const isSetup = computed(() => !auth.setup)
async function submit() {
error.value = null
if (!username.value || !password.value) {
error.value = 'Tous les champs sont requis'
return
}
if (isSetup.value && password.value !== confirm.value) {
error.value = 'Les mots de passe ne correspondent pas'
return
}
if (!username.value || !password.value) { error.value = 'Tous les champs sont requis'; return }
if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas'; return }
submitting.value = true
const err = isSetup.value
? await auth.setupAccount(username.value, password.value)
: await auth.login(username.value, password.value)
submitting.value = false
if (err) {
error.value = err
return
}
if (err) { error.value = err; return }
await router.push('/')
}
</script>
-300
View File
@@ -1,300 +0,0 @@
<template>
<div class="min-h-screen bg-zinc-950 text-zinc-100">
<!-- Header -->
<header class="sticky top-0 z-50 bg-zinc-950/90 backdrop-blur border-b border-white/5">
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<router-link to="/" class="text-2xl font-serif tracking-tight hover:opacity-80 transition">
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
</router-link>
<div class="flex items-center gap-2">
<!-- Icône logs -->
<router-link to="/logs"
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
>
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
</router-link>
<router-link to="/downloads"
class="relative p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
>
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<!-- Badge compteur -->
<span v-if="activeDownloads > 0"
class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 bg-orange-500 text-white text-[9px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
{{ activeDownloads }}
</span>
</router-link>
<router-link
to="/settings"
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
>
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<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>
</router-link>
<button
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
@click="handleLogout"
>
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
</svg>
</button>
</div>
</div>
<!-- Search + Filters -->
<div class="max-w-7xl mx-auto px-6 pb-3 flex flex-wrap items-center gap-3">
<div class="relative flex-1 min-w-48 max-w-sm">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600 pointer-events-none" width="15" height="15" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input
v-model="search"
type="text"
placeholder="Rechercher…"
class="w-full bg-white/5 border border-white/[0.08] rounded-lg pl-9 pr-4 py-2 text-sm placeholder-zinc-600 outline-none focus:border-orange-500/50 transition"
/>
</div>
<div class="flex items-center gap-1">
<!-- Groupe 1 : état de la série -->
<button
v-for="f in filtersGroupe1"
:key="f.value"
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
:class="activeFilter === f.value
? 'bg-orange-500 border-orange-500 text-white'
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
@click="activeFilter = f.value"
>{{ f.label }}</button>
<!-- Séparateur -->
<span class="w-px h-5 bg-white/10 mx-1" />
<!-- Groupe 2 : état du téléchargement -->
<button
v-for="f in filtersGroupe2"
:key="f.value"
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
:class="activeFilter === f.value
? 'bg-blue-500 border-blue-500 text-white'
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
@click="activeFilter = f.value"
>{{ f.label }}</button>
</div>
<span class="text-xs text-zinc-600 ml-auto">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
</div>
</header>
<!-- Main -->
<main class="max-w-7xl mx-auto px-6 py-8">
<!-- Loading -->
<div v-if="store.loadingSeries" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
<p class="text-sm">Chargement du catalogue…</p>
</div>
<!-- Error -->
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
<svg width="36" height="36" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
</svg>
<p class="text-sm">{{ store.error }}</p>
<button class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm hover:bg-orange-600 transition" @click="store.fetchSeries()">
Réessayer
</button>
</div>
<!-- Empty -->
<div v-else-if="filtered.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-96 text-zinc-600">
<p class="text-sm">Aucune série trouvée</p>
</div>
<!-- Grid -->
<div v-else class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-5">
<router-link
v-for="serie in filtered"
:key="serie.id"
:to="`/series/${serie.id}`"
class="group flex flex-col gap-2 transition-transform duration-200 hover:-translate-y-1"
:class="{ 'opacity-40': !serie.has_torrents }"
>
<!-- Poster -->
<div class="relative aspect-[2/3] rounded-xl overflow-hidden bg-zinc-900 border border-white/5">
<img
v-if="serie.poster_image"
:src="serie.poster_image"
:alt="serie.title"
loading="lazy"
class="w-full h-full object-cover"
/>
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700">
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="20" rx="2"/>
<path d="m9 9 6 6M15 9l-6 6"/>
</svg>
</div>
<!-- Badge état téléchargement -->
<div v-if="serie.download_state !== 'none'"
class="absolute bottom-2 left-2 flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[9px] font-bold leading-none backdrop-blur-sm"
:class="serie.download_state === 'complete' ? 'bg-green-500/90 text-white' :
serie.download_state === 'partial' ? 'bg-orange-500/90 text-white' :
serie.download_state === 'downloading' ? 'bg-blue-500/90 text-white' : ''">
<!-- Icône -->
<svg v-if="serie.download_state === 'complete'" width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
<svg v-else-if="serie.download_state === 'downloading'" width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3" class="animate-pulse">
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<svg v-else width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{{ serie.download_state === 'complete' ? 'ORGANISÉ' : serie.download_state === 'downloading' ? 'EN COURS' : 'PARTIEL' }}
</div>
<!-- Overlay hover -->
<div class="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<span class="text-xs text-white border border-white/40 px-3 py-1.5 rounded-md">Voir</span>
</div>
</div>
<!-- Info -->
<div>
<p class="text-xs font-medium truncate leading-snug">{{ serie.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5">
<span v-if="serie.year" class="text-[11px] text-zinc-600">{{ serie.year }}</span>
<span
v-if="serie.status"
class="text-[10px] px-1.5 py-0.5 rounded"
:class="serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/10 text-green-400'
: 'bg-white/5 text-zinc-600'"
>{{ serie.status }}</span>
</div>
</div>
</router-link>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import { useSeriesStore } from '@/stores/series'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
const store = useSeriesStore()
const auth = useAuthStore()
const router = useRouter()
const { add: toast } = useToast()
const search = ref('')
const activeFilter = ref('all')
const activeDownloads = ref(0)
const filtersGroupe1 = [
{ label: 'Toutes', value: 'all' },
{ label: 'Disponibles', value: 'available' },
{ label: 'Sans torrent', value: 'unavailable' },
]
const filtersGroupe2 = [
{ label: 'DL complet', value: 'complete' },
{ label: 'DL partiel', value: 'downloading' },
]
const filtered = computed(() => {
let list = store.series
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
if (activeFilter.value === 'downloading') list = list.filter(s => s.download_state === 'downloading' || s.download_state === 'partial')
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(s => s.title.toLowerCase().includes(q))
}
return list
})
async function handleLogout() {
await auth.logout()
router.push('/auth')
}
// ── Polling downloads actifs + notifications organisation ──────
let dlInterval: ReturnType<typeof setInterval> | null = null
const seenNotifs = new Set<string>()
async function fetchActiveDownloads() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (!res.ok) return
const torrents = await res.json()
activeDownloads.value = torrents.filter((t: any) => t.state === 'downloading').length
} catch {}
}
async function fetchOrganizeNotifs() {
try {
const res = await fetch('/api/organize/recent', { credentials: 'include' })
if (!res.ok) return
const notifs: any[] = await res.json()
for (const n of notifs) {
const key = `${n.hash}-${n.at}`
if (seenNotifs.has(key)) continue
seenNotifs.add(key)
if (n.done > 0) {
const msg = n.errors > 0
? `${n.name} — ${n.done} fichier(s) organisé(s), ${n.errors} erreur(s)`
: `${n.name} — ${n.done} fichier(s) organisé(s) ✓`
toast(msg, n.errors > 0 ? 'error' : 'success')
}
}
} catch {}
}
// ── Cache images ───────────────────────────────────────────────
// Géré directement dans le store series.ts
onMounted(async () => {
if (store.series.length === 0) await store.fetchSeries()
fetchActiveDownloads()
fetchOrganizeNotifs()
dlInterval = setInterval(() => {
fetchActiveDownloads()
fetchOrganizeNotifs()
}, 10000)
})
// Refresh au retour sur le catalogue (après update ou scan)
onActivated(async () => {
await store.fetchSeries()
fetchActiveDownloads()
})
onUnmounted(() => {
if (dlInterval) clearInterval(dlInterval)
})
</script>
+234
View File
@@ -0,0 +1,234 @@
<template>
<div class="px-8 py-8 max-w-4xl">
<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Catalogue</span>
<span class="text-2xl font-semibold text-primary">{{ stats.catalogue }}</span>
<span class="text-xs text-muted">séries disponibles</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Importées</span>
<span class="text-2xl font-semibold text-green-500">{{ stats.imported }}</span>
<span class="text-xs text-muted">{{ stats.catalogue > 0 ? Math.round(stats.imported / stats.catalogue * 100) : 0 }}% du catalogue</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">En cours</span>
<span class="text-2xl font-semibold text-accent">{{ stats.downloading }}</span>
<span class="text-xs text-muted">téléchargements</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Erreurs</span>
<span class="text-2xl font-semibold" :class="stats.errors > 0 ? 'text-red-400' : 'text-muted'">{{ stats.errors }}</span>
<span class="text-xs text-muted">{{ stats.errors === 0 ? 'aucune erreur' : 'à corriger' }}</span>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4 mb-6">
<!-- Téléchargements actifs -->
<div class="settings-card flex flex-col gap-3">
<h2 class="text-sm font-medium text-secondary">Téléchargements actifs</h2>
<div v-if="loadingTorrents" class="flex items-center gap-2 text-muted text-xs py-4">
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
</div>
<div v-else-if="activeTorrents.length === 0" class="text-xs text-muted py-4">Aucun téléchargement en cours</div>
<div v-else class="flex flex-col gap-3">
<div v-for="t in activeTorrents" :key="t.hash" class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-primary truncate">{{ t.name }}</span>
<span class="text-xs text-muted shrink-0">{{ t.progress }}%</span>
</div>
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="t.state === 'seeding' ? 'bg-green-500' : 'bg-accent'" :style="{ width: `${t.progress}%` }" />
</div>
<div class="flex items-center justify-between">
<span class="text-[11px] text-muted">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</span>
<span v-if="t.state === 'downloading' && t.eta > 0" class="text-[11px] text-muted">{{ formatEta(t.eta) }}</span>
</div>
</div>
</div>
</div>
<!-- Derniers imports -->
<div class="settings-card flex flex-col gap-3">
<h2 class="text-sm font-medium text-secondary">Derniers imports</h2>
<div v-if="loadingNotifs" class="flex items-center gap-2 text-muted text-xs py-4">
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
</div>
<div v-else-if="recentNotifs.length === 0" class="text-xs text-muted py-4">Aucun import récent</div>
<div v-else class="flex flex-col divide-y divide-border/50">
<div v-for="n in recentNotifs" :key="n.hash + n.at" class="flex items-start gap-3 py-2.5 first:pt-0 last:pb-0">
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0 mt-0.5" :class="n.errors > 0 ? 'bg-red-500/10' : 'bg-green-500/10'">
<svg v-if="n.errors === 0" width="10" height="10" viewBox="0 0 12 12" stroke="#22c55e" 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="#f87171" stroke-width="2.5" fill="none"><line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/></svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-primary truncate">{{ n.name }}</p>
<p class="text-[11px] text-muted mt-0.5">
{{ n.done }} fichier{{ n.done > 1 ? 's' : '' }} importé{{ n.done > 1 ? 's' : '' }}
<span v-if="n.errors > 0" class="text-red-400"> · {{ n.errors }} erreur{{ n.errors > 1 ? 's' : '' }}</span>
</p>
</div>
<span class="text-[11px] text-muted shrink-0">{{ formatRelative(n.at) }}</span>
</div>
</div>
</div>
</div>
<!-- Panneau debug -->
<div v-if="devMode" class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<h2 class="text-sm font-medium text-secondary">Debug</h2>
<span class="text-[10px] px-1.5 py-0.5 rounded border border-accent/30 text-accent">DEV</span>
<button @click="fetchDebug" class="ml-auto text-xs text-muted hover:text-primary transition-colors">↺ Rafraîchir</button>
</div>
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Heap utilisé</span>
<span class="text-xl font-semibold text-primary">{{ debug.memory.heapUsed }} MB</span>
<span class="text-xs text-muted">/ {{ debug.memory.heapTotal }} MB total</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">RSS (process)</span>
<span class="text-xl font-semibold text-primary">{{ debug.memory.rss }} MB</span>
<span class="text-xs text-muted">mémoire système</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Uptime</span>
<span class="text-xl font-semibold text-primary">{{ debug.uptime }}</span>
<span class="text-xs text-muted">depuis le démarrage</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Cache GitHub</span>
<span class="text-xl font-semibold text-primary">{{ debug.cache.entries }}</span>
<span class="text-xs text-muted">entrées (TTL {{ debug.cache.ttlHours }}h)</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Fichiers trackés</span>
<span class="text-xl font-semibold text-primary">{{ debug.organized.trackedFiles }}</span>
<span class="text-xs text-muted">dans organized.json</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Worker organize</span>
<span class="text-xl font-semibold" :class="debug.worker.running ? 'text-accent' : 'text-muted'">
{{ debug.worker.running ? 'Actif' : 'Inactif' }}
</span>
<span class="text-xs text-muted">{{ debug.requests.notifs }} notif(s) en mémoire</span>
</div>
</div>
<div v-else class="settings-card text-xs text-muted py-4 text-center">Chargement des stats...</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const loadingTorrents = ref(true)
const loadingNotifs = ref(true)
const devMode = ref(false)
const debug = ref<any>(null)
const stats = ref({ catalogue: 0, imported: 0, downloading: 0, errors: 0 })
const activeTorrents = ref<any[]>([])
const recentNotifs = ref<any[]>([])
let interval: ReturnType<typeof setInterval> | null = null
async function fetchSettings() {
const res = await fetch('/api/settings', { credentials: 'include' })
if (res.ok) {
const s = await res.json()
devMode.value = s.devMode ?? false
if (devMode.value) fetchDebug()
}
}
async function fetchDebug() {
const res = await fetch('/api/debug/stats', { credentials: 'include' })
if (res.ok) debug.value = await res.json()
}
async function fetchStats() {
try {
const [statusRes, seriesRes, downloadsRes] = await Promise.all([
fetch('/api/torrents/status', { credentials: 'include' }),
fetch('/api/series', { credentials: 'include' }),
fetch('/api/downloads', { credentials: 'include' }),
])
if (statusRes.ok) { const s = await statusRes.json(); stats.value.catalogue = s.count ?? 0 }
if (seriesRes.ok) {
const s = await seriesRes.json()
const list = Array.isArray(s) ? s : (s.series ?? [])
stats.value.imported = list.filter((s: any) => s.download_state === 'complete').length
}
if (downloadsRes.ok) {
const torrents = await downloadsRes.json()
stats.value.downloading = torrents.filter((t: any) => t.state === 'downloading').length
stats.value.errors = torrents.filter((t: any) => t.state === 'error').length
}
} catch {}
}
async function fetchTorrents() {
const isFirst = loadingTorrents.value
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (res.ok) {
const list = await res.json()
activeTorrents.value = list.filter((t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state)).slice(0, 5)
}
} catch {} finally {
if (isFirst) loadingTorrents.value = false
}
}
async function fetchNotifs() {
loadingNotifs.value = true
try {
const res = await fetch('/api/organize/recent', { credentials: 'include' })
if (res.ok) recentNotifs.value = (await res.json()).slice(0, 6)
} catch {} finally {
loadingNotifs.value = false
}
}
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 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`
}
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000), h = Math.floor(diff / 3600000), d = Math.floor(diff / 86400000)
if (m < 1) return "à l'instant"
if (m < 60) return `il y a ${m}min`
if (h < 24) return `il y a ${h}h`
return `il y a ${d}j`
}
onMounted(() => {
Promise.all([fetchStats(), fetchTorrents(), fetchNotifs(), fetchSettings()])
interval = setInterval(() => {
fetchTorrents()
if (devMode.value) fetchDebug()
}, 10000)
})
onUnmounted(() => { if (interval) clearInterval(interval) })
</script>
+191 -268
View File
@@ -1,224 +1,177 @@
<template>
<div class="max-w-4xl mx-auto px-8 py-8">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<button @click="$router.push('/')"
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
← CATALOGUE
</button>
<h1 class="text-xl font-black text-white font-sans">Téléchargements</h1>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 text-[10px] text-[#5a7a94] tracking-widest">
<div class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-[#1e2d3d]'"/>
{{ polling ? 'EN DIRECT' : 'PAUSE' }}
</div>
<button @click="togglePolling"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
{{ polling ? 'PAUSE' : 'REPRENDRE' }}
</button>
</div>
</div>
<div>
<div class="px-8 py-8 max-w-4xl">
<!-- Résumé -->
<div class="grid grid-cols-4 gap-3 mb-6">
<div v-for="stat in stats" :key="stat.label" class="bg-[#0d1219] border border-[#1e2d3d] p-4">
<div class="text-[10px] text-[#5a7a94] tracking-widest mb-1">{{ stat.label }}</div>
<div class="text-lg font-bold text-white">{{ stat.value }}</div>
</div>
</div>
<!-- Onglets -->
<div class="flex items-center justify-between mb-4">
<div class="flex gap-0 border border-[#1e2d3d]">
<button
@click="activeTab = 'active'"
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer"
:class="activeTab === 'active'
? 'bg-[#e8513a] text-white border-[#e8513a]'
: 'text-[#5a7a94] hover:text-white'">
EN COURS
<span v-if="activeTorrents.length > 0"
class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
{{ activeTorrents.length }}
</span>
</button>
<button
@click="activeTab = 'done'"
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer border-l border-[#1e2d3d]"
:class="activeTab === 'done'
? 'bg-[#e8513a] text-white'
: 'text-[#5a7a94] hover:text-white'">
TERMINÉS
<span v-if="doneTorrents.length > 0"
class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
{{ doneTorrents.length }}
</span>
</button>
</div>
<!-- Toggle masquer organisés (onglet TERMINÉS uniquement) -->
<div v-if="activeTab === 'done'" class="flex items-center gap-2">
<button @click="hideOrganized = !hideOrganized"
class="relative shrink-0 w-8 h-4 rounded-full transition-colors cursor-pointer"
:class="hideOrganized ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform duration-200"
:class="hideOrganized ? 'translate-x-4' : 'translate-x-0'" />
</button>
<span class="text-[10px] text-[#5a7a94] tracking-widest">MASQUER ORGANISÉS</span>
</div>
</div>
<!-- Pas de clients -->
<div v-if="noClients" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
<p class="text-sm">Aucun client torrent configuré</p>
<router-link to="/settings"
class="text-[10px] tracking-widest text-[#e8513a] hover:opacity-75 transition-opacity">
CONFIGURER →
</router-link>
</div>
<!-- Loading -->
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
<div class="w-5 h-5 border-2 border-white/10 border-t-[#e8513a] rounded-full animate-spin"/>
<span class="text-sm">Chargement...</span>
</div>
<!-- Vide -->
<div v-else-if="!loading && visibleTorrents.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
<p class="text-sm">
{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}
</p>
<p class="text-[11px]">
{{ activeTab === 'active'
? 'Les torrents avec la catégorie « fankai » apparaîtront ici'
: hideOrganized ? 'Tous les torrents terminés sont organisés' : '' }}
</p>
</div>
<!-- Liste torrents -->
<div v-else class="flex flex-col gap-2">
<div v-for="t in visibleTorrents" :key="t.hash"
class="bg-[#0d1219] border border-[#1e2d3d] p-4">
<!-- Ligne principale -->
<div class="flex items-start justify-between gap-4 mb-3">
<div class="flex-1 min-w-0">
<p class="text-sm text-white font-medium truncate">{{ t.name }}</p>
<div class="flex items-center gap-3 mt-1">
<span class="text-[10px] tracking-widest px-2 py-0.5 border"
:class="stateBadge(t.state).class">
{{ stateBadge(t.state).label }}
</span>
<span class="text-[10px] text-[#5a7a94]">{{ t.client_name }}</span>
</div>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-bold text-white">{{ t.progress }}%</p>
<p class="text-[10px] text-[#5a7a94] mt-0.5">
{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}
</p>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<h1 class="text-lg font-semibold text-primary">Activité</h1>
<div class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full transition-colors" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'" />
<span class="text-xs text-muted">{{ polling ? 'En direct' : 'Pause' }}</span>
</div>
</div>
<!-- Barre de progression -->
<div class="h-1 bg-[#1e2d3d] rounded-full overflow-hidden mb-2">
<div class="h-full rounded-full transition-all duration-500"
:class="t.state === 'seeding' ? 'bg-green-500' :
t.state === 'error' ? 'bg-red-500' :
t.state === 'paused' ? 'bg-[#5a7a94]' : 'bg-[#e8513a]'"
:style="{ width: `${t.progress}%` }"/>
</div>
<!-- Infos bas -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-[10px] text-[#5a7a94]">
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
<span v-if="t.state === 'seeding'" class="text-green-500">✓ Complété</span>
</div>
<!-- Badge organisation (seulement pour les terminés) -->
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
<span class="text-[9px] tracking-widest px-2 py-0.5 border border-red-500/40 text-red-400 cursor-default">
{{ t.errorFiles.length }} ERREUR{{ t.errorFiles.length > 1 ? 'S' : '' }}
</span>
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
<div class="bg-[#0d1219] border border-red-500/30 p-3 text-[10px] shadow-xl">
<div class="text-red-400 tracking-widest mb-2">FICHIERS EN ERREUR</div>
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
<p class="text-white truncate">{{ e.file }}</p>
<p class="text-[#5a7a94]">{{ e.error }}</p>
</div>
</div>
</div>
</div>
<span class="text-[9px] tracking-widest px-2 py-0.5 border"
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
'border-[#1e2d3d] text-[#5a7a94]'">
{{ t.organizeState === 'done' ? `ORGANISÉ (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
t.organizeState === 'partial' ? `EN COURS (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
'NON ORGANISÉ' }}
</span>
<button v-if="t.organizeState !== 'done'"
@click="organize(t)"
:disabled="organizing[t.hash]"
class="text-[9px] tracking-widest px-3 py-1 border transition-colors cursor-pointer border-[#1e2d3d] text-[#5a7a94] hover:border-[#e8513a] hover:text-[#e8513a] disabled:opacity-50">
{{ organizing[t.hash] ? '...' : 'ORGANISER' }}
</button>
</div>
<div class="flex items-center gap-2">
<button @click="importAll" :disabled="importingAll" class="btn-primary">{{ importingAll ? '...' : 'Importer tout' }}</button>
<button @click="fetchTorrents" :disabled="loading" class="btn-secondary">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" :class="loading ? 'animate-spin' : ''">
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
<polyline points="16 5 21 5 21 10"/>
</svg>
</button>
<button @click="togglePolling" class="btn-secondary">{{ polling ? 'Pause' : 'Reprendre' }}</button>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-4 gap-3 mb-6">
<div v-for="stat in stats" :key="stat.label" class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">{{ stat.label }}</span>
<span class="text-xl font-semibold text-primary">{{ stat.value }}</span>
</div>
</div>
<!-- Toolbar + Tabs -->
<DownloadsToolbar
v-model:search="search"
v-model:active-tab="activeTab"
v-model:seeding-only="seedingOnly"
v-model:hide-imported="hideImported"
:active-sort="activeSort"
:sort-dir="sortDir"
:columns="columns"
:active-count="activeTorrents.length"
:done-count="doneTorrents.length"
:column-options="columnOptions"
:sort-options="sortOptions"
@sort="setSort"
@toggle-col="(key) => (columns as any)[key] = !(columns as any)[key]"
/>
<!-- Pas de clients -->
<div v-if="noClients" class="settings-card flex flex-col items-center gap-3 py-16 text-center">
<p class="text-sm text-muted">Aucun client torrent configuré</p>
<RouterLink to="/settings/download-client" class="text-xs text-accent hover:underline">Configurer un client →</RouterLink>
</div>
<!-- Loading -->
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
</div>
<!-- Vide -->
<div v-else-if="!loading && visibleTorrents.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
<p class="text-sm text-muted">{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}</p>
<p class="text-xs text-muted">{{ activeTab === 'active' ? 'Les torrents avec la catégorie « fankai » apparaîtront ici' : hideImported ? 'Tous les torrents terminés sont importés' : '' }}</p>
</div>
<!-- Liste torrents -->
<div v-else class="flex flex-col gap-2">
<TorrentCard
v-for="t in visibleTorrents"
:key="t.hash"
:torrent="t"
:tab="activeTab"
:columns="columns"
:importing="!!importing[t.hash]"
:deleting="!!deleting[t.hash]"
:show-confirm-delete="confirmDelete === t.hash"
@import="importTorrent"
@delete="(torrent, withFiles) => deleteTorrent(torrent, withFiles)"
@toggle-confirm="(hash) => { confirmDelete = hash }"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useToast } from '@/composables/useToast'
import TorrentCard from '@/components/downloads/TorrentCard.vue'
import DownloadsToolbar from '@/components/downloads/DownloadsToolbar.vue'
const { add: toast } = useToast()
const torrents = ref<any[]>([])
const loading = ref(true)
const polling = ref(true)
const noClients = ref(false)
const organizing = ref<Record<string, boolean>>({})
const activeTab = ref<'active' | 'done'>('active')
const hideOrganized = ref(false)
const torrents = ref<any[]>([])
const loading = ref(true)
const polling = ref(true)
const noClients = ref(false)
const importing = ref<Record<string, boolean>>({})
const importingAll = ref(false)
const confirmDelete = ref<string | null>(null)
const deleting = ref<Record<string, boolean>>({})
const activeTab = ref<'active' | 'done'>('active')
const hideImported = ref(false)
const seedingOnly = ref(false)
const search = ref('')
const activeSort = ref<'none' | 'name' | 'size' | 'progress' | 'state' | 'ratio'>('none')
const sortDir = ref<'asc' | 'desc'>('asc')
const columns = ref({ client: true, size: true, ratio: true, uploaded: true, upspeed: true })
const columnOptions = [
{ key: 'client', label: 'Client' },
{ key: 'size', label: 'Taille / Téléchargé' },
{ key: 'ratio', label: 'Ratio' },
{ key: 'uploaded', label: 'Uploadé (total)' },
{ key: 'upspeed', label: 'Vitesse upload' },
]
const sortOptions = [
{ label: 'Nom', value: 'name' },
{ label: 'Taille', value: 'size' },
{ label: 'Progression', value: 'progress' },
{ label: 'État', value: 'state' },
{ label: 'Ratio', value: 'ratio' },
]
let pollInterval: ReturnType<typeof setInterval> | null = null
// ── Filtres onglets ────────────────────────────────────────────
const activeTorrents = computed(() =>
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error', 'unknown'].includes(t.state))
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
)
const doneTorrents = computed(() =>
torrents.value.filter(t => t.state === 'seeding')
torrents.value.filter(t => t.state === 'seeding' || t.state === 'unknown')
)
const visibleTorrents = computed(() => {
if (activeTab.value === 'active') return activeTorrents.value
if (hideOrganized.value) return doneTorrents.value.filter(t => t.organizeState !== 'done')
return doneTorrents.value
let list = activeTab.value === 'active' ? activeTorrents.value : doneTorrents.value
if (activeTab.value === 'done') {
if (seedingOnly.value) list = list.filter(t => t.state === 'seeding')
if (hideImported.value) list = list.filter(t => t.organizeState !== 'done')
}
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(t => t.name.toLowerCase().includes(q))
}
if (activeSort.value !== 'none') {
const dir = sortDir.value === 'asc' ? 1 : -1
list = [...list].sort((a, b) => {
switch (activeSort.value) {
case 'name' : return a.name.localeCompare(b.name, 'fr') * dir
case 'size' : return (a.size - b.size) * dir
case 'progress': return (a.progress - b.progress) * dir
case 'state' : return a.state.localeCompare(b.state) * dir
case 'ratio' : return ((a.ratio ?? 0) - (b.ratio ?? 0)) * dir
default : return 0
}
})
}
return list
})
// ── Stats ──────────────────────────────────────────────────────
const stats = computed(() => [
{ label: 'EN COURS', value: torrents.value.filter(t => t.state === 'downloading').length },
{ label: 'COMPLÉTÉS', value: torrents.value.filter(t => t.state === 'seeding').length },
{ label: 'EN PAUSE', value: torrents.value.filter(t => t.state === 'paused').length },
{ label: 'ERREURS', value: torrents.value.filter(t => t.state === 'error').length },
{ label: 'En cours', value: torrents.value.filter(t => t.state === 'downloading').length },
{ label: 'Complétés', value: torrents.value.filter(t => t.state === 'seeding').length },
{ label: 'En pause', value: torrents.value.filter(t => t.state === 'paused').length },
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
])
// ── Fetch ──────────────────────────────────────────────────────
function setSort(val: string) {
if (activeSort.value === val) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
else { activeSort.value = val as typeof activeSort.value; sortDir.value = 'asc' }
}
async function fetchTorrents() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
@@ -226,85 +179,55 @@ async function fetchTorrents() {
if (!res.ok) return
torrents.value = await res.json()
noClients.value = false
} catch {
// silencieux en polling
} finally {
loading.value = false
} catch {} finally { loading.value = false }
}
function startPolling() { fetchTorrents(); pollInterval = setInterval(fetchTorrents, 60_000) }
function stopPolling() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null } }
function togglePolling() { polling.value = !polling.value; polling.value ? startPolling() : stopPolling() }
async function importTorrent(torrent: any) {
importing.value[torrent.hash] = true
try {
const res = await fetch('/api/organize', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }),
})
if (res.ok) { toast(`${torrent.name} importé ✓`, 'success'); await fetchTorrents() }
else { const { error } = await res.json(); toast(error ?? "Erreur lors de l'import", 'error') }
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { importing.value[torrent.hash] = false }
}
async function importAll() {
const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done')
if (toImport.length === 0) { toast('Tous les torrents sont déjà importés', 'success'); return }
importingAll.value = true
let done = 0, errors = 0
for (const t of toImport) {
try {
const res = await fetch('/api/organize', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name }),
})
res.ok ? done++ : errors++
} catch { errors++ }
}
importingAll.value = false
await fetchTorrents()
errors === 0 ? toast(`${done} torrent(s) importé(s) ✓`, 'success') : toast(`${done} OK, ${errors} erreur(s)`, 'error')
}
function startPolling() {
fetchTorrents()
pollInterval = setInterval(fetchTorrents, 3000)
}
function stopPolling() {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
}
function togglePolling() {
polling.value = !polling.value
if (polling.value) startPolling()
else stopPolling()
async function deleteTorrent(torrent: any, withFiles: boolean) {
deleting.value[torrent.hash] = true
try {
const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) { toast(`"${torrent.name}" supprimé ✓`, 'success'); confirmDelete.value = null; await fetchTorrents() }
else toast('Erreur lors de la suppression', 'error')
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { deleting.value[torrent.hash] = false }
}
onMounted(startPolling)
onUnmounted(stopPolling)
// ── Organiser ──────────────────────────────────────────────────
async function organize(torrent: any) {
organizing.value[torrent.hash] = true
try {
const res = await fetch('/api/organize', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name })
})
if (res.ok) {
toast(`${torrent.name} organisé ✓`, 'success')
} else {
const { error } = await res.json()
toast(error ?? 'Erreur lors de l\'organisation', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
organizing.value[torrent.hash] = false
}
}
// ── Helpers affichage ──────────────────────────────────────────
function stateBadge(state: string) {
const map: Record<string, { label: string; class: string }> = {
downloading: { label: 'TÉLÉCHARGEMENT', class: 'border-[#e8513a]/40 text-[#e8513a]' },
seeding : { label: 'COMPLÉTÉ', class: 'border-green-500/40 text-green-500' },
paused : { label: 'EN PAUSE', class: 'border-[#5a7a94]/40 text-[#5a7a94]' },
checking : { label: 'VÉRIFICATION', class: 'border-yellow-500/40 text-yellow-500' },
error : { label: 'ERREUR', class: 'border-red-500/40 text-red-500' },
unknown : { label: 'INCONNU', class: 'border-[#1e2d3d] text-[#5a7a94]' },
}
return map[state] ?? map.unknown
}
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
function formatSpeed(bps: number): string {
return `${formatSize(bps)}/s`
}
function formatEta(seconds: number): string {
if (seconds < 0 || seconds > 86400 * 7) return '∞'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
return `${s}s`
}
</script>
</script>
-209
View File
@@ -1,209 +0,0 @@
<template>
<div class="max-w-5xl mx-auto px-8 py-8">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<button @click="$router.push('/')"
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
← CATALOGUE
</button>
<h1 class="text-xl font-black text-white font-sans">Logs</h1>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] text-[#5a7a94] tracking-widest">{{ formatSize(fileSize) }}</span>
<button @click="confirmClear"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-red-500 hover:text-red-500 transition-colors cursor-pointer">
EFFACER
</button>
<button @click="load"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
↺ RAFRAÎCHIR
</button>
</div>
</div>
<!-- Filtres -->
<div class="flex flex-wrap items-center gap-3 mb-5">
<!-- Niveau -->
<div class="flex gap-1">
<button v-for="l in levels" :key="l.value" @click="filterLevel = l.value; load()"
class="px-3 py-1 text-[10px] tracking-widest border transition-colors cursor-pointer"
:class="filterLevel === l.value
? 'border-[#e8513a] text-[#e8513a]'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
{{ l.label }}
</button>
</div>
<!-- Source -->
<select v-model="filterSource" @change="load()"
class="bg-[#0d1219] border border-[#1e2d3d] text-[#5a7a94] px-3 py-1 text-[10px] tracking-widest outline-none cursor-pointer">
<option value="">Toutes les sources</option>
<option value="organize">organize</option>
<option value="api">api</option>
<option value="auth">auth</option>
</select>
<!-- Limite / tout afficher -->
<div class="flex items-center gap-2 ml-auto">
<span class="text-[10px] text-[#5a7a94] tracking-widest">AFFICHER</span>
<button v-for="n in [100, 500, 2000]" :key="n" @click="limit = n; load()"
class="px-2 py-1 text-[10px] tracking-widest border transition-colors cursor-pointer"
:class="limit === n
? 'border-[#e8513a] text-[#e8513a]'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
{{ n === 2000 ? 'TOUT' : n }}
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center gap-3 min-h-48 text-[#5a7a94]">
<div class="w-4 h-4 border-2 border-white/10 border-t-[#e8513a] rounded-full animate-spin"/>
<span class="text-sm">Chargement...</span>
</div>
<!-- Vide -->
<div v-else-if="entries.length === 0" class="flex items-center justify-center min-h-48 text-[#5a7a94] text-sm">
Aucun log
</div>
<!-- Table logs -->
<div v-else class="bg-[#0d1219] border border-[#1e2d3d] overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-xs font-mono">
<thead>
<tr class="border-b border-[#1e2d3d]">
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-4 py-2.5 w-40">HORODATAGE</th>
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5 w-16">NIVEAU</th>
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5 w-24">SOURCE</th>
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5">MESSAGE</th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, i) in entries" :key="i"
class="border-b border-[#1e2d3d]/50 hover:bg-white/[0.02] transition-colors"
:class="entry.level === 'error' ? 'bg-red-500/5' : entry.level === 'warn' ? 'bg-yellow-500/5' : ''">
<td class="px-4 py-2 text-[#5a7a94] whitespace-nowrap">{{ formatDate(entry.at) }}</td>
<td class="px-3 py-2 whitespace-nowrap">
<span class="text-[10px] tracking-widest px-1.5 py-0.5 rounded"
:class="levelClass(entry.level)">
{{ entry.level.toUpperCase() }}
</span>
</td>
<td class="px-3 py-2 text-[#5a7a94] whitespace-nowrap">{{ entry.source }}</td>
<td class="px-3 py-2 text-[#c8d8e8] break-all">
{{ entry.msg }}
<span v-if="entry.meta" class="text-[#5a7a94] ml-2">
{{ JSON.stringify(entry.meta) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Footer count -->
<div class="px-4 py-2.5 border-t border-[#1e2d3d] flex items-center justify-between">
<span class="text-[10px] text-[#5a7a94] tracking-widest">{{ entries.length }} entrées affichées</span>
<span class="text-[10px] text-[#5a7a94]">{{ formatSize(fileSize) }} sur disque</span>
</div>
</div>
<!-- Confirm clear -->
<div v-if="showConfirm" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 max-w-sm w-full">
<h3 class="text-sm font-bold text-white mb-2">Effacer les logs ?</h3>
<p class="text-[11px] text-[#5a7a94] mb-6">Cette action est irréversible.</p>
<div class="flex gap-3">
<button @click="doClear"
class="bg-red-500 text-white px-5 py-2 text-xs tracking-widest hover:opacity-85 transition-opacity cursor-pointer">
EFFACER
</button>
<button @click="showConfirm = false"
class="border border-[#1e2d3d] text-[#5a7a94] px-5 py-2 text-xs tracking-widest hover:border-white/30 transition-colors cursor-pointer">
ANNULER
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const entries = ref<any[]>([])
const fileSize = ref(0)
const loading = ref(true)
const showConfirm = ref(false)
const filterLevel = ref('all')
const filterSource = ref('')
const limit = ref(100)
const levels = [
{ label: 'TOUS', value: 'all' },
{ label: 'INFO', value: 'info' },
{ label: 'WARN', value: 'warn' },
{ label: 'ERROR', value: 'error' },
{ label: 'DEBUG', value: 'debug' },
]
async function load() {
loading.value = true
try {
const params = new URLSearchParams({
limit : String(limit.value),
level : filterLevel.value,
...(filterSource.value ? { source: filterSource.value } : {})
})
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
if (res.ok) {
const data = await res.json()
entries.value = data.entries
fileSize.value = data.size
}
} finally {
loading.value = false
}
}
function confirmClear() { showConfirm.value = true }
async function doClear() {
showConfirm.value = false
await fetch('/api/logs/clear', { method: 'POST', credentials: 'include' })
toast('Logs effacés', 'success')
await load()
}
function formatDate(iso: string): string {
const d = new Date(iso)
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
function levelClass(level: string): string {
return {
debug: 'bg-[#1e2d3d] text-[#5a7a94]',
info : 'bg-blue-500/10 text-blue-400',
warn : 'bg-yellow-500/10 text-yellow-400',
error: 'bg-red-500/10 text-red-400',
}[level] ?? 'bg-[#1e2d3d] text-[#5a7a94]'
}
onMounted(load)
</script>
-135
View File
@@ -1,135 +0,0 @@
<template>
<div class="max-w-5xl mx-auto px-8 py-8">
<!-- Back -->
<button
@click="$router.push('/')"
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors mb-8 cursor-pointer"
>
← CATALOGUE
</button>
<!-- Loading -->
<div v-if="loading" class="text-[#5a7a94] text-sm tracking-widest py-20 text-center">
CHARGEMENT...
</div>
<template v-else-if="production">
<!-- Hero -->
<div class="flex gap-8 mb-10">
<!-- Poster -->
<div class="w-40 flex-shrink-0">
<img
v-if="production.cover"
:src="imageUrl(production.cover)"
:alt="production.name"
class="w-full object-cover"
/>
<div v-else class="w-full aspect-[2/3] bg-[#0d1219] flex items-center justify-center text-5xl">🎌</div>
</div>
<!-- Info -->
<div class="flex-1">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-2">FANKAI</div>
<h1 class="text-3xl font-black text-white font-sans mb-4">{{ production.name }}</h1>
<!-- Badges -->
<div class="flex flex-wrap gap-2 mb-4">
<span class="text-[10px] border border-[#1e2d3d] text-[#5a7a94] px-2 py-1 tracking-wider">
{{ production.status.toUpperCase() }}
</span>
<span class="text-[10px] border border-[#3a8fe8] text-[#3a8fe8] bg-[#3a8fe8]/10 px-2 py-1 tracking-wider">
{{ production.episodes_count }} FILMS
</span>
<span class="text-[10px] border border-[#1e2d3d] text-[#5a7a94] px-2 py-1 tracking-wider">
PAR {{ production.kaieur.toUpperCase() }}
</span>
</div>
<p class="text-sm text-[#5a7a94] leading-relaxed mb-6">{{ production.description }}</p>
<!-- Torrents globaux -->
<div v-if="torrents.length > 0">
<div class="text-[10px] text-[#5a7a94] tracking-widest mb-2">TORRENTS</div>
<div class="flex flex-col gap-2">
<a
v-for="t in torrents"
:key="t.url"
:href="t.url"
target="_blank"
class="flex items-center justify-between bg-[#0d1219] border border-[#1e2d3d] hover:border-[#e8513a] px-4 py-2.5 transition-colors group"
>
<span class="text-sm text-white">{{ t.name }}</span>
<span class="text-[10px] text-[#e8513a] tracking-widest group-hover:text-white transition-colors">
NYAA ↗
</span>
</a>
</div>
</div>
<!-- Lien wiki -->
<a
v-if="production.link"
:href="production.link"
target="_blank"
class="inline-block mt-4 text-[10px] text-[#5a7a94] hover:text-[#3a8fe8] tracking-widest transition-colors"
>
GUIDE DES ÉPISODES ↗
</a>
</div>
</div>
<!-- Saisons -->
<!-- Saisons -->
<div v-if="production.seasons.length > 0" class="space-y-6">
<div v-for="season in production.seasons" :key="season.id">
<div class="flex items-center gap-4 mb-3">
<div class="text-[10px] text-[#5a7a94] tracking-widest">SAISON {{ season.number }}</div>
<div class="text-sm font-bold text-white">{{ season.name }}</div>
<div class="flex-1 h-px bg-[#1e2d3d]"></div>
</div>
<div class="grid grid-cols-1 gap-px bg-[#1e2d3d]">
<div v-for="ep in season.episodes" :key="ep.id">
<EpisodeRow :ep="ep" :production-id="production.id" />
</div>
</div>
</div>
</div>
<!-- Episodes sans saison -->
<div v-else-if="production.episodes.length > 0">
<div class="flex items-center gap-4 mb-3">
<div class="text-sm font-bold text-white">ÉPISODES</div>
<div class="flex-1 h-px bg-[#1e2d3d]"></div>
</div>
<div class="grid grid-cols-1 gap-px bg-[#1e2d3d]">
<div v-for="ep in production.episodes" :key="ep.id">
<EpisodeRow :ep="ep" :production-id="production.id" />
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useCatalogStore } from '@/stores/catalog'
import { imageUrl, parseTorrents, type ProductionDetail } from '@/api/fankai'
import EpisodeRow from "@/components/EpisodeRow.vue";
const route = useRoute()
const store = useCatalogStore()
const production = ref<ProductionDetail | null>(null)
const loading = ref(true)
const torrents = computed(() => parseTorrents(production.value?.torrents ?? null))
onMounted(async () => {
const id = Number(route.params.id)
const name = route.query.name as string ?? ''
production.value = await store.loadProduction(id, name)
loading.value = false
})
</script>
+444 -336
View File
@@ -1,417 +1,525 @@
<template>
<div class="min-h-screen bg-zinc-950 text-zinc-100">
<!-- Back -->
<div class="max-w-7xl mx-auto px-6 pt-6">
<router-link to="/" class="inline-flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-100 transition">
<svg width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
Catalogue
</router-link>
</div>
<div>
<!-- Loading -->
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-sm">Chargement…</p>
</div>
<!-- Error -->
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
<p class="text-sm">{{ store.error }}</p>
<button class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm" @click="load">Réessayer</button>
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
<p class="text-sm text-red-400">{{ store.error }}</p>
<button class="btn-primary" @click="load">Réessayer</button>
</div>
<template v-else-if="data">
<!-- Hero -->
<div class="relative">
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none">
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20" />
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
<!-- Hero — poster, titre, métadonnées, synopsis -->
<SerieHero :serie="data.serie" />
<!-- Barre d'actions -->
<div class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
<!-- Gauche : gestion bibliothèque -->
<div class="flex items-center gap-2">
<!-- Import manuel -->
<button
@click="openManualImport"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent-muted text-accent border border-accent/20 hover:bg-accent/20 transition"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Import manuel
</button>
<!-- Supprimer série -->
<button
v-if="Object.keys(organizedByEpisode).length > 0"
@click="openUnimportSerieModal"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 transition"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>
</svg>
Supprimer
</button>
</div>
<div class="relative max-w-7xl mx-auto px-6 pt-8 pb-10 flex gap-8">
<!-- Poster -->
<div class="shrink-0 w-36 rounded-xl overflow-hidden border border-white/10 shadow-2xl hidden sm:block">
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" />
<div v-else class="w-full aspect-[2/3] bg-zinc-900 flex items-center justify-center text-zinc-700">
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="20" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/>
<!-- Droite : téléchargements -->
<div class="flex flex-wrap items-center gap-2">
<!-- Bouton unique "Tout télécharger" — dropdown si plusieurs intégrales -->
<div v-if="hasSomethingToDownload" class="relative" @click.stop>
<button
:disabled="downloadingAll"
class="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent text-white hover:bg-accent-hover transition"
:class="downloadingAll ? 'opacity-50 cursor-not-allowed' : ''"
@click="data.torrents_integrale.length > 1 ? (downloadMenuOpen = !downloadMenuOpen) : downloadAll()"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" :class="downloadingAll ? 'animate-spin' : ''">
<path v-if="!downloadingAll" d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline v-if="!downloadingAll" points="7 10 12 15 17 10"/><line v-if="!downloadingAll" x1="12" y1="15" x2="12" y2="3"/>
<path v-else d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
</svg>
</div>
</div>
{{ downloadingAll ? 'Envoi…' : 'Tout télécharger' }}
<span v-if="data.torrents_integrale.length > 1" class="opacity-70">▾</span>
</button>
<!-- Info -->
<div class="flex flex-col gap-3 justify-end min-w-0">
<h1 class="text-3xl font-bold tracking-tight">{{ data.serie.title }}</h1>
<div class="flex flex-wrap items-center gap-2">
<span v-if="data.serie.year" class="text-sm text-zinc-400">{{ data.serie.year }}</span>
<span
v-if="data.serie.status"
class="text-xs px-2 py-0.5 rounded-full font-medium border"
:class="data.serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/15 text-green-400 border-green-500/20'
: 'bg-white/5 text-zinc-500 border-white/10'"
>{{ data.serie.status }}</span>
</div>
<p v-if="data.serie.plot" class="text-sm text-zinc-400 leading-relaxed max-w-2xl line-clamp-3">
{{ data.serie.plot }}
</p>
<!-- Boutons hero : intégrale, pack_saison promu, pack_episodes -->
<div class="flex flex-wrap gap-2 mt-1">
<!-- Dropdown choix d'intégrale -->
<div v-if="downloadMenuOpen && data.torrents_integrale.length > 1"
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="(t, i) in data.torrents_integrale"
:key="i"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
:class="[
t.label === 'Intégrale'
? 'bg-orange-500 hover:bg-orange-600 text-white'
: 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20',
(downloading.has(`integrale-${i}`) || downloaded.has(`integrale-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''
]"
:disabled="downloading.has(`integrale-${i}`) || downloaded.has(`integrale-${i}`) || isAlreadyQueued(t)"
@click="download(`integrale-${i}`, t.torrent_url, t.magnet)"
:disabled="isAlreadyQueued(t) && !hasUncoveredByIntegrale(i)"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
:class="(isDownloaded(`integrale-${i}`) && !hasUncoveredByIntegrale(i)) ? 'opacity-40 cursor-not-allowed' : ''"
@click="downloadAll(i); downloadMenuOpen = false"
>
<DownloadIcon :spinning="downloading.has(`integrale-${i}`)" />
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : downloaded.has(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{{ integraleGroupLabels()[i] }}
</button>
</div>
<!-- Barre progression intégrale -->
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
<template v-if="torrentProgress(extractHash(t))">
<!-- Badge 100% -->
<span v-if="torrentProgress(extractHash(t))!.progress === 100"
class="mt-2 inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ Téléchargé
</span>
<!-- Barre en cours -->
<div v-else class="mt-2 w-full max-w-sm">
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
<span>Téléchargement</span>
<span>{{ torrentProgress(extractHash(t))!.progress }}%</span>
</div>
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(t))!.progress}%` }" />
</div>
</div>
</template>
</template>
</div>
</div>
</div>
<!-- Seasons -->
<div class="max-w-7xl mx-auto px-6 pb-16 flex flex-col gap-6">
<div
<!-- Saisons -->
<div class="px-4 md:px-8 pb-16 flex flex-col gap-4 pt-4">
<SerieSeasonCard
v-for="season in data.seasons"
:key="season.id"
class="bg-zinc-900/50 border border-white/5 rounded-2xl overflow-hidden"
>
<!-- Season header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-white/5">
<div class="flex items-center gap-3">
<button class="text-zinc-500 hover:text-zinc-100 transition" @click="toggleSeason(season.id)">
<svg
width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
class="transition-transform duration-200"
:class="collapsedSeasons.has(season.id) ? '' : 'rotate-180'"
>
<path d="M18 15l-6-6-6 6"/>
</svg>
</button>
<div>
<h2 class="font-semibold text-sm">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-zinc-500 font-normal ml-1">
— {{ season.title }}
</span>
</h2>
<p class="text-xs text-zinc-600 mt-0.5">
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
<span v-if="season.organized_state !== 'none'"
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px] font-medium"
: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} organisé${season.organized_count > 1 ? 's' : ''}`
: `${season.organized_count}/${seasonAvailableCount(season)} organisés` }}
</span>
</p>
</div>
</div>
<!-- Bouton pack_saison sur le header -->
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
:class="[
season.torrent && !downloaded.has(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
]"
:disabled="!season.torrent || downloading.has(`season-${season.id}`) || downloaded.has(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
@click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<DownloadIcon :spinning="downloading.has(`season-${season.id}`)" :size="12" />
{{ season.organized_state === 'complete' ? 'Organisé ✓' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : downloaded.has(`season-${season.id}`) ? 'Envoyé ✓' : 'Saison entière' }}
</button>
</div>
<!-- Barre progression saison -->
<template v-if="torrentProgress(extractHash(season.torrent))">
<!-- Badge 100% -->
<div v-if="torrentProgress(extractHash(season.torrent))!.progress === 100"
class="px-5 py-2 border-b border-white/5">
<span class="inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ Téléchargé
</span>
</div>
<!-- Barre en cours -->
<div v-else class="px-5 py-2 border-b border-white/5">
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
<span>Téléchargement en cours</span>
<span>{{ torrentProgress(extractHash(season.torrent))!.progress }}%</span>
</div>
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(season.torrent))!.progress}%` }" />
</div>
</div>
</template>
<!-- Episodes -->
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-white/[0.04]">
<div
v-for="ep in season.episodes"
:key="ep.id"
class="px-5 py-3 hover:bg-white/[0.02] transition"
>
<!-- Ligne principale -->
<div class="flex items-center gap-4">
<!-- Thumbnail -->
<div class="shrink-0 w-24 aspect-video rounded-lg overflow-hidden bg-zinc-800 hidden sm:block">
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700 text-xs font-mono">
{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}
</div>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-xs text-zinc-600 shrink-0 font-mono">
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
</span>
<span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
</div>
<div class="flex items-center gap-3 mt-0.5">
<span v-if="ep.aired" class="text-xs text-zinc-600">{{ formatDate(ep.aired) }}</span>
<span v-if="ep.duration" class="text-xs text-zinc-600">{{ ep.duration }} min</span>
</div>
</div>
<!-- Bouton épisode (type episode uniquement) -->
<div class="shrink-0 flex items-center gap-2">
<!-- Badge organisé -->
<span v-if="ep.organized"
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ organisé
</span>
<!-- Badge téléchargé à 100% -->
<span v-if="torrentProgress(extractHash(ep.torrent))?.progress === 100"
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ téléchargé
</span>
<!-- Badge dispo/indispo -->
<span
class="inline-block text-[10px] px-2 py-0.5 rounded-full border"
:class="ep.available
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
: 'bg-white/5 text-zinc-600 border-white/8'"
>
{{ ep.available ? 'dispo' : 'indispo' }}
</span>
<!-- Bouton download -->
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
:class="[
ep.torrent && !downloaded.has(`ep-${ep.id}`) && !isAlreadyQueued(ep.torrent) && !ep.organized
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
]"
:disabled="!ep.torrent || downloading.has(`ep-${ep.id}`) || downloaded.has(`ep-${ep.id}`) || isAlreadyQueued(ep.torrent) || ep.organized"
@click="ep.torrent && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
>
<DownloadIcon :spinning="downloading.has(`ep-${ep.id}`)" :size="12" />
{{ ep.organized ? '✓' : isAlreadyQueued(ep.torrent) ? '✓' : downloaded.has(`ep-${ep.id}`) ? '✓' : 'Ep.' }}
</button>
</div>
</div>
<!-- Barre progression épisode (seulement si < 100%) -->
<div v-if="torrentProgress(extractHash(ep.torrent)) && torrentProgress(extractHash(ep.torrent))!.progress < 100"
class="mt-2 sm:pl-28">
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
<span>Téléchargement</span>
<span>{{ torrentProgress(extractHash(ep.torrent))!.progress }}%</span>
</div>
<div class="h-0.5 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(ep.torrent))!.progress}%` }" />
</div>
</div>
</div>
</div>
</div>
:season="season"
:collapsed="collapsedSeasons.has(season.id)"
:active-torrents="activeTorrents"
:downloading="downloading"
:downloaded="downloaded"
:organized-by-episode="organizedByEpisode"
:ep-action-loading="epActionLoading"
:downloading-season="!!downloadingSeason[season.id]"
:nfo-support="nfoSupport"
@toggle="toggleSeason"
@download="(key, url, magnet, fi, fp, ih) => download(key, url, magnet, fi, fp, ih)"
@download-season="(s, h) => downloadSeason(s, h)"
@rename-episode="(ep, s) => renameEpisode(ep, s)"
@unimport-episode="(ep, s, del) => unimportEpisode(ep, s, del)"
@unimport-season="(s, del) => unimportSeason(s, del)"
/>
</div>
</template>
<!-- Toast -->
<Transition name="toast">
<div
v-if="toast"
class="fixed bottom-6 right-6 flex items-center gap-3 px-4 py-3 rounded-xl shadow-2xl text-sm font-medium z-50"
:class="toast.type === 'success'
? 'bg-zinc-800 border border-green-500/30 text-green-400'
: 'bg-zinc-800 border border-red-500/30 text-red-400'"
>
<svg v-if="toast.type === 'success'" width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path d="M20 6 9 17l-5-5"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
</svg>
{{ toast.message }}
<!-- Modal désimport série -->
<Teleport to="body">
<div v-if="unimportSerieModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="unimportSerieModal = false">
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-5">
<div>
<p class="text-sm font-semibold text-primary">Supprimer la série</p>
<p class="text-xs text-muted mt-1 truncate">{{ data?.serie?.title }}</p>
</div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" v-model="deleteSerieFiles" class="w-4 h-4 rounded border-border accent-accent" />
<span class="text-xs text-muted">Supprimer également les fichiers physiques du disque</span>
</label>
<div class="flex items-center justify-end gap-2">
<button @click="unimportSerieModal = false" class="btn-secondary text-xs">Annuler</button>
<button @click="confirmUnimportSerie" class="px-4 py-2 rounded-lg border border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20 transition-colors text-xs font-medium">
{{ deleteSerieFiles ? 'Supprimer les fichiers' : 'Supprimer' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Modal import manuel -->
<ManualImportModal
v-if="manualImportOpen && data"
:serie-id="Number(route.params.id)"
:serie-name="data.serie.title"
:seasons="data.seasons"
:organized="organizedByEpisode"
:initial-path="mediaPath"
@close="manualImportOpen = false"
@imported="load(); fetchOrganized(); manualImportOpen = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useSeriesStore } from '@/stores/series'
import { useToast } from '@/composables/useToast'
import ManualImportModal from '@/components/ManualImportModal.vue'
import SerieHero from '@/components/serie/SerieHero.vue'
import SerieSeasonCard from '@/components/serie/SerieSeasonCard.vue'
import type { Season } from '@/stores/series'
// ── Composant icône download ──────────────────────────────────
const DownloadIcon = defineComponent({
props: { spinning: Boolean, size: { type: Number, default: 14 } },
setup(props) {
return () => props.spinning
? h('div', { class: `w-${props.size === 12 ? '3' : '3.5'} h-${props.size === 12 ? '3' : '3.5'} border border-current/30 border-t-current rounded-full animate-spin` })
: h('svg', { width: props.size, height: props.size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
h('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3' })
])
}
})
const route = useRoute()
const store = useSeriesStore()
const { add: toast } = useToast()
// ── Setup ─────────────────────────────────────────────────────
const route = useRoute()
const store = useSeriesStore()
const collapsedSeasons = ref<Set<number>>(new Set())
const downloading = ref<string[]>([])
const downloaded = ref<string[]>([])
const manualImportOpen = ref(false)
const organizedByEpisode = ref<Record<string, any>>({})
const mediaPath = ref('/')
const nfoSupport = ref(false)
const epActionLoading = ref<Record<number, boolean>>({})
const unimportSerieModal = ref(false)
const deleteSerieFiles = ref(false)
const downloadMenuOpen = ref(false)
const downloadingAll = ref(false)
const downloadingSeason = ref<Record<number, boolean>>({})
const collapsedSeasons = ref<Set<number>>(new Set())
const downloading = ref<Set<string>>(new Set())
const downloaded = ref<Set<string>>(new Set()) // envoyés avec succès → bloqués
const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null)
// ── Progression downloads ──────────────────────────────────────
interface ActiveTorrent { hash: string; progress: number; state: string; name: string }
export interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[]; save_path?: string; name?: string }
const activeTorrents = ref<ActiveTorrent[]>([])
let pollTimer: ReturnType<typeof setInterval> | null = null
// Suivi des états précédents pour détecter les transitions (download → seeding)
const _prevTorrentStates = new Map<string, string>() // hash → state
const _prevFileProgresses = new Map<string, number>() // hash:index → progress
const _recentlyOrganized = new Set<string>() // hash:fileIndex ou hash seul
const data = computed(() => store.currentSerie)
// ── Helpers intégrale ─────────────────────────────────────────
function integraleGroupLabels(): string[] {
const torrents = data.value?.torrents_integrale ?? []
const names = torrents.map((t: any) => t.torrent_name ?? null)
const allPresent = names.every((n: any) => 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) => {
const raw = t.raw ?? ''
if (raw) return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
return `Intégrale ${i + 1}`
})
}
// Épisodes couverts par une intégrale donnée (hash commun)
function integraleEpisodeCoverage(integraleIndex: number): Set<number> {
const covered = new Set<number>()
const t = data.value?.torrents_integrale[integraleIndex]
if (!t || !data.value) return covered
const hash = t.infohash?.toLowerCase() ?? extractHash(t)
if (!hash) return covered
for (const season of data.value.seasons)
for (const ep of season.episodes)
if (ep.torrents?.some((et: any) => (et.infohash ?? extractHash(et)) === hash))
covered.add(ep.id)
return covered
}
// Vrai s'il reste des épisodes/saisons non couverts par cette intégrale
function hasUncoveredByIntegrale(integraleIndex: number): boolean {
if (!data.value) return false
const covered = integraleEpisodeCoverage(integraleIndex)
for (const season of data.value.seasons)
for (const ep of season.episodes)
if (ep.available && !ep.organized && !covered.has(ep.id)) return true
return false
}
// ── Helpers d'état ────────────────────────────────────────────
function isDownloading(key: string) { return downloading.value.includes(key) }
function isDownloaded(key: string) { return downloaded.value.includes(key) }
function addDownloading(key: string) { if (!downloading.value.includes(key)) downloading.value.push(key) }
function removeDownloading(key: string) { downloading.value = downloading.value.filter(k => k !== key) }
function addDownloaded(key: string) { if (!downloaded.value.includes(key)) downloaded.value.push(key) }
function removeDownloaded(key: string) { downloaded.value = downloaded.value.filter(k => k !== key) }
function clearEpDownloaded(ep: any) {
removeDownloaded(`ep-${ep.id}`)
const count = ep.torrents?.length ?? 0
for (let i = 0; i < count; i++) removeDownloaded(`ep-${ep.id}-${i}`)
}
function extractHash(torrent: any): string | null {
if (torrent?.infohash) return torrent.infohash.toLowerCase()
if (!torrent?.magnet) return null
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
return m ? m[1].toLowerCase() : null
}
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.
if (torrent?.file_index != null) return false
const hash = extractHash(torrent)
if (!hash) return false
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
// ── Fetch ─────────────────────────────────────────────────────
function load() { store.fetchSerieDetail(Number(route.params.id)) }
async function fetchOrganized() {
try {
const res = await fetch(`/api/organized/${route.params.id}`, { credentials: 'include' })
if (res.ok) organizedByEpisode.value = await res.json()
} catch {}
}
async function fetchSettings() {
try {
const res = await fetch('/api/settings', { credentials: 'include' })
if (res.ok) { const s = await res.json(); mediaPath.value = s.mediaPath || '/'; nfoSupport.value = !!s.nfoSupport }
} catch {}
}
async function fetchActiveDownloads() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (!res.ok) return
const list: any[] = await res.json()
const isFirstPoll = _prevTorrentStates.size === 0
for (const t of list) {
const prevState = _prevTorrentStates.get(t.hash)
// Détecter fichier individuel terminé (progression par fichier)
if (t.files) {
for (const f of t.files) {
const key = `${t.hash}:${f.index}`
const prevProg = _prevFileProgresses.get(key)
_prevFileProgresses.set(key, f.progress)
if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) {
_recentlyOrganized.add(key)
triggerOrganize(t)
}
}
}
// Détecter torrent entier passé en seeding (épisodes standalone sans per-file data)
if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) {
_recentlyOrganized.add(t.hash)
triggerOrganize(t)
}
_prevTorrentStates.set(t.hash, t.state)
}
activeTorrents.value = list.map(t => ({
hash : t.hash,
progress: t.progress ?? 0,
state : t.state,
name : t.name,
hash : t.hash,
progress : t.progress ?? 0,
state : t.state,
files : t.files,
save_path: t.save_path,
name : t.name,
}))
} catch {}
}
// Cherche si un torrent actif correspond à un hash donné
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
if (!hash) return null
return activeTorrents.value.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
async function triggerOrganize(torrent: any) {
if (!torrent?.hash || !torrent?.name || !torrent?.save_path) return
try {
const res = await fetch('/api/organize', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ hash: torrent.hash, name: torrent.name, save_path: torrent.save_path }),
})
if (res.ok) {
const result = await res.json()
if (result.done > 0) {
toast(`${result.done} épisode(s) importé(s) automatiquement ✓`, 'success')
await fetchOrganized()
load()
}
}
} catch {}
}
// Hash d'un torrent de saison ou d'épisode (extrait depuis magnet ou url)
function extractHash(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): string | null {
if (!torrent) return null
const magnet = torrent.magnet ?? ''
const m = magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
return m ? m[1].toLowerCase() : null
function openManualImport() { fetchOrganized(); manualImportOpen.value = true }
// ── Download ──────────────────────────────────────────────────
async function download(key: string, torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null) {
if (isDownloading(key) || isDownloaded(key)) return
addDownloading(key)
const result = await store.download(torrent_url, magnet, file_index, file_path, infohash)
removeDownloading(key)
if (result.success) { addDownloaded(key); toast('Téléchargement lancé ✓', 'success'); fetchActiveDownloads() }
else toast(result.error ?? 'Erreur inconnue', 'error')
}
// ── Data ───────────────────────────────────────────────────────
const data = computed(() => store.currentSerie)
type DlItem = { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null; infohash?: string | null }
function seasonAvailableCount(season: Season) {
return season.episodes.filter(e => e.available).length
// Construit la liste de tout ce qui doit être téléchargé.
// integraleIndex : si défini, utilise uniquement cette intégrale (+ épisodes non couverts).
// si omis, utilise toutes les intégrales disponibles.
function collectDownloadables(integraleIndex?: number): DlItem[] {
if (!data.value) return []
const result: DlItem[] = []
const covered = new Set<number>()
// ── 1. Intégrale(s) ───────────────────────────────────────────
const integrales: { t: any; i: number }[] = integraleIndex !== undefined
? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }]
: data.value.torrents_integrale.map((t: any, i: number) => ({ t, i }))
for (const { t, i } of integrales) {
if (!t) continue
if (!isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet })
// Marquer les épisodes couverts par CETTE intégrale uniquement
const hash = t.infohash?.toLowerCase() ?? extractHash(t)
if (hash)
for (const season of data.value!.seasons)
for (const ep of season.episodes)
if (ep.torrents?.some((et: any) => (et.infohash ?? extractHash(et)) === hash))
covered.add(ep.id)
}
// ── 2. Packs saison non couverts ──────────────────────────────
for (const season of data.value.seasons) {
if (!season.torrents?.length || season.organized_state === 'complete') continue
// Skiper si tous les épisodes disponibles sont déjà couverts par l'intégrale
const hasUncovered = season.episodes.some((ep: any) => ep.available && !ep.organized && !covered.has(ep.id))
if (!hasUncovered) continue
let addedAny = false
season.torrents.forEach((t: any, i: number) => {
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`
if (!isAlreadyQueued(t) && !isDownloaded(key)) { result.push({ key, torrent_url: t.torrent_url, magnet: t.magnet }); addedAny = true }
})
if (addedAny) for (const ep of season.episodes) covered.add(ep.id)
}
// ── 3. Épisodes individuels non couverts ─────────────────────
for (const season of data.value.seasons)
for (const ep of season.episodes) {
if (!ep.torrent || !ep.available || ep.organized || isAlreadyQueued(ep.torrent) || isDownloaded(`ep-${ep.id}`) || covered.has(ep.id)) continue
result.push({ key: `ep-${ep.id}`, torrent_url: ep.torrent.torrent_url, magnet: ep.torrent.magnet, file_index: ep.torrent.file_index ?? null, file_path: ep.torrent.file_path ?? null, infohash: ep.torrent.infohash ?? null })
}
return result
}
// Le bouton "Tout télécharger" s'affiche s'il y a quoi que ce soit à lancer
const hasSomethingToDownload = computed(() => {
if (!data.value) return false
if (data.value.torrents_integrale.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))) return true
for (const season of data.value.seasons) {
if (season.torrents?.length && season.organized_state !== 'complete' &&
season.torrents.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`))) return true
for (const ep of season.episodes)
if (ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)) return true
}
return false
})
async function downloadAll(integraleIndex?: number) {
downloadingAll.value = true
const torrents = collectDownloadables(integraleIndex)
let sent = 0
for (const t of torrents) {
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
}
downloadingAll.value = false
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
else toast('Aucun nouveau torrent à télécharger', 'success')
}
async function downloadSeason(season: Season, packHash?: string) {
downloadingSeason.value[season.id] = true
const torrents: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = []
const seasonAny = season as any
if (seasonAny.torrents && seasonAny.torrents.length > 0 && seasonAny.organized_state !== 'complete') {
seasonAny.torrents.forEach((t: any, i: number) => {
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`
if (!isAlreadyQueued(t) && !isDownloaded(key)) {
torrents.push({ key, torrent_url: t.torrent_url, magnet: t.magnet })
}
})
} else {
for (const ep of seasonAny.episodes ?? []) {
// Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant
const epTorrent = packHash
? (ep.torrents?.find((t: any) => (t.infohash ?? extractHash(t)) === packHash) ?? ep.torrent)
: ep.torrent
if (!epTorrent || !ep.available || ep.organized || isAlreadyQueued(epTorrent) || isDownloaded(`ep-${ep.id}`)) continue
torrents.push({ key: `ep-${ep.id}`, torrent_url: epTorrent.torrent_url, magnet: epTorrent.magnet, file_index: epTorrent.file_index ?? null, file_path: epTorrent.file_path ?? null, infohash: epTorrent.infohash ?? null })
}
}
let sent = 0
for (const t of torrents) {
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
}
downloadingSeason.value[season.id] = false
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
else toast('Aucun nouveau torrent à télécharger pour cette saison', 'success')
}
// ── Actions ───────────────────────────────────────────────────
function toggleSeason(id: number) {
if (collapsedSeasons.value.has(id)) collapsedSeasons.value.delete(id)
else collapsedSeasons.value.add(id)
}
function formatDate(d: string) {
if (!d) return ''
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
function openUnimportSerieModal() { deleteSerieFiles.value = false; unimportSerieModal.value = true }
async function confirmUnimportSerie() { unimportSerieModal.value = false; await unimportSerie(deleteSerieFiles.value) }
async function unimportSeason(season: any, deleteFile: boolean) {
try {
const res = await fetch(`/api/organized/${route.params.id}/seasons/${season.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport saison', 'error'); return }
const d = await res.json()
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
for (const ep of season.episodes ?? []) clearEpDownloaded(ep)
removeDownloaded(`season-${season.id}`)
await fetchOrganized(); load()
} catch { toast('Impossible de contacter le serveur', 'error') }
}
function showToast(type: 'success' | 'error', message: string) {
toast.value = { type, message }
setTimeout(() => { toast.value = null }, 3500)
async function unimportSerie(deleteFile: boolean) {
try {
const res = await fetch(`/api/organized/${route.params.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
const d = await res.json()
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
downloaded.value = []
await fetchOrganized(); load()
} catch { toast('Impossible de contacter le serveur', 'error') }
}
// Vérifie si un torrent est déjà en cours ou terminé dans qBittorrent
function isAlreadyQueued(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): boolean {
const hash = extractHash(torrent)
if (!hash) return false
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
async function renameEpisode(ep: any, _season: any) {
epActionLoading.value[ep.id] = true
try {
const torrentHash = ep.paths?.[0]?.infohash ?? null
const res = await fetch('/api/rename-episode', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ serie_id: Number(route.params.id), episode_id: ep.id, torrent_hash: torrentHash }),
})
const d = await res.json()
if (!res.ok) { toast(d.error ?? 'Erreur rename', 'error'); return }
if (d.renamed) { toast(`Renommé : ${d.new_name}`, 'success'); await fetchOrganized() }
else toast(d.message ?? 'Nom déjà correct', 'success')
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { epActionLoading.value[ep.id] = false }
}
async function download(key: string, torrent_url: string | null, magnet: string | null) {
if (downloading.value.has(key) || downloaded.value.has(key)) return
downloading.value = new Set([...downloading.value, key])
const result = await store.download(torrent_url, magnet)
downloading.value = new Set([...downloading.value].filter(k => k !== key))
if (result.success) {
downloaded.value = new Set([...downloaded.value, key])
showToast('success', 'Téléchargement lancé')
fetchActiveDownloads()
} else {
showToast('error', result.error ?? 'Erreur inconnue')
}
async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
epActionLoading.value[ep.id] = true
try {
const res = await fetch(`/api/organized/${route.params.id}/${ep.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
clearEpDownloaded(ep)
await fetchOrganized(); load()
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { epActionLoading.value[ep.id] = false }
}
function load() {
store.fetchSerieDetail(Number(route.params.id))
}
const closeMenus = () => { downloadMenuOpen.value = false }
onMounted(() => {
load()
fetchActiveDownloads()
load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads()
pollTimer = setInterval(fetchActiveDownloads, 5000)
document.addEventListener('click', closeMenus)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
document.removeEventListener('click', closeMenus)
})
</script>
<style scoped>
.toast-enter-active, .toast-leave-active { transition: all 0.25s ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(6px); }
</style>
+217
View File
@@ -0,0 +1,217 @@
<template>
<div class="flex flex-col">
<SeriesToolbar
v-model:search="search"
v-model:active-filter="activeFilter"
v-model:active-sort="activeSort"
v-model:poster-size="posterSize"
:poster-sizes="posterSizes"
:count="filtered.length"
:has-active-filter="hasActiveFilter"
:filters-disponibilite="filtersDisponibilite"
:filters-import="filtersImport"
:sort-options="sortOptions"
:legend="legend"
/>
<!-- Grille -->
<div class="px-4 md:px-6 py-6">
<div v-if="store.loadingSeries" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-sm">Chargement du catalogue…</p>
</div>
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
<p class="text-sm text-red-400">{{ store.error }}</p>
<button class="btn-primary" @click="store.fetchSeries()">Réessayer</button>
</div>
<div v-else-if="filtered.length === 0" class="flex items-center justify-center h-64 text-muted text-sm">
Aucune série trouvée
</div>
<div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }">
<RouterLink
v-for="serie in filtered"
:key="serie.id"
:to="`/series/${serie.id}`"
class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5"
:class="{ 'opacity-40': !serie.has_torrents && serie.download_state === 'none' }"
>
<div class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border border-border">
<img
v-if="serie.poster_image"
:src="serie.poster_image"
:alt="serie.title"
loading="lazy"
class="w-full h-full object-cover"
/>
<div v-else class="w-full h-full flex items-center justify-center text-muted">
<Tv :size="28" />
</div>
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<span class="text-xs text-white border border-white/30 px-3 py-1 rounded-md">Voir</span>
</div>
<div
class="absolute bottom-0 left-0 right-0 h-1"
:style="{ background: stateColor(serie.download_state) }"
/>
</div>
<div>
<p class="text-xs font-medium truncate leading-snug text-primary">{{ serie.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5">
<span v-if="serie.year" class="text-[11px] text-muted">{{ serie.year }}</span>
<span
v-if="serie.status"
class="text-[10px] px-1.5 py-0.5 rounded"
:class="serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/10 text-green-400'
: 'bg-hover text-muted'"
>
{{ serie.status }}
</span>
</div>
</div>
</RouterLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
import { RouterLink } from 'vue-router'
import { Tv } from 'lucide-vue-next'
import { useSeriesStore } from '@/stores/series'
import { useDownloadsStore } from '@/stores/downloads'
import { useToast } from '@/composables/useToast'
import { usePosterSize } from '@/composables/usePosterSize'
import SeriesToolbar from '@/components/series/SeriesToolbar.vue'
defineOptions({ name: 'SeriesView' })
const store = useSeriesStore()
const dlStore = useDownloadsStore()
const { add: toast } = useToast()
const { current: posterSize, sizes: posterSizes } = usePosterSize()
const search = ref('')
const activeFilter = ref('all')
const activeSort = ref('alpha')
const filtersDisponibilite = [
{ label: 'Toutes', value: 'all' },
{ label: 'Disponibles', value: 'available' },
{ label: 'Sans torrent', value: 'unavailable' },
]
const filtersImport = [
{ label: 'Importés', value: 'complete', color: '#22c55e' },
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
{ label: 'Partiel', value: 'partial', color: '#9b59b6' },
]
const sortOptions = [
{ label: 'A→Z', value: 'alpha' },
{ label: 'Z→A', value: 'alpha-desc' },
{ label: 'Récents', value: 'recent' },
{ label: 'Importés', value: 'imported' },
]
const legend = [
{ label: 'Importé', color: '#22c55e' },
{ label: 'En cours', color: '#3b82f6' },
{ label: 'Partiel', color: '#9b59b6' },
{ label: 'Non importé', color: 'var(--border)' },
]
const hasActiveFilter = computed(() => activeFilter.value !== 'all')
const filtered = computed(() => {
let list = [...store.series]
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
if (activeFilter.value === 'downloading') list = list.filter(s => ['downloading', 'partial'].includes(s.download_state))
if (activeFilter.value === 'partial') list = list.filter(s => s.download_state === 'partial')
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(s => s.title.toLowerCase().includes(q))
}
switch (activeSort.value) {
case 'alpha': list.sort((a, b) => a.title.localeCompare(b.title, 'fr')); break
case 'alpha-desc': list.sort((a, b) => b.title.localeCompare(a.title, 'fr')); break
case 'recent': list.sort((a, b) => (b.year ?? 0) - (a.year ?? 0)); break
case 'imported': {
const order: Record<string, number> = { complete: 0, partial: 1, downloading: 2, none: 3 }
list.sort((a, b) => (order[a.download_state] ?? 3) - (order[b.download_state] ?? 3))
break
}
}
return list
})
function stateColor(state: string): string {
return ({
complete : '#22c55e',
downloading: '#3b82f6',
partial : '#9b59b6',
none : 'transparent',
} as Record<string, string>)[state] ?? 'transparent'
}
// ── Polling downloads + notifs import ────────────────────────
let dlInterval: ReturnType<typeof setInterval> | null = null
const seenNotifs = new Set<string>()
async function fetchOrganizeNotifs() {
try {
const res = await fetch('/api/organize/recent', { credentials: 'include' })
if (!res.ok) return
const notifs: any[] = await res.json()
let hasNew = false
for (const n of notifs) {
const key = `${n.hash}-${n.at}`
if (seenNotifs.has(key)) continue
seenNotifs.add(key)
hasNew = true
if (n.done > 0) {
const msg = n.errors > 0
? `${n.name} — ${n.done} fichier(s) importé(s), ${n.errors} erreur(s)`
: `${n.name} — ${n.done} fichier(s) importé(s) ✓`
toast(msg, n.errors > 0 ? 'error' : 'success')
}
}
if (hasNew) await store.fetchSeries()
} catch {}
}
onMounted(async () => {
if (store.series.length === 0) await store.fetchSeries()
dlStore.refresh()
fetchOrganizeNotifs()
dlInterval = setInterval(() => {
dlStore.refresh()
fetchOrganizeNotifs()
}, 10000)
})
onActivated(async () => {
store.fetchSeries()
dlStore.refresh()
})
onUnmounted(() => {
if (dlInterval) clearInterval(dlInterval)
})
</script>
-495
View File
@@ -1,495 +0,0 @@
<template>
<div class="max-w-2xl mx-auto px-8 py-8">
<!-- Header -->
<div class="flex items-center gap-4 mb-8">
<button
@click="$router.push('/')"
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
← CATALOGUE
</button>
<h1 class="text-xl font-black text-white font-sans">Paramètres</h1>
</div>
<!-- Bannière données manquantes -->
<div v-if="torrentsStatus.empty" class="border border-[#e8513a]/40 bg-[#e8513a]/5 p-4 mb-6 flex items-center justify-between gap-4">
<div>
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES MANQUANTES</div>
<p class="text-sm text-[#a0b4c4]">
{{ torrentsStatus.exists ? 'Le fichier de torrents est vide.' : 'Aucun fichier de torrents trouvé.' }}
Téléchargez les données pour utiliser Fankarr.
</p>
</div>
<button @click="update" :disabled="updating"
class="bg-[#e8513a] text-white px-5 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer whitespace-nowrap">
{{ updating ? 'TÉLÉCHARGEMENT...' : 'TÉLÉCHARGER' }}
</button>
</div>
<!-- Données torrents -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES</div>
<h2 class="text-sm font-bold text-white mb-1">Torrents Fankai</h2>
<p class="text-[11px] text-[#5a7a94] mb-5">
{{ torrentsStatus.empty ? 'Aucune donnée chargée' : `${torrentsStatus.count} torrents chargés` }}
</p>
<button @click="update" :disabled="updating"
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
{{ updating ? 'MISE À JOUR...' : 'METTRE À JOUR' }}
</button>
</div>
<!-- Clients torrent -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">TÉLÉCHARGEMENT</div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-sm font-bold text-white">Clients torrent</h2>
<button @click="openAddModal"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
+ AJOUTER
</button>
</div>
<div v-if="clients.length === 0" class="text-[11px] text-[#5a7a94]">
Aucun client configuré
</div>
<div v-else class="flex flex-col gap-2">
<div v-for="client in clients" :key="client.uuid"
class="flex items-center justify-between border border-[#1e2d3d] px-4 py-3">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full"
:class="healthStatus[client.uuid] === true ? 'bg-green-500' :
healthStatus[client.uuid] === false ? 'bg-red-500' : 'bg-[#1e2d3d]'"/>
<div>
<p class="text-sm text-white font-medium">{{ client.name }}</p>
<p class="text-[10px] text-[#5a7a94] tracking-widest mt-0.5">{{ clientLabel(client.type) }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="testClient(client.uuid)"
class="text-[10px] text-[#5a7a94] tracking-widest hover:text-[#3a8fe8] transition-colors cursor-pointer px-2 py-1">
TESTER
</button>
<button @click="deleteClient(client.uuid)"
class="text-[10px] text-[#5a7a94] tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer px-2 py-1">
SUPPRIMER
</button>
</div>
</div>
</div>
</div>
<!-- Médias & Organisation -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">MÉDIAS</div>
<h2 class="text-sm font-bold text-white mb-6">Organisation</h2>
<div class="flex flex-col gap-5">
<!-- Dossier complétion -->
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER COMPLÉTION</label>
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<button @click="openPicker('completePath')"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate"
:class="form.completePath ? 'text-white' : 'text-[#5a7a94]'">
{{ form.completePath || '/' }}
</button>
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier de complétion de qBittorrent</p>
</div>
<!-- Dossier Jellyfin -->
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER JELLYFIN</label>
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<button @click="openPicker('mediaPath')"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate"
:class="form.mediaPath ? 'text-white' : 'text-[#5a7a94]'">
{{ form.mediaPath || '/' }}
</button>
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier racine de la bibliothèque Fankai dans Jellyfin</p>
</div>
<!-- Mode organisation -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">MODE</label>
<div class="flex gap-2">
<button @click="form.organizeMode = 'hardlink'"
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
:class="form.organizeMode === 'hardlink'
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
HARDLINK
</button>
<button @click="form.organizeMode = 'move'"
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
:class="form.organizeMode === 'move'
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
MOVE
</button>
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
<span class="text-white">Hardlink recommandé</span> — le fichier reste dans qBittorrent pour le ratio
</p>
</div>
<!-- Support NFO -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">NFO / MÉTADONNÉES</label>
<div class="flex items-center gap-3">
<button @click="form.nfoSupport = !form.nfoSupport"
class="relative shrink-0 w-11 h-6 rounded-full transition-colors cursor-pointer"
:class="form.nfoSupport ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
<span class="absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="form.nfoSupport ? 'translate-x-5' : 'translate-x-0'" />
</button>
<span class="text-xs text-[#c8d8e8]">Télécharger les NFO et images depuis GitLab</span>
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
Pour les lecteurs sans agent Fankai (Kodi, Infuse, etc.) — télécharge les métadonnées depuis
<span class="text-white">fankai_pack</span> au moment de l'organisation
</p>
</div>
<div class="pt-1 flex items-center gap-3">
<button type="button" @click="save" :disabled="saving"
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
{{ saving ? '...' : 'SAUVEGARDER' }}
</button>
<button type="button" @click="scan" :disabled="scanning"
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors disabled:opacity-50 cursor-pointer">
{{ scanning ? 'SCAN...' : 'ANALYSER BIBLIOTHÈQUE' }}
</button>
<span v-if="scanResult" class="text-[10px]"
:class="scanResult.added > 0 ? 'text-green-400' : 'text-[#5a7a94]'">
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
</span>
</div>
</div>
</div>
<!-- Compte -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">COMPTE</div>
<h2 class="text-sm font-bold text-white mb-6">Session</h2>
<button type="button" @click="logout"
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
SE DÉCONNECTER
</button>
</div>
<!-- Folder Picker -->
<FolderPicker
v-if="picker.open"
:initial-path="picker.currentPath"
@select="onPickerSelect"
@cancel="picker.open = false"
/>
<!-- Modal ajout client -->
<div v-if="modal.open" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
<div class="bg-[#0d1219] border border-[#1e2d3d] w-full max-w-md p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-sm font-bold text-white">Ajouter un client</h3>
<button @click="closeModal" class="text-[#5a7a94] hover:text-white transition-colors cursor-pointer">✕</button>
</div>
<div class="mb-4">
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">NOM</label>
<input v-model="modal.name" placeholder="Mon qBittorrent"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"/>
</div>
<div class="mb-4">
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">TYPE</label>
<select v-model="modal.type" @change="onTypeChange"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors cursor-pointer">
<option value="">Choisir un type...</option>
<option v-for="def in availableClients" :key="def.id" :value="def.id">{{ def.label }}</option>
</select>
</div>
<div v-if="currentDefinition" class="flex flex-col gap-3 mb-6">
<div v-for="field in currentDefinition.fields" :key="field.key">
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">{{ field.label.toUpperCase() }}</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"/>
</div>
</div>
<div class="flex gap-3">
<button @click="saveClient" :disabled="modal.saving || !modal.tested"
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
{{ modal.saving ? '...' : 'ENREGISTRER' }}
</button>
<button @click="testNewClient" :disabled="modal.testing || !modal.type"
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#3a8fe8] hover:text-[#3a8fe8] transition-colors disabled:opacity-50 cursor-pointer">
{{ modal.testing ? '...' : modal.tested ? 'TESTÉ ✓' : 'TESTER' }}
</button>
</div>
<p v-if="!modal.tested && modal.type" class="text-[10px] text-[#5a7a94] mt-3">
Testez la connexion avant d'enregistrer
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import FolderPicker from '@/components/FolderPicker.vue'
const router = useRouter()
const auth = useAuthStore()
const { add: toast } = useToast()
// ── State ──────────────────────────────────────────────────────
const form = ref({
mediaPath : '',
completePath: '',
organizeMode: 'hardlink' as 'hardlink' | 'move',
nfoSupport : false,
})
const saving = ref(false)
const updating = ref(false)
const scanning = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
// ── Folder picker ──────────────────────────────────────────────
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
open: false, field: 'completePath', currentPath: '/'
})
function openPicker(field: 'completePath' | 'mediaPath') {
picker.value = {
open : true,
field,
currentPath: (form.value as any)[field] || '/',
}
}
function onPickerSelect(path: string) {
(form.value as any)[picker.value.field] = path
picker.value.open = false
}
const isDocker = ref(false)
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
const clients = ref<any[]>([])
const availableClients = ref<any[]>([])
const healthStatus = ref<Record<string, boolean | null>>({})
const modal = ref({
open : false,
name : '',
type : '',
config : {} as Record<string, string>,
testing: false,
saving : false,
tested : false,
})
// ── Computed ───────────────────────────────────────────────────
const currentDefinition = computed(() =>
availableClients.value.find(d => d.id === modal.value.type) ?? null
)
function clientLabel(type: string): string {
return availableClients.value.find(d => d.id === type)?.label ?? type
}
// ── Init ───────────────────────────────────────────────────────
onMounted(async () => {
const [settingsRes, statusRes, clientsRes, availRes, systemRes] = await Promise.all([
fetch('/api/settings', { credentials: 'include' }),
fetch('/api/torrents/status', { credentials: 'include' }),
fetch('/api/torrent-clients', { credentials: 'include' }),
fetch('/api/torrent-clients/available', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
])
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (statusRes.ok) torrentsStatus.value = await statusRes.json()
if (clientsRes.ok) clients.value = await clientsRes.json()
if (availRes.ok) availableClients.value = await availRes.json()
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
for (const c of clients.value) refreshHealth(c.uuid)
})
// ── Healthcheck ────────────────────────────────────────────────
async function refreshHealth(uuid: string) {
healthStatus.value[uuid] = null
try {
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
healthStatus.value[uuid] = res.ok ? (await res.json()).online : false
} catch {
healthStatus.value[uuid] = false
}
}
// ── Test / delete clients ──────────────────────────────────────
async function testClient(uuid: string) {
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
const { ok, message } = await res.json()
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
healthStatus.value[uuid] = ok
}
async function deleteClient(uuid: string) {
const res = await fetch(`/api/torrent-clients/${uuid}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) {
clients.value = clients.value.filter(c => c.uuid !== uuid)
delete healthStatus.value[uuid]
toast('Client supprimé', 'success')
} else {
toast('Erreur lors de la suppression', 'error')
}
}
// ── Modal ──────────────────────────────────────────────────────
function openAddModal() {
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
}
function closeModal() {
modal.value.open = false
}
function onTypeChange() {
modal.value.config = {}
modal.value.tested = false
const def = currentDefinition.value
if (def) {
for (const field of def.fields) {
if (field.default !== undefined) modal.value.config[field.key] = String(field.default)
}
}
}
async function testNewClient() {
modal.value.testing = true
modal.value.tested = false
try {
const res = await fetch('/api/torrent-clients/test-config', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ type: modal.value.type, config: modal.value.config })
})
const { ok, message } = await res.json()
modal.value.tested = ok
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
modal.value.testing = false
}
}
async function saveClient() {
if (!modal.value.tested) return
modal.value.saving = true
try {
const res = await fetch('/api/torrent-clients', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name : modal.value.name || clientLabel(modal.value.type),
type : modal.value.type,
config: modal.value.config
})
})
if (res.ok) {
const client = await res.json()
clients.value.push(client)
refreshHealth(client.uuid)
closeModal()
toast('Client enregistré ✓', 'success')
} else {
const { error } = await res.json()
toast(error ?? "Erreur lors de l'enregistrement", 'error')
}
} finally {
modal.value.saving = false
}
}
// ── Données torrents ───────────────────────────────────────────
async function update() {
updating.value = true
try {
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
if (res.ok) {
const { count } = await res.json()
torrentsStatus.value = { exists: true, count, empty: count === 0 }
toast(`${count} torrents téléchargés ✓`, 'success')
} else {
const { error } = await res.json()
toast(error ?? 'Erreur lors de la mise à jour', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
updating.value = false
}
}
// ── Scan bibliothèque ──────────────────────────────────────────
async function scan() {
scanning.value = true
scanResult.value = null
try {
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
if (res.ok) {
const data = await res.json()
scanResult.value = { found: data.found, added: data.added }
toast(
data.added > 0
? `${data.found} fichiers scannés — ${data.added} ajoutés`
: `${data.found} fichiers scannés — rien de nouveau`,
'success'
)
} else {
toast('Erreur lors du scan', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
scanning.value = false
}
}
// ── Sauvegarder settings ───────────────────────────────────────
async function save() {
saving.value = true
try {
const res = await fetch('/api/settings', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(form.value)
})
if (res.ok) toast('Configuration sauvegardée', 'success')
else toast('Erreur lors de la sauvegarde', 'error')
} finally {
saving.value = false
}
}
async function logout() {
await auth.logout()
await router.push('/auth')
}
</script>
+49
View File
@@ -0,0 +1,49 @@
<template>
<div class="flex flex-col gap-6">
<div>
<h2 class="text-base font-semibold text-primary">Avancé</h2>
<p class="text-sm text-muted mt-1">Options pour le débogage et le développement.</p>
</div>
<div class="settings-card flex flex-col gap-5">
<SettingsToggle
v-model="devMode"
label="Mode développeur"
description="Affiche un panneau de debug sur le dashboard : mémoire, cache, uptime, worker."
/>
</div>
<div v-if="saved" class="text-xs text-green-400">Paramètre sauvegardé.</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
const devMode = ref(false)
const saved = ref(false)
let saveTimer: ReturnType<typeof setTimeout> | null = null
onMounted(async () => {
const res = await fetch('/api/settings', { credentials: 'include' })
if (res.ok) {
const s = await res.json()
devMode.value = s.devMode ?? false
}
})
watch(devMode, async (val) => {
await fetch('/api/settings', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ devMode: val }),
})
saved.value = true
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saved.value = false }, 2000)
})
</script>
+90
View File
@@ -0,0 +1,90 @@
<template>
<div class="flex flex-col gap-6">
<div>
<h2 class="text-base font-semibold text-primary">Catalogue Fankai</h2>
<p class="text-sm text-muted mt-1">
Données locales synchronisées depuis GitLab. Mise à jour automatique toutes les 6h.
</p>
</div>
<!-- Loading -->
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<template v-else>
<!-- Bannière données manquantes -->
<div
v-if="status.empty"
class="border border-accent/30 bg-accent-muted rounded-lg p-4 flex items-center justify-between gap-4"
>
<div>
<p class="text-xs text-accent font-medium mb-1">Données manquantes</p>
<p class="text-sm text-secondary">
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
Téléchargez les données pour utiliser FanKarr.
</p>
</div>
<button @click="update" :disabled="updating" class="btn-primary whitespace-nowrap">
{{ updating ? 'Téléchargement...' : 'Télécharger' }}
</button>
</div>
<!-- Statut -->
<div class="settings-card">
<p class="text-xs text-muted mb-1">Statut</p>
<p class="text-sm text-primary font-medium">
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}
</p>
</div>
<!-- Action -->
<div>
<button @click="update" :disabled="updating" class="btn-primary">
{{ updating ? 'Mise à jour...' : 'Mettre à jour maintenant' }}
</button>
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const updating = ref(false)
const loaded = ref(false)
const status = ref({ exists: false, count: 0, empty: true })
onMounted(async () => {
const res = await fetch('/api/torrents/status', { credentials: 'include' })
if (res.ok) status.value = await res.json()
loaded.value = true
})
async function update() {
updating.value = true
try {
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
if (res.ok) {
const { count } = await res.json()
status.value = { exists: true, count, empty: count === 0 }
toast(`${count} séries chargées ✓`, 'success')
} else {
const { error } = await res.json()
toast(error ?? 'Erreur lors de la mise à jour', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
updating.value = false
}
}
</script>
+402
View File
@@ -0,0 +1,402 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-primary">Clients torrent</h2>
<p class="text-sm text-muted mt-1">Gérez vos clients de téléchargement.</p>
</div>
<button @click="openAddModal" class="btn-secondary">+ Ajouter</button>
</div>
<!-- Liste vide -->
<div v-if="clients.length === 0" class="settings-card text-sm text-muted">
Aucun client configuré.
</div>
<!-- Liste clients -->
<div v-else class="flex flex-col gap-2">
<div
v-for="client in clients"
:key="client.uuid"
class="settings-card flex items-center justify-between"
>
<div class="flex items-center gap-3">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="healthStatus[client.uuid] === true ? 'bg-green-500' :
healthStatus[client.uuid] === false ? 'bg-red-500' : 'bg-border'"
/>
<div>
<p class="text-sm text-primary font-medium">{{ client.name }}</p>
<p class="text-xs text-muted mt-0.5">{{ clientLabel(client.type) }}</p>
</div>
</div>
<div class="flex items-center gap-1">
<button @click="testClient(client.uuid)" class="btn-ghost text-xs">Tester</button>
<button @click="editClient(client)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
<button @click="deleteClient(client.uuid)" class="btn-ghost text-xs text-red-400 hover:text-red-300">Supprimer</button>
</div>
</div>
</div>
<!-- Tableau comparatif -->
<div>
<h3 class="text-sm font-semibold text-primary mb-1">Comparatif des clients</h3>
<p class="text-xs text-muted mb-3">Fonctionnalités supportées par chaque client.</p>
<div class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-xs">
<thead>
<tr class="border-b border-border bg-shell">
<th class="text-left px-4 py-2.5 text-muted font-medium">Client</th>
<th class="text-center px-3 py-2.5 text-muted font-medium">Sélection fichier</th>
<th class="text-center px-3 py-2.5 text-muted font-medium">Catégories</th>
<th class="text-center px-3 py-2.5 text-muted font-medium">Dossier cible</th>
<th class="text-center px-3 py-2.5 text-muted font-medium">Remap. chemin</th>
</tr>
</thead>
<tbody class="divide-y divide-border/60">
<tr v-for="row in clientMatrix" :key="row.id" class="hover:bg-hover/40 transition-colors">
<td class="px-4 py-2.5">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full shrink-0"
:class="healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === true ? 'bg-green-500' :
healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === false ? 'bg-red-500' : 'bg-transparent'" />
<span class="text-primary font-medium">{{ row.label }}</span>
</div>
</td>
<td class="text-center px-3 py-2.5">
<span :class="row.fileSelect === '✗' ? 'text-muted' : row.fileSelect.startsWith('Async') ? 'text-yellow-500' : 'text-green-400'">
{{ row.fileSelect }}
</span>
</td>
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-green-400' : 'text-muted'">{{ row.categories ? '✓' : '✗' }}</td>
<td class="text-center px-3 py-2.5 text-green-400">✓</td>
<td class="text-center px-3 py-2.5 text-green-400">✓</td>
</tr>
</tbody>
</table>
</div>
<p class="text-[11px] text-muted mt-2">
<span class="text-green-400">✓ Natif</span> — sélection à l'ajout, aucun fichier superflu ·
<span class="text-green-400">Async ✓</span> — sélection dès réception des métadonnées, avant tout téléchargement ·
<span class="text-muted">✗</span> — non supporté, torrent complet téléchargé
</p>
</div>
<!-- Modal ajout -->
<Teleport to="body">
<div
v-if="modal.open"
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
@click.self="closeModal"
>
<div class="bg-card border border-border rounded-xl w-full max-w-md p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-primary">{{ modal.uuid ? 'Modifier un client' : 'Ajouter un client' }}</h3>
<button @click="closeModal" class="text-muted hover:text-primary transition-colors">✕</button>
</div>
<div class="flex flex-col gap-3">
<div>
<label class="settings-label">Nom</label>
<input v-model="modal.name" placeholder="Mon client torrent" class="settings-input" />
</div>
<div>
<label class="settings-label">Type</label>
<select v-model="modal.type" @change="onTypeChange" class="settings-input">
<option value="">Choisir un type...</option>
<option v-for="def in availableClients" :key="def.id" :value="def.id">{{ def.label }}</option>
</select>
</div>
<!-- Champs principaux -->
<template v-if="currentDefinition">
<div v-for="field in basicFields" :key="field.key">
<label class="settings-label">
{{ field.label }}
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/>
</div>
<!-- Paramètres avancés -->
<div v-if="advancedFields.length > 0">
<button
type="button"
@click="advancedOpen = !advancedOpen"
class="flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-full py-1"
>
<ChevronDown
:size="13"
class="transition-transform duration-200 shrink-0"
:class="advancedOpen ? 'rotate-180' : ''"
/>
Paramètres avancés
</button>
<div v-if="advancedOpen" class="flex flex-col gap-3 mt-2 pl-3 border-l border-border">
<div v-for="field in advancedFields" :key="field.key">
<label class="settings-label flex items-center gap-1.5">
{{ field.label }}
<span
class="inline-flex items-center justify-center w-4 h-4 rounded-full border border-border text-[10px] text-muted cursor-default leading-none shrink-0 relative group"
tabindex="-1"
>
i
<span class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-56 text-[11px] text-secondary bg-card border border-border rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-10 leading-relaxed">
{{ fieldTooltip(field.key) }}
</span>
</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
placeholder=""
class="settings-input"
/>
</div>
</div>
</div>
</template>
</div>
<p v-if="!modal.tested && modal.type" class="text-xs text-muted">
Testez la connexion avant d'enregistrer.
</p>
<div class="flex gap-2 pt-1">
<button
@click="saveClient"
:disabled="modal.saving || !modal.tested"
class="btn-primary"
>
{{ modal.saving ? '...' : 'Enregistrer' }}
</button>
<button
@click="testNewClient"
:disabled="modal.testing || !modal.type"
class="btn-secondary"
>
{{ modal.testing ? '...' : modal.tested ? 'Testé ✓' : 'Tester' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { ChevronDown } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const clients = ref<any[]>([])
const availableClients = ref<any[]>([])
const healthStatus = ref<Record<string, boolean | null>>({})
const clientMatrix = [
{ id: 'qbittorrent', label: 'qBittorrent', fileSelect: 'Async ✓', categories: true },
{ id: 'transmission', label: 'Transmission', fileSelect: '✓ Natif', categories: true },
{ id: 'rtorrent', label: 'rTorrent / ruTorrent', fileSelect: 'Async ✓', categories: true },
{ id: 'utorrent', label: 'uTorrent', fileSelect: 'Async ✓', categories: true },
{ id: 'synology-ds', label: 'Synology Download Station', fileSelect: '✗', categories: false },
]
const modal = ref({
open : false,
uuid : null as string | null,
name : '',
type : '',
config : {} as Record<string, string>,
testing: false,
saving : false,
tested : false,
})
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath']
const FIELD_TOOLTIPS: Record<string, string> = {
savePath : 'Optionnel. Permet de placer les téléchargements dans un sous-dossier spécifique à l\'intérieur du dossier déjà configuré dans la gestion des médias.',
remotePath : 'Si le client tourne sur une autre machine, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.',
localPath : 'Chemin équivalent au chemin distant, mais vu par FanKarr sur sa machine (ex: /mnt/nas/downloads). Les deux champs fonctionnent en binôme pour faire la correspondance.',
}
function fieldTooltip(key: string): string {
return FIELD_TOOLTIPS[key] ?? ''
}
const advancedOpen = ref(false)
const currentDefinition = computed(() =>
availableClients.value.find(d => d.id === modal.value.type) ?? null
)
const basicFields = computed(() =>
currentDefinition.value?.fields.filter((f: any) => !ADVANCED_KEYS.includes(f.key)) ?? []
)
const advancedFields = computed(() =>
currentDefinition.value?.fields.filter((f: any) => ADVANCED_KEYS.includes(f.key)) ?? []
)
function clientLabel(type: string) {
return availableClients.value.find(d => d.id === type)?.label ?? type
}
onMounted(async () => {
const [clientsRes, availRes] = await Promise.all([
fetch('/api/torrent-clients', { credentials: 'include' }),
fetch('/api/torrent-clients/available', { credentials: 'include' }),
])
if (clientsRes.ok) clients.value = await clientsRes.json()
if (availRes.ok) availableClients.value = await availRes.json()
for (const c of clients.value) refreshHealth(c.uuid)
})
async function refreshHealth(uuid: string) {
healthStatus.value[uuid] = null
try {
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
healthStatus.value[uuid] = res.ok ? (await res.json()).online : false
} catch {
healthStatus.value[uuid] = false
}
}
async function testClient(uuid: string) {
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
const { ok, message } = await res.json()
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
healthStatus.value[uuid] = ok
}
async function deleteClient(uuid: string) {
const res = await fetch(`/api/torrent-clients/${uuid}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) {
clients.value = clients.value.filter(c => c.uuid !== uuid)
delete healthStatus.value[uuid]
toast('Client supprimé', 'success')
} else {
toast('Erreur lors de la suppression', 'error')
}
}
function openAddModal() {
modal.value = { open: true, uuid: null, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
advancedOpen.value = false
}
function editClient(client: any) {
modal.value = {
open : true,
uuid : client.uuid,
name : client.name,
type : client.type,
config : JSON.parse(JSON.stringify(client.config)),
testing: false,
saving : false,
tested : true,
}
}
function closeModal() { modal.value.open = false }
function onTypeChange() {
modal.value.config = {}
modal.value.tested = false
advancedOpen.value = false
const def = currentDefinition.value
if (def) {
for (const field of def.fields) {
if (field.default !== undefined) modal.value.config[field.key] = String(field.default)
}
}
}
// Reset tested dès que l'utilisateur modifie un champ (nom ou config)
// sauf si c'est un mot de passe masqué non touché
watch(() => modal.value.name, () => {
if (modal.value.uuid) modal.value.tested = false
})
watch(() => modal.value.config, (newConfig) => {
if (!modal.value.uuid) return
const driver = currentDefinition.value
if (!driver) return
// Si au moins un champ non-password a changé → reset tested
// Pour les passwords, on reset seulement si la valeur n'est plus le masque
for (const [key, val] of Object.entries(newConfig)) {
const field = driver.fields.find(f => f.key === key)
if (!field) continue
if (field.type === 'password' && val === '••••••••') continue
modal.value.tested = false
return
}
}, { deep: true })
async function testNewClient() {
modal.value.testing = true
modal.value.tested = false
try {
const res = await fetch('/api/torrent-clients/test-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ type: modal.value.type, config: modal.value.config, uuid: modal.value.uuid }),
})
const { ok, message } = await res.json()
modal.value.tested = ok
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
modal.value.testing = false
}
}
async function saveClient() {
if (!modal.value.tested) return
modal.value.saving = true
try {
const isEdit = !!modal.value.uuid
const method = isEdit ? 'PUT' : 'POST'
const url = isEdit ? `/api/torrent-clients/${modal.value.uuid}` : '/api/torrent-clients'
const res = await fetch(url, {
method, headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({
name : modal.value.name || clientLabel(modal.value.type),
type : modal.value.type,
config: modal.value.config,
}),
})
if (res.ok) {
const client = await res.json()
if (isEdit) {
const index = clients.value.findIndex(c => c.uuid === client.uuid)
if (index !== -1) clients.value[index] = client
} else {
clients.value.push(client)
}
refreshHealth(client.uuid)
closeModal()
toast(isEdit ? 'Client modifié ✓' : 'Client enregistré ✓', 'success')
} else {
const { error } = await res.json()
toast(error ?? "Erreur lors de l'enregistrement", 'error')
}
} finally {
modal.value.saving = false
}
}
</script>
+311
View File
@@ -0,0 +1,311 @@
<template>
<div class="flex flex-col gap-6 px-4 md:px-8 py-6 max-w-4xl">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-primary">Gestion des imports</h2>
<p class="text-xs text-muted mt-0.5">
Visualisez et renommez vos fichiers importés selon le mode actuel
<span class="ml-1 px-1.5 py-0.5 rounded text-[10px]"
:class="nfoSupport ? 'bg-accent/10 text-accent' : 'bg-hover text-muted'">
{{ nfoSupport ? 'Mode NFO' : 'Mode Formaté' }}
</span>
</p>
</div>
<div class="flex items-center gap-2">
<button @click="load" :disabled="loading" class="btn-secondary">
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
:class="loading ? 'animate-spin' : ''">
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
<polyline points="16 5 21 5 21 10"/>
</svg>
Actualiser
</button>
<button
@click="purgeNfo"
:disabled="purgingNfo"
class="btn-secondary text-red-400 hover:text-red-300"
title="Supprime tous les .nfo, .png, .jpg, .tbn dans les dossiers séries"
>
{{ purgingNfo ? 'Suppression…' : 'Purger NFO / images' }}
</button>
<button
v-if="totalNeedsRename > 0"
@click="renameAll()"
:disabled="renamingAll"
class="btn-primary"
>
{{ renamingAll ? 'Renommage…' : `Tout renommer (${totalNeedsRename})` }}
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<!-- Vide -->
<div v-else-if="series.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
<p class="text-sm text-muted">Aucune série importée</p>
<p class="text-xs text-muted">Importez des séries depuis la page Activité ou via l'import manuel.</p>
</div>
<!-- Résultat rename all -->
<div v-if="renameResult" class="settings-card flex items-center justify-between gap-3">
<p class="text-xs" :class="renameResult.errors.length > 0 ? 'text-yellow-500' : 'text-green-400'">
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}
<span v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</span>
</p>
<button @click="renameResult = null" class="text-xs text-muted hover:text-primary">✕</button>
</div>
<!-- Liste séries -->
<div v-else class="flex flex-col gap-3">
<div
v-for="serie in filteredSeries"
:key="serie.serie_id"
class="settings-card flex flex-col gap-0"
>
<!-- Header série -->
<div class="flex items-center justify-between py-1">
<div class="flex items-center gap-2.5">
<button @click="toggleSerie(serie.serie_id)" class="text-muted hover:text-primary transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
class="transition-transform" :class="collapsed.has(serie.serie_id) ? '' : 'rotate-180'">
<polyline points="18 15 12 9 6 15"/>
</svg>
</button>
<p class="text-sm font-medium text-primary">{{ serie.serie_title }}</p>
<span class="text-xs text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
<span v-if="serie.needs_rename > 0"
class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/20">
{{ serie.needs_rename }} à renommer
</span>
<span v-if="serie.episodes.filter((e: any) => !e.file_exists).length > 0"
class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">
{{ serie.episodes.filter((e: any) => !e.file_exists).length }} introuvable{{ serie.episodes.filter((e: any) => !e.file_exists).length > 1 ? 's' : '' }}
</span>
<span v-else-if="serie.needs_rename === 0" class="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400 border border-green-500/20">
✓ À jour
</span>
</div>
<div class="flex items-center gap-2">
<button v-if="serie.needs_rename > 0" @click="renameAll(serie.serie_id)" :disabled="renamingAll" class="text-xs text-accent hover:underline">
Tout renommer
</button>
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="text-xs text-muted hover:text-primary transition-colors">
Désimporter
</button>
<button @click="unimportSerie(serie, true)" :disabled="renamingAll" class="text-xs text-red-400 hover:text-red-300 transition-colors">
Supprimer fichiers
</button>
</div>
</div>
<!-- Liste épisodes -->
<div v-if="!collapsed.has(serie.serie_id)" class="flex flex-col divide-y divide-border/50 mt-2 -mx-4 px-4">
<div
v-for="ep in serie.episodes"
:key="ep.episode_id"
class="flex items-center gap-3 py-2.5"
>
<!-- Numéro -->
<span class="text-xs text-muted font-mono shrink-0 w-14">
S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}
</span>
<!-- Noms -->
<div class="flex-1 min-w-0">
<p class="text-xs text-primary truncate">{{ ep.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5">
<span class="text-[11px] font-mono text-muted truncate max-w-xs">{{ ep.current_name }}</span>
<template v-if="ep.needs_rename">
<svg width="10" height="10" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0 text-yellow-400">
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
</svg>
<span class="text-[11px] font-mono text-yellow-400 truncate max-w-xs">{{ ep.expected_name }}</span>
</template>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 shrink-0">
<span v-if="!ep.file_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">Introuvable</span>
<span v-else-if="!ep.needs_rename" class="text-[10px] text-green-400">✓</span>
<button
v-else
@click="renameSingle(ep, serie)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 transition-colors"
>
{{ renamingEp[ep.episode_id] ? '…' : 'Renommer' }}
</button>
<button
@click="unimportEp(ep, false)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors"
>
Désimporter
</button>
<button
@click="unimportEp(ep, true)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-red-500/20 text-red-400 hover:bg-red-500/10 transition-colors"
>
Supprimer
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
// ─── State ────────────────────────────────────────────────────
const loading = ref(true)
const renamingAll = ref(false)
const purgingNfo = ref(false)
const series = ref<any[]>([])
const nfoSupport = ref(false)
const collapsed = ref<Set<number>>(new Set())
const renamingEp = ref<Record<number, boolean>>({})
const renameResult = ref<{ done: number; errors: any[] } | null>(null)
const showOnlyNeedsRename = ref(false)
// ─── Computed ──────────────────────────────────────────────────
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
const filteredSeries = computed(() =>
showOnlyNeedsRename.value ? series.value.filter(s => s.needs_rename > 0) : series.value
)
// ─── Actions ──────────────────────────────────────────────────
async function load() {
loading.value = true
try {
const res = await fetch('/api/organized-summary', { credentials: 'include' })
if (!res.ok) { toast('Erreur chargement', 'error'); return }
const data = await res.json()
series.value = data.series ?? []
nfoSupport.value = data.nfo_support ?? false
// Replier les séries sans rename par défaut
collapsed.value = new Set(series.value.filter(s => s.needs_rename === 0).map((s: any) => s.serie_id))
} finally {
loading.value = false
}
}
function toggleSerie(id: number) {
if (collapsed.value.has(id)) collapsed.value.delete(id)
else collapsed.value.add(id)
// Forcer la réactivité
collapsed.value = new Set(collapsed.value)
}
async function renameAll(serieId?: number) {
renamingAll.value = true
renameResult.value = null
try {
const res = await fetch('/api/rename-all', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify(serieId ? { serie_id: serieId } : {}),
})
const data = await res.json()
renameResult.value = { done: data.done, errors: data.errors ?? [] }
if (data.done > 0) await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingAll.value = false
}
}
async function renameSingle(ep: any, serie: any) {
renamingEp.value[ep.episode_id] = true
try {
const res = await fetch('/api/rename-episode', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ serie_id: serie.serie_id, episode_id: ep.episode_id, torrent_hash: ep.torrent_hash }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
toast(`Renommé : ${data.new_name}`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingEp.value[ep.episode_id] = false
}
}
async function purgeNfo() {
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
purgingNfo.value = true
try {
const res = await fetch('/api/purge-nfo', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
toast(`${data.deleted} fichier${data.deleted > 1 ? 's' : ''} supprimé${data.deleted > 1 ? 's' : ''}${data.errors.length > 0 ? ` (${data.errors.length} erreur${data.errors.length > 1 ? 's' : ''})` : ''}`, data.errors.length > 0 ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
purgingNfo.value = false
}
}
async function unimportSerie(serie: any, deleteFile: boolean) {
if (!confirm(`Désimporter "${serie.serie_title}"${deleteFile ? ' et supprimer les fichiers' : ''} ?`)) return
renamingAll.value = true
try {
const res = await fetch(`/api/organized/${serie.serie_id}?deleteFile=${deleteFile}`, {
method: 'DELETE', credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
const data = await res.json()
toast(`${data.removed} épisode(s) désimporté(s) ✓`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingAll.value = false
}
}
async function unimportEp(ep: any, deleteFile: boolean) {
renamingEp.value[ep.episode_id] = true
try {
// On cherche le serie_id depuis la liste
const serie = series.value.find(s => s.episodes.some((e: any) => e.episode_id === ep.episode_id))
if (!serie) return
const res = await fetch(`/api/organized/${serie.serie_id}/${ep.episode_id}?deleteFile=${deleteFile}`, {
method : 'DELETE',
credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingEp.value[ep.episode_id] = false
}
}
onMounted(load)
</script>
+185
View File
@@ -0,0 +1,185 @@
<template>
<div class="flex flex-col gap-4">
<!-- Actions -->
<div class="flex items-center justify-between">
<span class="text-xs text-muted">{{ entries.length }} entrées · {{ formatSize(fileSize) }}</span>
<div class="flex gap-2">
<button @click="load" class="btn-secondary text-xs">↺ Rafraîchir</button>
<button @click="showConfirm = true" class="btn-ghost text-xs text-red-400 hover:text-red-300">Effacer</button>
</div>
</div>
<!-- Filtres -->
<div class="flex flex-wrap items-center gap-2">
<div class="flex gap-1">
<button
v-for="l in levels" :key="l.value"
@click="filterLevel = l.value; load()"
class="px-3 py-1 text-xs rounded-lg border transition-colors"
:class="filterLevel === l.value
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ l.label }}
</button>
</div>
<select v-model="filterSource" @change="load()" class="settings-input w-auto text-xs py-1">
<option value="">Toutes les sources</option>
<option value="organize">organize</option>
<option value="api">api</option>
<option value="auth">auth</option>
</select>
<div class="flex items-center gap-1 ml-auto">
<span class="text-xs text-muted mr-1">Afficher</span>
<button
v-for="n in [100, 500, 2000]" :key="n"
@click="limit = n; load()"
class="px-2 py-1 text-xs rounded-lg border transition-colors"
:class="limit === n
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ n === 2000 ? 'Tout' : n }}
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
</div>
<!-- Vide -->
<div v-else-if="entries.length === 0" class="flex items-center justify-center py-16 text-muted text-sm">
Aucun log
</div>
<!-- Table -->
<div v-else class="settings-card p-0 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-xs font-mono">
<thead>
<tr class="border-b border-border">
<th class="text-left text-muted px-4 py-2.5 w-36 font-normal">Horodatage</th>
<th class="text-left text-muted px-3 py-2.5 w-16 font-normal">Niveau</th>
<th class="text-left text-muted px-3 py-2.5 w-24 font-normal">Source</th>
<th class="text-left text-muted px-3 py-2.5 font-normal">Message</th>
</tr>
</thead>
<tbody>
<tr
v-for="(entry, i) in entries" :key="i"
class="border-b border-border/50 hover:bg-hover transition-colors"
:class="entry.level === 'error' ? 'bg-red-500/5' : entry.level === 'warn' ? 'bg-yellow-500/5' : ''"
>
<td class="px-4 py-2 text-muted whitespace-nowrap">{{ formatDate(entry.at) }}</td>
<td class="px-3 py-2 whitespace-nowrap">
<span class="px-1.5 py-0.5 rounded text-[10px]" :class="levelClass(entry.level)">
{{ entry.level.toUpperCase() }}
</span>
</td>
<td class="px-3 py-2 text-muted whitespace-nowrap">{{ entry.source }}</td>
<td class="px-3 py-2 text-primary break-all">
{{ entry.msg }}
<span v-if="entry.meta" class="text-muted ml-2">{{ JSON.stringify(entry.meta) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Confirm effacement -->
<Teleport to="body">
<div v-if="showConfirm" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="showConfirm = false">
<div class="bg-card border border-border rounded-xl p-6 max-w-sm w-full flex flex-col gap-4">
<div>
<h3 class="text-sm font-semibold text-primary mb-1">Effacer les logs ?</h3>
<p class="text-xs text-muted">Cette action est irréversible.</p>
</div>
<div class="flex gap-2">
<button @click="doClear" class="btn-primary bg-red-500 hover:bg-red-400">Effacer</button>
<button @click="showConfirm = false" class="btn-secondary">Annuler</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const entries = ref<any[]>([])
const fileSize = ref(0)
const loading = ref(true)
const showConfirm = ref(false)
const filterLevel = ref('all')
const filterSource = ref('')
const limit = ref(100)
const levels = [
{ label: 'Tous', value: 'all' },
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
{ label: 'Error', value: 'error' },
{ label: 'Debug', value: 'debug' },
]
async function load() {
loading.value = true
try {
const params = new URLSearchParams({
limit: String(limit.value),
level: filterLevel.value,
...(filterSource.value ? { source: filterSource.value } : {}),
})
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
if (res.ok) {
const data = await res.json()
entries.value = data.entries
fileSize.value = data.size
}
} finally {
loading.value = false
}
}
async function doClear() {
showConfirm.value = false
await fetch('/api/logs/clear', { method: 'POST', credentials: 'include' })
toast('Logs effacés', 'success')
await load()
}
function formatDate(iso: string): string {
const d = new Date(iso)
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
function levelClass(level: string): string {
return ({
debug: 'bg-border text-muted',
info : 'bg-blue-500/10 text-blue-400',
warn : 'bg-yellow-500/10 text-yellow-400',
error: 'bg-red-500/10 text-red-400',
} as Record<string, string>)[level] ?? 'bg-border text-muted'
}
onMounted(load)
</script>
+283
View File
@@ -0,0 +1,283 @@
<template>
<div class="flex flex-col gap-6">
<div>
<h2 class="text-base font-semibold text-primary">Media Management</h2>
<p class="text-sm text-muted mt-1">Configuration des dossiers et du comportement d'import.</p>
</div>
<!-- Loading -->
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<template v-else>
<!-- Chemins -->
<div class="settings-card flex flex-col gap-5">
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="settings-label">Dossier téléchargements</label>
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
{{ form.completePath || '/' }}
</button>
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
</div>
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="settings-label">Médiathèque Fankai</label>
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
{{ form.mediaPath || '/' }}
</button>
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
</div>
</div>
<!-- Alerte chemins non configurés -->
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5">
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
<p class="text-xs text-muted">
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
Configurez-les ci-dessus avant de sauvegarder.
</p>
</div>
</div>
<!-- Mode import -->
<div class="settings-card flex flex-col gap-2">
<label class="settings-label">Mode d'import</label>
<div class="flex gap-2">
<button
v-for="mode in (['hardlink', 'copy', 'move'] as const)"
:key="mode"
@click="form.organizeMode = mode"
class="px-4 py-2 text-xs rounded-lg border transition-colors capitalize"
:class="form.organizeMode === mode
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ mode === 'hardlink' ? 'Hardlink' : mode === 'copy' ? 'Copier' : 'Déplacer' }}
</button>
</div>
<p class="text-xs text-muted">
<span class="text-primary">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
</p>
</div>
<!-- Toggles -->
<div class="settings-card flex flex-col gap-5">
<SettingsToggle
v-model="form.autoImport"
label="Import automatique"
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
/>
<SettingsToggle
:model-value="form.nfoSupport"
@update:model-value="onNfoToggle"
label="NFO / Métadonnées"
description="Télécharge les NFO et images depuis GitLab lors de l'import (Kodi, Infuse, etc.)."
/>
<SettingsToggle
v-if="form.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement"
description="Supprime automatiquement le torrent du client après un import en mode Déplacer."
/>
<SettingsToggle
v-model="form.autoUnimportMissing"
label="Désimporter si fichier manquant"
description="Si un fichier importé n'est plus trouvé sur le disque lors du scan, il est automatiquement retiré de la bibliothèque."
/>
</div>
<!-- Plex -->
<div class="settings-card flex items-center justify-between gap-4">
<div>
<p class="settings-label">Plex</p>
<p class="text-xs text-muted mt-0.5">
Créez une bibliothèque Plex connectée à l'agent de métadonnées Fankai.
Requiert Plex Media Server 1.43+ pour la configuration automatique de l'agent.
</p>
</div>
<button @click="plexOpen = true" class="btn-secondary shrink-0">Configurer Plex</button>
</div>
<!-- Actions -->
<div class="flex items-center gap-3">
<button @click="save" :disabled="saving" class="btn-primary">
{{ saving ? '...' : 'Sauvegarder' }}
</button>
<button @click="scan" :disabled="scanning" class="btn-secondary">
{{ scanning ? 'Scan...' : 'Analyser médiathèque' }}
</button>
<span v-if="scanResult" class="text-xs" :class="scanResult.added > 0 ? 'text-green-400' : 'text-muted'">
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
</span>
</div>
</template>
</div>
<!-- Folder Picker -->
<FolderPicker
v-if="picker.open"
:initial-path="picker.currentPath"
@select="onPickerSelect"
@cancel="picker.open = false"
/>
<!-- Plex Wizard -->
<PlexWizard
v-if="plexOpen"
:media-path="form.mediaPath"
@close="plexOpen = false"
/>
<!-- Confirmation NFO -->
<Teleport to="body">
<div
v-if="nfoConfirmOpen"
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
@click.self="nfoConfirmOpen = false"
>
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<h3 class="text-sm font-semibold text-primary">Activer les NFO / Métadonnées ?</h3>
<p class="text-xs text-muted leading-relaxed">
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex ou Emby</span>,
les métadonnées sont déjà gérées directement par l'agent — activer les NFO est inutile
et peut créer des conflits.
</p>
<p class="text-xs text-muted leading-relaxed">
Activez cette option uniquement si vous utilisez <span class="text-primary">Kodi, Infuse</span>
ou un lecteur qui lit les fichiers NFO locaux.
</p>
</div>
<div class="flex gap-2 pt-1">
<button @click="confirmNfo" class="btn-primary text-xs">Oui, activer quand même</button>
<button @click="nfoConfirmOpen = false" class="btn-secondary text-xs">Annuler</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import FolderPicker from '@/components/FolderPicker.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import PlexWizard from '@/components/settings/PlexWizard.vue'
const { add: toast } = useToast()
const saving = ref(false)
const scanning = ref(false)
const isDocker = ref(false)
const loaded = ref(false)
const plexOpen = ref(false)
const nfoConfirmOpen = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
function onNfoToggle(val: boolean) {
if (val && !form.value.nfoSupport) {
nfoConfirmOpen.value = true
} else {
form.value.nfoSupport = val
}
}
function confirmNfo() {
form.value.nfoSupport = true
nfoConfirmOpen.value = false
}
const form = ref({
mediaPath : '',
completePath : '',
organizeMode : 'hardlink' as 'hardlink' | 'copy' | 'move',
nfoSupport : false,
autoImport : true,
deleteTorrentOnMove : false,
autoUnimportMissing : false,
})
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
open: false, field: 'completePath', currentPath: '/',
})
function isRootPath(p: string): boolean { return !p || p === '/' }
const hasUnconfiguredPaths = computed(() =>
isRootPath(form.value.completePath) || isRootPath(form.value.mediaPath)
)
function openPicker(field: 'completePath' | 'mediaPath') {
picker.value = { open: true, field, currentPath: form.value[field] || picker.value.currentPath || '/' }
}
function onPickerSelect(path: string) {
form.value[picker.value.field] = path
picker.value.open = false
}
async function save() {
saving.value = true
try {
const res = await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(form.value),
})
if (res.ok) toast('Configuration sauvegardée', 'success')
else toast('Erreur lors de la sauvegarde', 'error')
} finally {
saving.value = false
}
}
async function scan() {
scanning.value = true
scanResult.value = null
try {
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
if (res.ok) {
const data = await res.json()
scanResult.value = { found: data.found, added: data.added }
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
} else {
toast("Erreur lors de l'analyse", 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
scanning.value = false
}
}
onMounted(async () => {
const [settingsRes, systemRes, infoRes] = await Promise.all([
fetch('/api/settings', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
fetch('/api/system/info', { credentials: 'include' }),
])
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
loaded.value = true
})
</script>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div>
<div class="px-8 py-8 max-w-4xl">
<h1 class="text-lg font-semibold text-primary mb-6">{{ pageTitle }}</h1>
<RouterView />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const titles: Record<string, string> = {
'settings-download' : 'Clients de téléchargement',
'settings-media' : 'Gestion des médias',
'settings-catalogue' : 'Catalogue Fankai',
'settings-logs' : 'Journaux',
}
const pageTitle = computed(() => titles[route.name as string] ?? 'Paramètres')
</script>
+1
View File
@@ -0,0 +1 @@
3.14.3
+5 -2
View File
@@ -10,10 +10,13 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
build: {
outDir: 'dist/client'
},
server: {
port: 3000,
port: 3000, // ou autre port fixe
proxy: {
'/api': 'http://localhost:3001'
'/api': 'http://localhost:9898'
}
}
})