Compare commits

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

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

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

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

No files matched your search

+2 -1
View File
@@ -5,4 +5,5 @@ node_modules
config
.env
.env.*
!.env.example
!.env.example
.test-sandbox
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+5
View File
@@ -27,3 +27,8 @@ binaries
*.njsproj
*.sln
*.sw?
# Instance de test locale
.test-sandbox
scripts/seed-test-sandbox.mjs
docker-compose.test.yml
+118
View File
@@ -1,5 +1,123 @@
# Changelog
## [3.23.1](https://github.com/Masutayunikon/FanKarr/compare/v3.23.0...v3.23.1) (2026-09-12)
### Bug Fixes
* **scrap:** Correction fallback si scraper KO ([f64d2e1](https://github.com/Masutayunikon/FanKarr/commit/f64d2e19f7f9ab838cee6d33f5523fabb22b5cf7))
* **scrap:** Correction fallback si scraper KO ([6348cc4](https://github.com/Masutayunikon/FanKarr/commit/6348cc40cb42dd23e3b3e77aa1e96f813b9d2687))
## [3.23.0](https://github.com/Masutayunikon/FanKarr/compare/v3.22.0...v3.23.0) (2026-09-11)
### Features
* **import:** catalogue unifié, renommage de dossier série et nettoya……ge des orphelins corection dédoublonnage ([c6b26d8](https://github.com/Masutayunikon/FanKarr/commit/c6b26d88f0a9c5b1092e162f43ff052efdbffb4d))
* **import:** catalogue unifié, renommage de dossier série et nettoyage des orphelins corection dédoublonnage ([0ff7f21](https://github.com/Masutayunikon/FanKarr/commit/0ff7f21958ad33432123f76a2fa7830655329cbb))
### Bug Fixes
* **import:** repli sur l'API Fankai quand une série est absente du scraper et retrait de Kodi des NFO ([1ca7eac](https://github.com/Masutayunikon/FanKarr/commit/1ca7eac48c9337c39b1d40a43a90c993e90d11ea))
* **import:** repli sur l'API Fankai quand une série est absente du scraper et retrait de Kodi des NFO ([67794d9](https://github.com/Masutayunikon/FanKarr/commit/67794d92c8ab48a402bdcb5a2a855df39c86bba8))
## [3.22.0](https://github.com/Masutayunikon/FanKarr/compare/v3.21.0...v3.22.0) (2026-09-11)
### Features
* actions de masse sur les séries et fiabilisation des imports ([f4f2135](https://github.com/Masutayunikon/FanKarr/commit/f4f213508de8500aa5e32e8782f358a22e47ec36))
* actions de masse sur les séries et fiabilisation des imports et correction protection admin ([7f06dd6](https://github.com/Masutayunikon/FanKarr/commit/7f06dd654a72b4960195202ef5597411a54f8d75))
* **torrent:** ajout d'une option pour ignorer les erreurs de certificat ssl ([7479ddf](https://github.com/Masutayunikon/FanKarr/commit/7479ddf8eb63579727cbb0f497ead98c90145da5))
* **torrent:** ajout d'une option pour ignorer les erreurs de certificat SSL ([#138](https://github.com/Masutayunikon/FanKarr/issues/138)) ([0768964](https://github.com/Masutayunikon/FanKarr/commit/07689640413cf9ea57ea69e1b50273c80dffd640))
## [3.21.0](https://github.com/Masutayunikon/FanKarr/compare/v3.20.3...v3.21.0) (2026-06-28)
### Features
* **settings:** ajout de englishDirectory qui permet d'avoir Season XX au lieu de Saison X ([6f1e0dc](https://github.com/Masutayunikon/FanKarr/commit/6f1e0dc844e8f97b0966b223d558c09ab498ddc9))
## [3.20.3](https://github.com/Masutayunikon/FanKarr/compare/v3.20.2...v3.20.3) (2026-06-26)
### Bug Fixes
* **serie:** ajout du label de saisons avec les episodes ([63146fd](https://github.com/Masutayunikon/FanKarr/commit/63146fdc27d98acb70cf86c7890ea1236388d717))
## [3.20.2](https://github.com/Masutayunikon/FanKarr/compare/v3.20.1...v3.20.2) (2026-06-26)
### Bug Fixes
* **client:** add placeholder from config ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
* **client:** remove alldebrid ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
* **label:** add season of episodes ([98b317d](https://github.com/Masutayunikon/FanKarr/commit/98b317d493b7742c330d49ca816c0fecd983e82a))
* **scraper:** fallback on empty env for GITHUB_BASE ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
## [3.20.1](https://github.com/Masutayunikon/FanKarr/compare/v3.20.0...v3.20.1) (2026-06-08)
### Bug Fixes
* **organize:** import manuel ne traite que les fichiers 100% complets ([f311c8e](https://github.com/Masutayunikon/FanKarr/commit/f311c8e2f3b9f53b6d03a327cf1b5bfd69cc6424))
## [3.20.0](https://github.com/Masutayunikon/FanKarr/compare/v3.19.1...v3.20.0) (2026-05-29)
### Features
* **torrent:** système générique de suivi des tâches gérées ([a7250a5](https://github.com/Masutayunikon/FanKarr/commit/a7250a5f804189a7ddecef44193436cf6b324c31))
### Bug Fixes
* **organize:** fallback par torrent_name quand le hash n'est pas résolu ([f6f735a](https://github.com/Masutayunikon/FanKarr/commit/f6f735ad8838d0755472f739571684d75f9dc1b5))
* **synology:** filtrage par savePath et retour au GET pour add() ([d326e08](https://github.com/Masutayunikon/FanKarr/commit/d326e0883477a7dbf453ab28a3fc619f759a8604))
## [3.19.1](https://github.com/Masutayunikon/FanKarr/compare/v3.19.0...v3.19.1) (2026-05-28)
### Bug Fixes
* **ci:** rerun deployment ([099b088](https://github.com/Masutayunikon/FanKarr/commit/099b0889b069f0226b7cb2ec4fa3b1ca535a82a6))
## [3.19.0](https://github.com/Masutayunikon/FanKarr/compare/v3.18.3...v3.19.0) (2026-05-28)
### Features
* **synology:** files par fichier + getFiles() comme qbittorrent ([c6c52a1](https://github.com/Masutayunikon/FanKarr/commit/c6c52a1ae742dcb02ee86f3de04831ac1f041940))
### Bug Fixes
* **organize:** markOrganized manquant en mode copy pour les packs ([98ed3c7](https://github.com/Masutayunikon/FanKarr/commit/98ed3c7199915d2aafbb7be87b41fb725bb0c1ee))
* **synology:** resolution hash via cache URI + magnet + detail.hash ([ee1c7d5](https://github.com/Masutayunikon/FanKarr/commit/ee1c7d579c3795701158e39159511be3cc1789d7))
* **torrent:** correction suppression torrent après déplacement ([ed556ac](https://github.com/Masutayunikon/FanKarr/commit/ed556acd1d2990a6375d4a60f745048a79854249))
## [3.18.3](https://github.com/Masutayunikon/FanKarr/compare/v3.18.2...v3.18.3) (2026-05-27)
### Bug Fixes
* **synology:** double prefix dbid_ et fallback hash ([2587768](https://github.com/Masutayunikon/FanKarr/commit/25877685eaf02b3b1c02ff06db618a37aebf0e6b))
## [3.18.2](https://github.com/Masutayunikon/FanKarr/compare/v3.18.1...v3.18.2) (2026-05-27)
### Bug Fixes
* **client:** synology use default savepath when 403 ([63f6c12](https://github.com/Masutayunikon/FanKarr/commit/63f6c1276bd48a14e5806ff997b6be63c458d94c))
## [3.18.1](https://github.com/Masutayunikon/FanKarr/compare/v3.18.0...v3.18.1) (2026-05-27)
### Bug Fixes
* **client:** suppresion de alldebrid ([8b4b198](https://github.com/Masutayunikon/FanKarr/commit/8b4b198697ba3296444759c522bb0139be69e364))
## [3.18.0](https://github.com/Masutayunikon/FanKarr/compare/v3.17.1...v3.18.0) (2026-05-25)
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Serif+Display&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fankarr</title>
<title>Fankarr</title>
</head>
<body>
<div id="app"></div>
+5 -2
View File
@@ -7,7 +7,9 @@
"dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
"dev:server": "tsx watch server/index.ts",
"dev:client": "vite",
"build": "vite build && tsc -p tsconfig.server.json",
"build": "vue-tsc -p tsconfig.app.json --noEmit && vite build && tsc -p tsconfig.server.json",
"typecheck": "vue-tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.server.json --noEmit",
"test": "vitest run",
"build:bun:linux": "bun build server/index.ts --compile --target bun-linux-x64 --outfile binaries/fankarr-linux-x64 && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
"build:bun:windows": "bun build server/index.ts --compile --target bun-windows-x64 --outfile binaries/fankarr-windows-x64.exe && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
"build:bun:macos": "bun build server/index.ts --compile --target bun-darwin-arm64 --outfile binaries/fankarr-macos && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
@@ -44,6 +46,7 @@
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vitest": "^5.0.0",
"vue-tsc": "^3.1.5"
}
}
}
+228
View File
@@ -87,6 +87,9 @@ importers:
vite:
specifier: ^7.3.1
version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
vitest:
specifier: ^5.0.0
version: 5.0.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
vue-tsc:
specifier: ^3.1.5
version: 3.2.5(typescript@5.9.3)
@@ -283,6 +286,9 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/sourcemap-codec@1.6.0':
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
@@ -323,66 +329,79 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -452,24 +471,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
@@ -510,6 +533,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@@ -521,6 +547,9 @@ packages:
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -564,6 +593,20 @@ packages:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
vue: ^3.2.25
'@vitest/mocker@5.0.0':
resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/spy@5.0.0':
resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==}
'@volar/language-core@2.4.28':
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
@@ -676,6 +719,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-kit@2.2.0:
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
engines: {node: '>=20.19.0'}
@@ -710,6 +757,10 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -828,6 +879,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@2.3.2:
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -847,10 +901,17 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
@@ -1017,24 +1078,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -1089,6 +1154,9 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magic-string@1.3.1:
resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1147,6 +1215,10 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
obug@2.2.1:
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
engines: {node: '>=12.20.0'}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -1180,6 +1252,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
@@ -1289,6 +1365,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1297,10 +1376,16 @@ packages:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -1328,10 +1413,22 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tinybench@6.1.4:
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
engines: {node: '>=20.0.0'}
tinyexec@1.3.0:
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
engines: {node: '>=18'}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@@ -1423,6 +1520,47 @@ packages:
yaml:
optional: true
vitest@5.0.0:
resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.0
'@vitest/browser-preview': 5.0.0
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.0
'@vitest/coverage-v8': 5.0.0
'@vitest/ui': 5.0.0
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
@@ -1462,6 +1600,11 @@ packages:
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -1601,6 +1744,8 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/sourcemap-codec@1.6.0': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
@@ -1760,6 +1905,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 24.12.0
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/connect@3.4.38':
dependencies:
'@types/node': 24.12.0
@@ -1772,6 +1922,8 @@ snapshots:
dependencies:
'@types/node': 24.12.0
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
'@types/express-serve-static-core@5.1.1':
@@ -1821,6 +1973,17 @@ snapshots:
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
vue: 3.5.29(typescript@5.9.3)
'@vitest/mocker@5.0.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.0
estree-walker: 3.0.3
magic-string: 1.3.1
optionalDependencies:
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/spy@5.0.0': {}
'@volar/language-core@2.4.28':
dependencies:
'@volar/source-map': 2.4.28
@@ -1971,6 +2134,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
assertion-error@2.0.1: {}
ast-kit@2.2.0:
dependencies:
'@babel/parser': 7.29.0
@@ -2013,6 +2178,8 @@ snapshots:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -2110,6 +2277,8 @@ snapshots:
es-errors@1.3.0: {}
es-module-lexer@2.3.2: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -2149,8 +2318,14 @@ snapshots:
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
etag@1.8.1: {}
expect-type@1.4.0: {}
express@5.2.1:
dependencies:
accepts: 2.0.0
@@ -2190,6 +2365,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
fetch-blob@3.2.0:
dependencies:
node-domexception: 1.0.0
@@ -2390,6 +2569,10 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magic-string@1.3.1:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
math-intrinsics@1.1.0: {}
media-typer@1.1.0: {}
@@ -2431,6 +2614,8 @@ snapshots:
object-inspect@1.13.4: {}
obug@2.2.1: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -2455,6 +2640,8 @@ snapshots:
picomatch@4.0.3: {}
picomatch@4.0.7: {}
pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.9
@@ -2618,12 +2805,18 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
stackback@0.0.2: {}
statuses@2.0.2: {}
std-env@4.2.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -2650,11 +2843,20 @@ snapshots:
tapable@2.3.0: {}
tinybench@6.1.4: {}
tinyexec@1.3.0: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
toidentifier@1.0.1: {}
tree-kill@1.2.2: {}
@@ -2713,6 +2915,27 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
vitest@5.0.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
magic-string: 1.3.1
obug: 2.2.1
picomatch: 4.0.7
std-env: 4.2.0
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.12.0
transitivePeerDependencies:
- msw
vscode-uri@3.1.0: {}
vue-router@5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)):
@@ -2759,6 +2982,11 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 231 KiB

+39 -35
View File
@@ -13,12 +13,13 @@ import synologyDsDriver from './torrent-clients/synology-ds.js'
import utorrentDriver from './torrent-clients/utorrent.js'
import rtorrentDriver from './torrent-clients/rtorrent.js'
import realDebridDriver from './torrent-clients/real-debrid.js'
import allDebridDriver from './torrent-clients/alldebrid.js'
import delugeDriver from './torrent-clients/deluge.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from './organize.js'
import { logger } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js'
import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js'
import { readAvailable, readInfohashMap } from './lib/github-cache.js'
import { loadCatalog } from './lib/serie-helpers.js'
import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js'
import { pushNotif } from './lib/notifs.js'
import { readRequests, completeRequest } from './requests.js'
import { checkNfoUpdates } from './lib/nfo.js'
@@ -48,7 +49,6 @@ registerDriver(synologyDsDriver)
registerDriver(utorrentDriver)
registerDriver(rtorrentDriver)
registerDriver(realDebridDriver)
registerDriver(allDebridDriver)
registerDriver(delugeDriver)
const app = express()
@@ -93,6 +93,9 @@ const ADMIN_PREFIXES = [
'/users', '/jellyfin', '/settings', '/torrent-clients',
'/downloads', '/download', '/organize', '/import',
'/system', '/nfo-updates', '/plex', '/rss-sync',
'/torrent', '/manual-import', '/organized', '/organized-summary',
'/rename-episode', '/rename-all', '/purge-nfo',
'/logs', '/browse', '/browse-files', '/update', '/scan', '/debug',
]
app.use('/api', (req, res, next) => {
if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) {
@@ -137,48 +140,46 @@ server.listen(PORT, async () => {
logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`)
}
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
const { mediaPath } = readSettings()
// Migration organized.json : si ancien format (valeurs string) → reset + rescan
try {
if (fs.existsSync(ORGANIZED_PATH)) {
const raw = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
const isOldFormat = Object.values(raw).some((entries: any) =>
Object.values(entries).some(v => typeof v === 'string')
)
if (isOldFormat) {
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
fs.writeFileSync(ORGANIZED_PATH, '{}', 'utf-8')
}
const raw = readOrganized()
const isOldFormat = Object.values(raw).some((entries: any) =>
Object.values(entries).some(v => typeof v === 'string')
)
if (isOldFormat) {
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
writeOrganized({})
}
} catch {}
} catch (err) {
logger.error('api', `Migration organized.json ignorée : ${err instanceof Error ? err.message : err}`)
}
// Migration organized.json : backfill dest_dir manquant sur les anciennes entrées
try {
if (fs.existsSync(ORGANIZED_PATH)) {
const raw: Record<string, Record<string, any>> = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
let patched = 0
for (const episodes of Object.values(raw)) {
for (const entry of Object.values(episodes)) {
if (!entry.dest_dir && entry.dest_path) {
entry.dest_dir = path.dirname(entry.dest_path)
patched++
}
const raw = readOrganized()
let patched = 0
for (const episodes of Object.values(raw)) {
for (const entry of Object.values(episodes)) {
if (!entry.dest_dir && entry.dest_path) {
entry.dest_dir = path.dirname(entry.dest_path)
patched++
}
}
if (patched > 0) {
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(raw, null, 2))
logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`)
}
}
if (patched > 0) {
writeOrganized(raw)
logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`)
}
} catch (err) {
logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`)
}
loadEnrichedSeriesData()
loadCatalog()
.then(async seriesData => {
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
dedupeOrganizedEpisodes(ORGANIZED_PATH)
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
})
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
@@ -187,7 +188,7 @@ server.listen(PORT, async () => {
try {
const { category } = readSettings()
const infohashMap = await readInfohashMap()
const seriesData = await loadEnrichedSeriesData()
const seriesData = await loadCatalog()
await autoOrganizeAll(
() => dispatchList(category ?? 'fankai', infohashMap),
seriesData,
@@ -212,11 +213,11 @@ server.listen(PORT, async () => {
}
const { organizeMode, deleteTorrentOnMove } = readSettings()
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
try {
await dispatchRemove(result.hash, false)
const removeResult = await dispatchRemove(result.hash, false)
if (removeResult.ok) {
logger.info('api', `Torrent "${result.name}" supprimé après move`)
} catch (err) {
logger.warn('api', `Impossible de supprimer le torrent "${result.name}" : ${err instanceof Error ? err.message : err}`)
} else {
logger.warn('api', `Torrent "${result.name}" — suppression échouée après move : ${removeResult.error ?? 'aucun client n\'a pu supprimer'}`)
}
}
}
@@ -242,11 +243,14 @@ server.listen(PORT, async () => {
// Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches.
setInterval(async () => {
try {
const seriesData = await loadEnrichedSeriesData()
const seriesData = await loadCatalog()
const organizedPath = path.join(DATA_DIR, 'organized.json')
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
if (updated > 0)
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`)
const { removed } = dedupeOrganizedEpisodes(organizedPath)
if (removed > 0)
logger.info('api', `Dédoublonnage auto — ${removed} entrée(s) redondante(s) supprimée(s)`)
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0)
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
+24 -15
View File
@@ -2,8 +2,9 @@ import { DATA_DIR } from '../config.js'
import path from 'path'
import fs from 'fs'
const GITHUB_BASE = process.env.GITHUB_BASE
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
const GITHUB_BASE =
process.env.GITHUB_BASE?.trim() ||
'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
@@ -25,34 +26,42 @@ 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}`)
/**
* @param force Ignore le cache mémoire ET le cache CDN de raw.githubusercontent
* (~5 min).
*/
export async function githubGet(urlPath: string, force = false): Promise<any> {
if (!force) {
const cached = cacheGet(urlPath)
if (cached) return cached
}
const url = force ? `${GITHUB_BASE}/${urlPath}?_=${Date.now()}` : `${GITHUB_BASE}/${urlPath}`
const init = force ? { cache: 'no-store' as const, headers: { 'Cache-Control': 'no-cache' } } : undefined
const res = await fetch(url, init)
if (!res.ok) throw new Error(`GitHub ${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[] }
export async function readAvailable(force = false): Promise<number[]> {
try { return await githubGet('available.json', force) as number[] }
catch { return [] }
}
export async function readInfohashMap(): Promise<Record<string, string>> {
try { return await githubGet('infohash_map.json') as Record<string, string> }
export async function readInfohashMap(force = false): Promise<Record<string, string>> {
try { return await githubGet('infohash_map.json', force) as Record<string, string> }
catch { return {} }
}
export async function readSerieData(serieId: number): Promise<any | null> {
try { return await githubGet(`series/${serieId}.json`) }
export async function readSerieData(serieId: number, force = false): Promise<any | null> {
try { return await githubGet(`series/${serieId}.json`, force) }
catch { return null }
}
export async function loadEnrichedSeriesData(): Promise<any[]> {
const ids = await readAvailable()
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
export async function loadEnrichedSeriesData(force = false): Promise<any[]> {
const ids = await readAvailable(force)
const allSd = await Promise.all(ids.map(id => readSerieData(id, force)))
return allSd.filter(Boolean)
}
+59
View File
@@ -0,0 +1,59 @@
import fs from 'fs'
import path from 'path'
import { threadId } from 'worker_threads'
import { DATA_DIR } from '../config.js'
export type Organized = Record<string, Record<string, any>>
export const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// Un fichier existant mais illisible lève une erreur : ne jamais repartir de {} avant d'écrire
export function readOrganized(file = ORGANIZED_PATH): Organized {
if (!fs.existsSync(file)) return {}
const raw = fs.readFileSync(file, 'utf-8')
let data: unknown
try { data = JSON.parse(raw) }
catch (err) { throw new Error(`organized.json illisible (${raw.length} octets) : ${err instanceof Error ? err.message : err}`) }
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('organized.json : contenu inattendu')
return data as Organized
}
export function writeOrganized(data: Organized, file = ORGANIZED_PATH): void {
for (const hash of Object.keys(data))
if (!data[hash] || Object.keys(data[hash]).length === 0) delete data[hash]
const tmp = `${file}.${process.pid}-${threadId}.tmp`
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8')
renameWithRetry(tmp, file)
}
// Répercute des déplacements de fichiers sur toutes les entrées qui les suivent, tous hashes confondus
export function retargetEntries(data: Organized, moves: Map<string, string>): number {
let updated = 0
for (const eps of Object.values(data)) {
for (const entry of Object.values(eps)) {
const to = entry?.dest_path ? moves.get(entry.dest_path) : undefined
if (!to) continue
entry.dest_path = to
entry.dest_filename = path.basename(to)
entry.dest_dir = path.dirname(to)
updated++
}
}
return updated
}
// Lecture-modification-écriture synchrone ; fn renvoie false pour ne rien écrire
export function updateOrganized(fn: (data: Organized) => boolean | void, file = ORGANIZED_PATH): void {
const data = readOrganized(file)
if (fn(data) !== false) writeOrganized(data, file)
}
function renameWithRetry(from: string, to: string, attempts = 5): void {
for (let i = 1; ; i++) {
try { fs.renameSync(from, to); return }
catch (err: any) {
if (i >= attempts || !['EPERM', 'EACCES', 'EBUSY'].includes(err?.code)) throw err
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * i)
}
}
}
+48 -6
View File
@@ -11,7 +11,8 @@ import path from 'path'
import fs from 'fs'
import { DATA_DIR } from '../config.js'
import { logger } from '../logger.js'
import { loadEnrichedSeriesData, readInfohashMap } from './github-cache.js'
import { readInfohashMap } from './github-cache.js'
import { loadCatalogStatus } from './serie-helpers.js'
import { dispatchDownload, dispatchList } from '../torrent-clients/index.js'
import { readSettings } from '../settings.js'
@@ -53,10 +54,48 @@ export function setSync(serieId: number, serieName: string, enabled: boolean): S
return map
}
// Une série déjà surveillée garde sa date d'activation
export function setSyncBulk(series: { id: number; name: string }[], enabled: boolean): { changed: number; map: SyncedMap } {
const map = loadSynced()
let changed = 0
for (const { id, name } of series) {
if (enabled && !map[id]) { map[id] = { serieId: id, serieName: name, addedAt: new Date().toISOString() }; changed++ }
if (!enabled && map[id]) { delete map[id]; changed++ }
}
if (changed > 0) saveSynced(map)
return { changed, map }
}
export function isSynced(serieId: number): boolean {
return !!loadSynced()[serieId]
}
const normalizeName = (name: string) =>
name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
// Série recréée sous un nouvel ID avec le même nom : la surveillance suit.
// Renvoie les surveillances dont la série n'existe plus dans le catalogue.
export function reconcileSynced(catalog: any[]): SyncedSerie[] {
const map = loadSynced()
const ids = new Set(catalog.map(sd => sd.id))
const orphans: SyncedSerie[] = []
let changed = false
for (const entry of Object.values(map)) {
if (ids.has(entry.serieId)) continue
const matches = catalog.filter(sd => normalizeName(sd.title ?? '') === normalizeName(entry.serieName))
if (matches.length === 1 && !map[matches[0].id]) {
delete map[entry.serieId]
map[matches[0].id] = { ...entry, serieId: matches[0].id, serieName: matches[0].title }
logger.info('rss-sync', `Surveillance de "${entry.serieName}" déplacée de la série ${entry.serieId} vers ${matches[0].id}`)
changed = true
} else {
orphans.push(entry)
}
}
if (changed) saveSynced(map)
return orphans
}
// ── Helpers ───────────────────────────────────────────────────
function readOrganized(): Record<string, Record<string, any>> {
@@ -70,10 +109,11 @@ function readOrganized(): Record<string, Record<string, any>> {
function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string, any>>): Set<number> {
const ids = new Set<number>()
const manual = organized['manual'] ?? {}
// Sous n'importe quel hash : évite de retélécharger un épisode importé via un torrent disparu du scraper
const tracked = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
for (const season of sd.seasons ?? [])
for (const ep of season.episodes ?? [])
if (manual[String(ep.id)]) ids.add(ep.id)
if (tracked.has(String(ep.id))) ids.add(ep.id)
for (const t of sd.torrents ?? []) {
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
@@ -107,14 +147,16 @@ function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string
// ── Logique de sync ───────────────────────────────────────────
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
if (Object.keys(loadSynced()).length === 0) return { sent: 0, skipped: 0, errors: 0 }
const { series: seriesData, complete } = await loadCatalogStatus()
if (complete) reconcileSynced(seriesData)
const synced = loadSynced()
const ids = Object.keys(synced).map(Number)
if (ids.length === 0) return { sent: 0, skipped: 0, errors: 0 }
logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`)
const organized = readOrganized()
const seriesData = await loadEnrichedSeriesData()
const activeHashes = new Set<string>()
try {
@@ -134,7 +176,7 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const syncedEntry = synced[serieId]
const sd = seriesData.find((s: any) => s.id === serieId)
if (!sd) {
logger.warn('rss-sync', `Série ${serieId} introuvable dans le cache`)
logger.warn('rss-sync', `Série ${serieId} ("${syncedEntry.serieName}") introuvable dans le catalogue — surveillance à retirer depuis Paramètres › Catalogue`)
continue
}
+79 -9
View File
@@ -1,4 +1,6 @@
import path from 'path'
import { logger } from '../logger.js'
import { githubGet, loadEnrichedSeriesData, readAvailable } from './github-cache.js'
const FANKAI_API = 'https://metadata.fankai.fr'
@@ -8,6 +10,80 @@ export async function fankaiGet(endpoint: string): Promise<any> {
return res.json()
}
export async function fetchSerieFromApi(serieId: number): Promise<any> {
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${serieId}`), fankaiGet(`/series/${serieId}/seasons`)])
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
return {
...serie,
seasons: await Promise.all(seasons.map(async (season: any) => {
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
})),
}
}
export function serieHasEpisodes(sd: any): boolean {
return (sd?.seasons ?? []).some((s: any) => (s.episodes ?? []).length > 0)
}
export function serieFolderName(title: string): string {
return title.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
}
// Même règle que seasonFolder() du worker
export function seasonFolderName(seasonNumber: number, englishDirectory: boolean): string {
return seasonNumber === 0 ? 'Specials' : (englishDirectory ? `Season ${String(seasonNumber).padStart(2, '0')}` : `Saison ${seasonNumber}`)
}
const errorMessage = (err: unknown) => err instanceof Error ? err.message : String(err)
// Données scraper avec le titre à jour de l'API, ou repli complet sur l'API quand le scraper n'a pas (encore) la série
export async function resolveSerieData(serieId: number, action?: string): Promise<any | null> {
const [scraper, api] = await Promise.allSettled([githubGet(`series/${serieId}.json`), fankaiGet(`/series/${serieId}`)])
if (scraper.status === 'fulfilled' && serieHasEpisodes(scraper.value))
return api.status === 'fulfilled' && api.value?.title ? { ...scraper.value, title: api.value.title } : scraper.value
const scraperError = scraper.status === 'rejected' ? errorMessage(scraper.reason) : 'aucun épisode'
try {
const sd = await fetchSerieFromApi(serieId)
if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises de l'API Fankai`)
return sd
} catch (err) {
logger.error('api', `${action ?? 'Lecture série'} : série ${serieId} introuvable — scraper (${scraperError}), API Fankai (${errorMessage(err)})`)
return null
}
}
let lastCatalogWarn = 0
// Catalogue du scraper complété par l'API Fankai : titres à jour et séries pas encore scrapées.
// complete = false si une source a échoué : ne rien supprimer sur la base d'une absence dans ce cas.
export async function loadCatalogStatus(force = false): Promise<{ series: any[]; complete: boolean }> {
const [seriesData, availableIds] = await Promise.all([loadEnrichedSeriesData(force), readAvailable(force)])
const scraperComplete = availableIds.length > 0 && seriesData.length === availableIds.length
let apiSeries: any[]
try {
const data = await fankaiGet('/series')
apiSeries = Array.isArray(data) ? data : (data.series ?? [])
} catch (err) {
if (Date.now() - lastCatalogWarn > 60 * 60_000) {
lastCatalogWarn = Date.now()
logger.warn('api', `Catalogue : API Fankai injoignable, titres du scraper conservés (${errorMessage(err)})`)
}
return { series: seriesData, complete: false }
}
const titles = new Map<number, string>(apiSeries.filter(s => s.title).map(s => [s.id, s.title]))
const catalog = seriesData.map(sd => titles.has(sd.id) ? { ...sd, title: titles.get(sd.id) } : sd)
// Scraper vide = injoignable : ne pas interroger l'API série par série
if (seriesData.length === 0) return { series: catalog, complete: false }
const known = new Set(seriesData.map(sd => sd.id))
const missing = await Promise.all(apiSeries.filter(s => !known.has(s.id)).map(s => fetchSerieFromApi(s.id).catch(() => null)))
return { series: [...catalog, ...missing.filter(Boolean)], complete: scraperComplete && missing.every(Boolean) }
}
export async function loadCatalog(force = false): Promise<any[]> {
return (await loadCatalogStatus(force)).series
}
export function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
if (!url) return null
const stripped = url.replace(/^https?:\/\//, '')
@@ -170,15 +246,9 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
}
}
const manualOrg = organized['manual'] ?? {}
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (manualOrg[String(ep.id)]) {
const key = epIdToKey.get(ep.id)
if (key) organizedEpKeys.add(key)
}
}
}
const trackedIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
for (const [id, key] of epIdToKey)
if (trackedIds.has(String(id))) organizedEpKeys.add(key)
if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none'
if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete'
+14 -6
View File
@@ -22,18 +22,26 @@ export interface LogEntry {
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
const MAX_LINES = 2000 // rotation auto au-delà
const ROTATE_SLACK = 200 // marge avant rotation, pour ne pas réécrire le fichier à chaque ligne
const IS_PROD = process.env.NODE_ENV === 'production'
// ── Écriture ──────────────────────────────────────────────────
let lineCount: number | null = null
function countLines(): number {
if (!fs.existsSync(LOGS_PATH)) return 0
return fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).length
}
function writeLine(entry: LogEntry) {
try {
lineCount ??= countLines()
fs.appendFileSync(LOGS_PATH, JSON.stringify(entry) + '\n', 'utf-8')
// Rotation si trop grand
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
const lines = content.split('\n').filter(Boolean)
if (lines.length > MAX_LINES) {
fs.writeFileSync(LOGS_PATH, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8')
if (++lineCount > MAX_LINES + ROTATE_SLACK) {
const lines = fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).slice(-MAX_LINES)
fs.writeFileSync(LOGS_PATH, lines.join('\n') + '\n', 'utf-8')
lineCount = lines.length
}
} catch {}
}
@@ -98,7 +106,7 @@ export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
}
export function clearLogs(): void {
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8') } catch {}
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8'); lineCount = 0 } catch {}
}
export function logsFileSize(): number {
+93 -25
View File
@@ -10,8 +10,7 @@ import path from 'path'
import { parentPort } from 'worker_threads'
import { DATA_DIR } from './config.js'
import { getGitlabTitle } from './gitlab-map.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
import { readOrganized } from './lib/organized-store.js'
// ─── Utils log ────────────────────────────────────────────────
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
@@ -35,34 +34,28 @@ export interface OrgEntry {
type Organized = Record<string, Record<string, OrgEntry>>
// ─── Settings ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean, englishDirectory: boolean } {
try {
const p = path.join(DATA_DIR, 'settings.json')
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, englishDirectory: false }
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false , englishDirectory: false } }
}
// ─── Organized log ────────────────────────────────────────────
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, episodeId: number, entry: OrgEntry) {
const data = loadOrganized()
if (!data[hash]) data[hash] = {}
data[hash][String(episodeId)] = entry
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
parentPort?.postMessage({ type: 'mark', hash, episodeId, entry })
}
function isOrganized(hash: string, episodeId: number): boolean {
return !!loadOrganized()[hash]?.[String(episodeId)]
return !!readOrganized()[hash]?.[String(episodeId)]
}
// ─── Lookup torrent dans les seriesData ───────────────────────
/**
* Recherche par infohash (méthode principale).
*/
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
const h = hash.toLowerCase()
for (const sd of seriesData) {
@@ -101,6 +94,53 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
return null
}
/**
* Fallback quand le hash n'est pas résolu (ex: dbid_X Synology).
* Cherche par torrent_name (nom réel du dossier torrent) ou title dans le catalogue.
* Retourne aussi le vrai infohash BT pour que l'import soit correctement indexé.
*/
function findTorrentByName(
name : string,
seriesData: any[],
): { torrent: any; serieData: any; infohash: string } | null {
const n = name.toLowerCase().trim()
if (!n) return null
for (const sd of seriesData) {
// Pack intégral (torrents au niveau série)
for (const t of sd.torrents ?? []) {
if (!t.infohash) continue
if (
t.torrent_name?.toLowerCase().trim() === n ||
t.title?.toLowerCase().trim() === n
) return { torrent: t, serieData: sd, infohash: t.infohash.toLowerCase() }
}
for (const season of sd.seasons ?? []) {
// Pack saison
for (const t of season.torrents ?? []) {
if (!t.infohash) continue
if (
t.torrent_name?.toLowerCase().trim() === n ||
t.title?.toLowerCase().trim() === n
) return { torrent: { ...t, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
}
// Torrent épisode unique
for (const ep of season.episodes ?? []) {
for (const t of ep.torrents ?? []) {
if (!t.infohash) continue
if (
t.torrent_name?.toLowerCase().trim() === n ||
t.title?.toLowerCase().trim() === n
) return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
}
}
}
}
return null
}
function buildFileMap(
serieData : any,
hash : string,
@@ -251,7 +291,8 @@ function swapExtension(filename: string, ext: string): string {
}
function seasonFolder(n: number): string {
return n === 0 ? 'Specials' : `Saison ${n}`
const { englishDirectory } = readSettings()
return n === 0 ? 'Specials' : (englishDirectory ? `Season ${String(n).padStart(2, '0')}` : `Saison ${n}`)
}
/**
@@ -458,7 +499,12 @@ async function organizeTorrent(
try {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'copy') {
await fsp.copyFile(src, dest)
await fsp.copyFile(src, dest)
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
})
} else if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
markOrganized(hash, episode_id, {
@@ -493,13 +539,35 @@ parentPort?.on('message', async (msg: any) => {
const torrents: any[] = msg.torrents
const seriesData: any[] = msg.seriesData ?? []
const organized = loadOrganized()
const { nfoSupport } = readSettings()
let organized: Organized
try { organized = readOrganized() as Organized }
catch (err) {
error(`Import annulé : ${err instanceof Error ? err.message : err}`)
parentPort?.postMessage({ type: 'done' })
return
}
for (const t of torrents) {
if (t.state !== 'seeding') continue
const found = findTorrentByHash(t.hash, seriesData)
// Résolution principale : par infohash
let found = findTorrentByHash(t.hash, seriesData)
let effectiveHash = t.hash
// Fallback : si le hash n'est pas résolu (ex: dbid_X Synology), on cherche
// par torrent_name — le nom du dossier racine tel que rapporté par le client,
// qui correspond au champ torrent_name dans les données série.
if (!found && t.name) {
const byName = findTorrentByName(t.name, seriesData)
if (byName) {
found = byName
effectiveHash = byName.infohash
debug(`Hash résolu par torrent_name : "${t.name}" → ${effectiveHash.slice(0, 8)}…`)
}
}
if (!found) {
debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
continue
@@ -507,8 +575,8 @@ parentPort?.on('message', async (msg: any) => {
const { torrent, serieData } = found
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
const orgHash = organized[t.hash] ?? {}
const fileMap = buildFileMap(serieData, effectiveHash, nfoSupport, seasonFilter)
const orgHash = organized[effectiveHash] ?? {}
const allDone = fileMap.size > 0
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
: Object.keys(orgHash).length > 0
@@ -541,8 +609,8 @@ parentPort?.on('message', async (msg: any) => {
}
}
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, serieId: serieData.id ?? null, ...result })
const result = await organizeTorrent(effectiveHash, t.name, t.save_path, seriesData, completedFileNames)
parentPort?.postMessage({ type: 'result', hash: effectiveHash, name: t.name, serieId: serieData.id ?? null, ...result })
} catch (err) {
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
}
+188 -96
View File
@@ -8,10 +8,8 @@ import fs from 'fs'
import path from 'path'
import { Worker } from 'worker_threads'
import { logger } from './logger.js'
import { DATA_DIR } from './config.js'
import { readSettings } from './settings.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from './lib/organized-store.js'
export let workerRunning = false
const _prevStates = new Map<string, string>()
@@ -30,18 +28,54 @@ export interface OrganizeResult {
errors : { file: string; error: string }[]
}
interface OrgEntry {
at : string
season : number
episode : number
episode_id : number
src_filename : string
dest_filename: string
dest_path : string
dest_dir? : string // absent dans les anciennes entrées — backfillé au démarrage
type WorkerResult = { hash: string; name: string; serieId?: number | null; total: number; done: number; skipped: number; errors: { file: string; error: string }[] }
// ── File d'attente des workers ────────────────────────────────
let _queue: Promise<unknown> = Promise.resolve()
let _queued = 0
const _pendingManual = new Map<string, Promise<OrganizeResult>>()
function enqueue<T>(job: () => Promise<T>): Promise<T> {
_queued++
const run = _queue.then(job).finally(() => { _queued-- })
_queue = run.catch(() => {})
return run
}
type Organized = Record<string, Record<string, OrgEntry>>
function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResult) => void): Promise<void> {
return new Promise((resolve, reject) => {
workerRunning = true
const worker = new Worker(resolveWorkerPath())
let settled = false
const finish = (err?: Error) => {
if (settled) return
settled = true
workerRunning = false
worker.terminate()
err ? reject(err) : resolve()
}
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'mark') {
try {
updateOrganized(data => { (data[msg.hash] ??= {})[String(msg.episodeId)] = msg.entry })
} catch (err) {
logger.error('organize', `Enregistrement de l'import impossible (ep ${msg.episodeId}) : ${err instanceof Error ? err.message : err}`)
}
} else if (msg.type === 'result') {
onResult(msg)
} else if (msg.type === 'done') {
finish()
}
})
worker.on('error', err => finish(err))
worker.on('exit', code => finish(code > 1 ? new Error(`Worker exit inattendu : code ${code}`) : undefined))
worker.postMessage({ type: 'run', torrents, seriesData })
})
}
// ── Scan initial ──────────────────────────────────────────────
@@ -65,10 +99,8 @@ export async function scanMediaPath(
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
// Map : nom_sans_extension → { hash, srcFilename, episodeId, season, episode }
// On indexe par nom sans extension pour matcher quelle que soit l'extension sur le disque
const filenameIndex = new Map<string, {
hash : string
hashes : string[]
srcFilename : string
episodeId : number
season : number
@@ -84,8 +116,13 @@ export async function scanMediaPath(
const idx = (filename: string, hash: string, destFilename?: string) => {
const base = filename.replace(/\.[^.]+$/, '')
if (!base) return
const existing = filenameIndex.get(base)
if (existing && existing.episodeId === ep.id) {
if (!existing.hashes.includes(hash)) existing.hashes.push(hash)
return
}
filenameIndex.set(base, {
hash,
hashes : [hash],
srcFilename : filename,
episodeId : ep.id,
season : season.season_number,
@@ -123,16 +160,27 @@ export async function scanMediaPath(
}
}
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch {}
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
logger.error('organize', `Scan annulé : ${err instanceof Error ? err.message : err}`)
return result
}
// Track les paires hash:episodeId présentes
const presentFiles = new Set<string>()
let noMatch = 0
const trackedByPath = new Map<string, string[]>()
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes)) {
if (!entry?.dest_path) continue
const keys = trackedByPath.get(entry.dest_path) ?? []
keys.push(`${hash}:${episodeId}`)
trackedByPath.set(entry.dest_path, keys)
}
}
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
@@ -143,6 +191,14 @@ export async function scanMediaPath(
walk(full)
} else if (entry.isFile() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) {
result.found++
// Fichier déjà suivi (sous n'importe quel hash) : ne pas créer d'entrée en double
const tracked = trackedByPath.get(full)
if (tracked) {
for (const key of tracked) presentFiles.add(key)
continue
}
const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '')
let match = filenameIndex.get(nameWithoutExt)
@@ -167,7 +223,8 @@ export async function scanMediaPath(
continue
}
const { hash, srcFilename, episodeId, season, episode } = match
const { hashes, srcFilename, episodeId, season, episode } = match
const hash = hashes.find(h => organized[h]?.[String(episodeId)]) ?? hashes[0]
presentFiles.add(`${hash}:${episodeId}`)
if (organized[hash]?.[String(episodeId)]) continue
@@ -190,11 +247,17 @@ export async function scanMediaPath(
walk(mediaPath)
const mediaEmpty = result.found === 0 && Object.keys(organized).length > 0
if (mediaEmpty) logger.warn('organize', `Scan : aucun fichier dans ${mediaPath} — suppression des entrées ignorée`)
// Supprimer uniquement les entrées connues dans l'index et absentes du disque
const allEpisodeIds = new Set([...filenameIndex.values()].map(v => `${v.hash}:${v.episodeId}`))
const allEpisodeIds = new Set(
[...filenameIndex.values()].flatMap(v => v.hashes.map(h => `${h}:${v.episodeId}`))
)
let removed = 0
for (const [hash, episodes] of Object.entries(organized)) {
if (mediaEmpty) break
for (const episodeId of Object.keys(episodes)) {
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
delete organized[hash][episodeId]
@@ -207,7 +270,7 @@ export async function scanMediaPath(
// Désimport automatique des fichiers manquants sur le disque
const { autoUnimportMissing } = readSettings() as any
let autoRemoved = 0
if (autoUnimportMissing) {
if (autoUnimportMissing && !mediaEmpty) {
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
const destPath = entry?.dest_path
@@ -222,7 +285,7 @@ export async function scanMediaPath(
}
if (result.added > 0 || removed > 0 || autoRemoved > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
writeOrganized(organized, organizedPath)
}
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
@@ -243,8 +306,8 @@ export async function autoOrganizeAll(
return
}
if (workerRunning) {
logger.debug('organize', 'Worker déjà en cours — skip')
if (_queued > 0) {
logger.debug('organize', 'Import déjà en cours — skip')
return
}
@@ -268,11 +331,12 @@ export async function autoOrganizeAll(
for (const t of torrents) _prevStates.set(t.hash, t.state)
let organized: Organized = {}
try {
if (fs.existsSync(ORGANIZED_PATH))
organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch {}
let organized: Organized
try { organized = readOrganized() }
catch (err) {
logger.error('organize', `Import automatique annulé : ${err instanceof Error ? err.message : err}`)
return
}
const hasUnorganized = torrents.some(t =>
t.state === 'seeding' && !organized[t.hash?.toLowerCase()]
@@ -290,13 +354,8 @@ export async function autoOrganizeAll(
logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
}
workerRunning = true
const worker = new Worker(resolveWorkerPath())
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'result') {
try {
await enqueue(() => runWorker(torrents, seriesData, msg => {
if (msg.errors.length > 0) {
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) {
@@ -316,24 +375,11 @@ export async function autoOrganizeAll(
errors : msg.errors.length,
errorFiles: msg.errors,
})
} else if (msg.type === 'done') {
logger.debug('organize', 'Worker terminé')
workerRunning = false
worker.terminate()
}
})
worker.on('error', (err) => {
logger.error('organize', `Worker erreur : ${err.message}`)
workerRunning = false
})
worker.on('exit', (code) => {
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
else logger.debug('organize', `Worker terminé (code ${code})`)
workerRunning = false
})
worker.postMessage({ type: 'run', torrents, seriesData })
}))
logger.debug('organize', 'Worker terminé')
} catch (err) {
logger.error('organize', `Worker erreur : ${err instanceof Error ? err.message : err}`)
}
}
// ── Migration des IDs épisodes ────────────────────────────────
@@ -368,11 +414,12 @@ export async function migrateOrganizedEpisodeIds(
}
// 2. Charger organized.json
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch { return { updated: 0, orphaned: 0 } }
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
logger.error('organize', `Migration IDs annulée : ${err instanceof Error ? err.message : err}`)
return { updated: 0, orphaned: 0 }
}
let updated = 0
let orphaned = 0
@@ -422,7 +469,7 @@ export async function migrateOrganizedEpisodeIds(
}
if (changed) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
writeOrganized(organized, organizedPath)
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)` : ''}`)
@@ -431,6 +478,49 @@ export async function migrateOrganizedEpisodeIds(
return { updated, orphaned }
}
// ── Déduplication ─────────────────────────────────────────────
export function dedupeOrganizedEpisodes(
organizedPath: string,
): { removed: number } {
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
logger.error('organize', `Dédoublonnage annulé : ${err instanceof Error ? err.message : err}`)
return { removed: 0 }
}
// episode_id → [{ hash, entry }]
const byEpisode = new Map<string, { hash: string; entry: any }[]>()
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
if (!byEpisode.has(episodeId)) byEpisode.set(episodeId, [])
byEpisode.get(episodeId)!.push({ hash, entry })
}
}
let removed = 0
for (const [episodeId, candidates] of byEpisode) {
if (candidates.length < 2) continue
const present = candidates.filter(c => c.entry?.dest_path && fs.existsSync(c.entry.dest_path))
if (present.length === 0) continue
for (const c of candidates) {
if (present.includes(c)) continue
delete (organized[c.hash] as any)[episodeId]
removed++
logger.info('organize', `Dédoublonnage : ep ${episodeId} retiré de ${c.hash.slice(0, 8)}… (fichier absent, conservé sous ${present[0].hash.slice(0, 8)}…)`)
}
}
if (removed > 0) {
writeOrganized(organized, organizedPath)
logger.info('organize', `Dédoublonnage terminé — ${removed} entrée(s) redondante(s) supprimée(s)`)
}
return { removed }
}
// ── Sync des noms de fichiers après mise à jour du scraper ────
export async function syncFilenameChanges(
@@ -438,11 +528,12 @@ export async function syncFilenameChanges(
organizedPath: string,
): Promise<{ renamed: number; errors: number }> {
const { nfoSupport } = readSettings()
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch { return { renamed: 0, errors: 0 } }
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
logger.error('organize', `Sync noms annulée : ${err instanceof Error ? err.message : err}`)
return { renamed: 0, errors: 0 }
}
let renamed = 0
let errors = 0
@@ -494,6 +585,7 @@ export async function syncFilenameChanges(
dest_path : newPath,
at : new Date().toISOString(),
}
retargetEntries(organized, new Map([[oldPath, newPath]]))
renamed++
changed = true
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
@@ -506,7 +598,7 @@ export async function syncFilenameChanges(
}
if (changed)
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
writeOrganized(organized, organizedPath)
if (renamed > 0 || errors > 0)
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
@@ -520,19 +612,27 @@ export async function organizeTorrent(
hash : string,
name : string,
savePath : string,
seriesData: any[]
seriesData: any[],
files? : { name?: string; progress: number; priority?: number }[],
): Promise<OrganizeResult> {
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
const key = hash.toLowerCase()
const pending = _pendingManual.get(key)
if (pending) {
logger.info('organize', `Import manuel de "${name}" déjà en attente — demande fusionnée`)
return pending
}
logger.info('organize', `Import manuel ${_queued > 0 ? 'mis en file' : '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: [] }
// files : progression par fichier récupérée depuis le client torrent.
// Permet au worker de ne traiter que les fichiers à 100% et d'éviter
// les EBUSY sur Windows quand qBit est encore en train d'écrire les autres.
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] }
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'result') {
const job = enqueue(async () => {
_pendingManual.delete(key)
try {
await runWorker([fakeTorrent], seriesData, msg => {
result.total = msg.total
result.skipped = msg.skipped
result.done = msg.done
@@ -545,21 +645,13 @@ export async function organizeTorrent(
} 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) => {
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err.message}`)
worker.terminate()
reject(err)
})
worker.on('exit', (code) => {
if (code > 1) reject(new Error(`Worker exit inattendu: code ${code}`))
})
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
})
} catch (err) {
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err instanceof Error ? err.message : err}`)
throw err
}
return result
})
_pendingManual.set(key, job)
return job
}
+261 -80
View File
@@ -1,28 +1,77 @@
import { Router } from 'express'
import path from 'path'
import fs from 'fs'
import { requireAuth } from '../auth.js'
import { requireAuth, requireAdmin } from '../auth.js'
import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { getGitlabTitle } from '../gitlab-map.js'
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js'
import { resolveEpNaming, computeExpectedName } from '../lib/serie-helpers.js'
import {
resolveEpNaming, computeExpectedName, resolveSerieData, loadCatalog, loadCatalogStatus,
serieFolderName, seasonFolderName,
} from '../lib/serie-helpers.js'
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
import { dispatchRemove } from '../torrent-clients/index.js'
import { readRequests, deleteRequest } from '../requests.js'
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from '../lib/organized-store.js'
const router = Router()
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
const serieNotFound = (id: unknown) => `Série ${id} introuvable dans le scraper et sur l'API Fankai (voir les logs)`
function loadOrganizedJson(): Record<string, Record<string, any>> {
try { if (fs.existsSync(ORGANIZED_PATH)) return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
return {}
// Entrée d'un épisode sous n'importe quel hash (torrent absent des paths du scraper)
function findEntry(organized: Organized, episodeId: number): { hash: string; entry: any } | null {
for (const [hash, eps] of Object.entries(organized))
if (eps[String(episodeId)]) return { hash, entry: eps[String(episodeId)] }
return null
}
function saveOrganizedJson(data: Record<string, Record<string, any>>) {
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
// Entrées dont l'épisode n'existe plus dans le catalogue
function findOrphans(organized: Organized, catalog: any[]) {
const known = new Set<string>()
for (const sd of catalog)
for (const season of sd.seasons ?? [])
for (const ep of season.episodes ?? []) known.add(String(ep.id))
const orphans: { hash: string; episode_id: number; season: number | null; episode: number | null; dest_path: string | null }[] = []
for (const [hash, eps] of Object.entries(organized))
for (const [episodeId, entry] of Object.entries(eps))
if (!known.has(episodeId))
orphans.push({ hash, episode_id: Number(episodeId), season: entry?.season ?? null, episode: entry?.episode ?? null, dest_path: entry?.dest_path ?? null })
return orphans
}
// Dossiers série (enfants directs de la médiathèque) contenant les fichiers suivis d'une série
function serieFolders(sd: any, organized: Organized, mediaPath: string): Map<string, number> {
const ids = new Set<string>()
for (const season of sd.seasons ?? [])
for (const ep of season.episodes ?? []) ids.add(String(ep.id))
const folders = new Map<string, number>()
if (!mediaPath) return folders
for (const eps of Object.values(organized)) {
for (const [episodeId, entry] of Object.entries(eps)) {
if (!ids.has(episodeId) || !entry?.dest_path) continue
const parts = path.relative(mediaPath, entry.dest_path).split(path.sep)
if (parts.length < 2 || parts[0] === '..' || path.isAbsolute(parts[0])) continue
const folder = path.join(mediaPath, parts[0])
folders.set(folder, (folders.get(folder) ?? 0) + 1)
}
}
return folders
}
function listFiles(dir: string): string[] {
const files: string[] = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) files.push(...listFiles(full))
else files.push(full)
}
return files
}
function removeEmptyDirs(dir: string): void {
for (const entry of fs.readdirSync(dir, { withFileTypes: true }))
if (entry.isDirectory()) removeEmptyDirs(path.join(dir, entry.name))
if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir)
}
// ── Import manuel ──────────────────────────────────────────────
@@ -31,18 +80,16 @@ router.post('/manual-import', requireAuth, async (req, res) => {
if (!serie_id || !Array.isArray(items) || items.length === 0) {
res.status(400).json({ error: 'serie_id et items requis' }); return
}
const { mediaPath, organizeMode, nfoSupport } = readSettings()
const organized = loadOrganizedJson()
let sd = await readSerieData(Number(serie_id))
if (!sd) {
// readSerieData ne couvre que le cache local — fallback sur le catalogue complet
// (utile pour les séries sans torrent officiel dont les données sont peut-être absentes du cache individuel)
const all = await loadEnrichedSeriesData()
sd = all.find((s: any) => s.id === Number(serie_id)) ?? null
}
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return }
const rawTitle = sd.title ?? sd.show_title ?? ''
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
const { mediaPath, organizeMode, nfoSupport, englishDirectory } = readSettings()
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
// Les copies de fichiers sont asynchrones : les modifications sont rejouées sur une relecture fraîche à la fin
const ops: ((data: Organized) => void)[] = []
const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) }
const sd = await resolveSerieData(Number(serie_id), 'Import manuel')
if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
const serieTitle = serieFolderName(sd.title ?? sd.show_title ?? '')
const episodeIndex = new Map<number, { ep: any; season: any }>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
@@ -68,8 +115,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
const destFilename = resolvedExt && resolvedExt !== srcExt
? resolvedName.slice(0, -resolvedExt.length) + srcExt
: resolvedName
const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}`
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
const destDir = path.join(mediaPath, serieTitle, seasonFolderName(season.season_number, englishDirectory))
const destPath = path.join(destDir, destFilename)
try {
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
@@ -78,13 +124,15 @@ router.post('/manual-import', requireAuth, async (req, res) => {
// sous un épisode différent (réassignation → l'ancien enregistrement devient invalide)
for (const [h, eps] of Object.entries(organized)) {
for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) {
delete (organized[h] as any)[epId]
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id))
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (fichier réassigné)`)
}
}
if (Object.keys(organized[h]).length === 0) delete organized[h]
}
apply(data => {
for (const eps of Object.values(data))
for (const [epId, entry] of Object.entries(eps))
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) delete eps[epId]
})
fs.mkdirSync(destDir, { recursive: true })
if (file_path !== destPath) {
@@ -96,18 +144,21 @@ router.post('/manual-import', requireAuth, async (req, res) => {
fs.unlinkSync(destPath)
logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`)
// Nettoyer toute entrée organized qui pointait sur ce fichier supprimé
for (const [h, eps] of Object.entries(organized)) {
for (const eps of Object.values(organized)) {
for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === destPath) {
delete (organized[h] as any)[epId]
if (entry?.dest_path === destPath)
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (ancien fichier écrasé)`)
}
}
if (Object.keys(organized[h]).length === 0) delete organized[h]
}
apply(data => {
for (const eps of Object.values(data))
for (const [epId, entry] of Object.entries(eps))
if (entry?.dest_path === destPath) delete eps[epId]
})
}
if (isInSeriePath && !fs.existsSync(destPath)) {
fs.renameSync(file_path, destPath)
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
} else if (!isInSeriePath && !fs.existsSync(destPath)) {
if (organizeMode === 'hardlink') {
@@ -116,6 +167,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
} else if (organizeMode === 'move') {
try { fs.renameSync(file_path, destPath) }
catch { await fs.promises.copyFile(file_path, destPath); await fs.promises.unlink(file_path) }
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
} else {
await fs.promises.copyFile(file_path, destPath)
}
@@ -125,12 +177,12 @@ router.post('/manual-import', requireAuth, async (req, res) => {
logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`)
}
const torrentHash = String(hash || '').toLowerCase() || 'manual'
if (!organized[torrentHash]) organized[torrentHash] = {}
organized[torrentHash][String(episode_id)] = {
const entry = {
at: new Date().toISOString(), season: season.season_number,
episode: ep.episode_number, episode_id: ep.id,
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
}
apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry })
done.push(episode_id)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
@@ -138,7 +190,13 @@ router.post('/manual-import', requireAuth, async (req, res) => {
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
}
}
saveOrganizedJson(organized)
if (ops.length > 0) {
try { updateOrganized(data => { for (const op of ops) op(data) }) }
catch (err) {
logger.error('api', `Import manuel : enregistrement impossible : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return
}
}
if (nfoSupport && done.length > 0) {
const gitlabTitle = getGitlabTitle(serieTitle)
@@ -173,9 +231,9 @@ router.post('/manual-import', requireAuth, async (req, res) => {
router.get('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId)
try {
const sd = await readSerieData(serieId)
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const organized = loadOrganizedJson()
const sd = await resolveSerieData(serieId)
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
const organized = readOrganized()
const { nfoSupport } = readSettings()
const result: Record<string, any> = {}
for (const season of sd.seasons ?? []) {
@@ -191,6 +249,10 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => {
if (!entry && organized['manual']?.[String(ep.id)]) {
entry = organized['manual'][String(ep.id)]
}
if (!entry) {
const found = findEntry(organized, ep.id)
if (found) { entry = found.entry; entryHash = found.hash }
}
if (!entry) continue
const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport)
@@ -208,9 +270,11 @@ 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 }
const sd = await resolveSerieData(Number(serie_id), 'Renommage')
if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
let foundEp: any = null
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
@@ -224,9 +288,8 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
if (!orgEntry) {
for (const [h, eps] of Object.entries(organized)) {
if (eps[String(episode_id)]) { orgEntry = eps[String(episode_id)]; entryHash = h; break }
}
const found = findEntry(organized, Number(episode_id))
if (found) { orgEntry = found.entry; entryHash = found.hash }
}
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
const { currentName, expectedName: newName, needsRename } = computeExpectedName(ep, orgEntry, entryHash, nfoSupport)
@@ -238,7 +301,8 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
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)
retargetEntries(organized, new Map([[oldPath, newPath]]))
writeOrganized(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) {
@@ -252,9 +316,11 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
router.delete('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const sd = await resolveSerieData(Number(serieId), 'Désimport série')
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
const episodeIds = new Set<string>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id))
@@ -279,7 +345,7 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
}
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
}
saveOrganizedJson(organized)
writeOrganized(organized)
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
@@ -312,9 +378,11 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
const serieId = String(req.params.serieId)
const seasonId = Number(req.params.seasonId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const sd = await resolveSerieData(Number(serieId), 'Désimport saison')
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); 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)) ?? [])
@@ -333,7 +401,7 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
}
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
}
saveOrganizedJson(organized)
writeOrganized(organized)
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
@@ -348,27 +416,40 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const episodeId = String(req.params.episodeId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
let foundHash: string | null = null
let foundEntry: any = null
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
const found: { hash: string; entry: any }[] = []
for (const [hash, episodes] of Object.entries(organized)) {
if (episodes[episodeId]) { foundHash = hash; foundEntry = episodes[episodeId]; break }
if (episodes[episodeId]) found.push({ hash, entry: episodes[episodeId] })
}
if (!foundHash || !foundEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
if (found.length === 0) { res.status(404).json({ error: 'Épisode non importé' }); return }
try {
if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) {
fs.unlinkSync(foundEntry.dest_path)
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`)
const deletedPaths = new Set<string>()
if (deleteFile) {
for (const { entry } of found) {
const p = entry?.dest_path
if (!p || deletedPaths.has(p) || !fs.existsSync(p)) continue
fs.unlinkSync(p)
deletedPaths.add(p)
logger.info('api', `Désimport + suppression fichier : "${p}"`)
}
}
delete organized[foundHash][episodeId]
const torrentEmpty = Object.keys(organized[foundHash]).length === 0
if (torrentEmpty) delete organized[foundHash]
saveOrganizedJson(organized)
// Si plus aucun épisode de ce torrent n'est importé → retirer le torrent du client
if (deleteFile && torrentEmpty && foundHash !== 'manual') {
dispatchRemove(foundHash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${foundHash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
const emptyHashes: string[] = []
for (const { hash } of found) {
delete organized[hash][episodeId]
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
}
logger.info('api', `Désimport ep ${episodeId}${deleteFile && torrentEmpty ? ` + torrent ${foundHash.slice(0, 8)}… retiré du client` : ''}`)
writeOrganized(organized)
// Si plus aucun épisode d'un torrent n'est importé → retirer ce torrent du client
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
}
const removedTorrents = deleteFile ? emptyHashes.filter(h => h !== 'manual') : []
logger.info('api', `Désimport ep ${episodeId}${removedTorrents.length ? ` + ${removedTorrents.length} torrent(s) retiré(s) du client` : ''}`)
res.json({ ok: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
@@ -381,12 +462,12 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) =>
router.get('/organized-summary', requireAuth, async (_req, res) => {
try {
const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const seriesData = await loadEnrichedSeriesData()
const { series: seriesData, complete } = await loadCatalogStatus()
const organized = readOrganized()
const result: any[] = []
for (const sd of seriesData) {
const rawTitle = sd.title ?? sd.show_title ?? ''
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
const serieTitle = serieFolderName(rawTitle)
const episodes: any[] = []
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
@@ -403,6 +484,10 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
if (!orgEntry && organized['manual']?.[String(ep.id)]) {
orgEntry = organized['manual'][String(ep.id)]; orgHash = 'manual'
}
if (!orgEntry) {
const found = findEntry(organized, ep.id)
if (found) { orgEntry = found.entry; orgHash = found.hash }
}
if (!orgEntry) continue
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
@@ -413,23 +498,118 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
result.push({ serie_id: sd.id, serie_title: rawTitle, serie_title_clean: serieTitle, total: episodes.length, needs_rename: episodes.filter(e => e.needs_rename).length, episodes })
}
}
res.json({ series: result, nfo_support: nfoSupport })
const orphans = complete
? findOrphans(organized, seriesData).map(o => ({ ...o, file_exists: !!o.dest_path && fs.existsSync(o.dest_path) }))
: []
res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete })
} 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' })
}
})
// ── Retrait des entrées orphelines ─────────────────────────────
router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => {
const only = Array.isArray(req.body?.episode_ids) ? new Set(req.body.episode_ids.map(String)) : null
const { series, complete } = await loadCatalogStatus()
if (!complete) { res.status(503).json({ error: 'Catalogue incomplet (scraper ou API Fankai injoignable), réessayez plus tard' }); return }
let removed = 0
try {
updateOrganized(data => {
for (const o of findOrphans(data, series)) {
if (only && !only.has(String(o.episode_id))) continue
delete data[o.hash][String(o.episode_id)]
removed++
}
return removed > 0
})
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return
}
logger.info('api', `Entrées orphelines retirées du suivi : ${removed}`)
res.json({ ok: true, removed })
})
// ── Dossier série ──────────────────────────────────────────────
router.get('/organized/:serieId/folder', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId)
const sd = await resolveSerieData(serieId)
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
const { mediaPath } = readSettings()
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
const current = [...serieFolders(sd, organized, mediaPath)].map(([folder, entries]) => ({ path: folder, entries, exists: fs.existsSync(folder) }))
res.json({ expected, current, needs_rename: current.some(f => f.exists && f.path !== expected) })
})
router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
const serieId = Number(req.params.serieId)
const sd = await resolveSerieData(serieId, 'Renommage dossier')
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
const { mediaPath } = readSettings()
if (!mediaPath) { res.status(400).json({ error: 'Chemin médiathèque non configuré' }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
const sources = [...serieFolders(sd, organized, mediaPath).keys()].filter(f => f !== expected && fs.existsSync(f))
if (sources.length === 0) { res.json({ ok: true, moved: 0, updated: 0 }); return }
// Tout le contenu (vidéos, NFO, images…) est fusionné dans le dossier attendu, sans jamais écraser
const moves = new Map<string, string>()
for (const src of sources)
for (const file of listFiles(src)) moves.set(file, path.join(expected, path.relative(src, file)))
const targets = [...moves.values()]
const conflicts = targets.filter((to, i) => fs.existsSync(to) || targets.indexOf(to) !== i)
if (conflicts.length > 0) {
res.status(409).json({ error: `${conflicts.length} fichier(s) existent déjà dans « ${path.basename(expected)} »`, conflicts: conflicts.slice(0, 10) }); return
}
const done = new Map<string, string>()
let failure: string | null = null
for (const [from, to] of moves) {
try {
fs.mkdirSync(path.dirname(to), { recursive: true })
fs.renameSync(from, to)
done.set(from, to)
} catch (err) {
failure = `${path.basename(from)} : ${err instanceof Error ? err.message : err}`
break
}
}
for (const src of sources) {
try { removeEmptyDirs(src) } catch {}
}
let updated = 0
try { updateOrganized(data => { updated = retargetEntries(data, done); return updated > 0 }) }
catch (err) {
logger.error('api', `Renommage dossier série ${serieId} : fichiers déplacés mais suivi non mis à jour : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: 'Fichiers déplacés mais suivi non mis à jour, lancez un scan de la médiathèque' }); return
}
const label = `"${sources.map(s => path.basename(s)).join('", "')}" → "${path.basename(expected)}"`
if (failure) {
logger.error('api', `Renommage dossier ${label} interrompu après ${done.size} fichier(s) : ${failure}`)
res.status(500).json({ error: `Interrompu après ${done.size} fichier(s) : ${failure}`, moved: done.size, updated }); return
}
logger.info('api', `Dossier série renommé : ${label} (${done.size} fichier(s), ${updated} entrée(s))`)
res.json({ ok: true, moved: done.size, updated })
})
// ── Rename en masse ────────────────────────────────────────────
router.post('/rename-all', requireAuth, async (req, res) => {
const { serie_id } = req.body
router.post('/rename-all', requireAdmin, async (req, res) => {
const { serie_id, serie_ids } = req.body
const onlyIds = Array.isArray(serie_ids) ? new Set(serie_ids.map(Number)) : serie_id ? new Set([Number(serie_id)]) : null
const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const seriesData = await loadEnrichedSeriesData()
const seriesData = await loadCatalog()
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
const done: number[] = []
const errors: { episode_id: number; error: string }[] = []
for (const sd of seriesData) {
if (serie_id && sd.id !== Number(serie_id)) continue
if (onlyIds && !onlyIds.has(sd.id)) continue
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
@@ -455,6 +635,7 @@ router.post('/rename-all', requireAuth, async (req, res) => {
}
fs.renameSync(oldPath, newPath)
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
retargetEntries(organized, new Map([[oldPath, newPath]]))
done.push(ep.id)
logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`)
} catch (err) {
@@ -465,7 +646,7 @@ router.post('/rename-all', requireAuth, async (req, res) => {
}
}
}
saveOrganizedJson(organized)
writeOrganized(organized)
res.json({ ok: true, done: done.length, errors })
})
+11 -4
View File
@@ -2,8 +2,9 @@ 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 { loadCatalog } from '../lib/serie-helpers.js'
import { recentOrganized, pushNotif } from '../lib/notifs.js'
import { dispatchGetFiles } from '../torrent-clients/index.js'
import { DATA_DIR } from '../config.js'
import path from 'path'
@@ -11,7 +12,7 @@ const router = Router()
router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
try {
const seriesData = await loadEnrichedSeriesData()
const seriesData = await loadCatalog()
const organizedPath = path.join(DATA_DIR, 'organized.json')
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
res.json({ ok: true, ...result })
@@ -35,8 +36,14 @@ 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)
const seriesData = await loadCatalog()
// Récupère la progression par fichier depuis le client torrent.
// Permet au worker de sauter les fichiers encore en téléchargement (EBUSY sur Windows).
// En cas d'échec (client injoignable, hash non trouvé), on continue sans filtre.
const files = await dispatchGetFiles(hash).catch(() => [])
const result = await organizeTorrent(hash, name, save_path, seriesData, files)
if (result.done > 0 || result.errors.length > 0) {
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
}
+21 -2
View File
@@ -1,7 +1,8 @@
import { Router } from 'express'
import { requireAuth } from '../auth.js'
import { requireAuth, requireAdmin } from '../auth.js'
import { logger } from '../logger.js'
import { loadSynced, setSync, isSynced, runRssSync } from '../lib/rss-sync.js'
import { loadSynced, setSync, setSyncBulk, isSynced, runRssSync, reconcileSynced } from '../lib/rss-sync.js'
import { loadCatalogStatus } from '../lib/serie-helpers.js'
const router = Router()
@@ -21,6 +22,24 @@ router.post('/rss-sync/run', requireAuth, async (_req, res) => {
}
})
/** GET /api/rss-sync/orphans → surveillances dont la série n'existe plus (avant /:id) */
router.get('/rss-sync/orphans', requireAdmin, async (_req, res) => {
const { series, complete } = await loadCatalogStatus()
res.json({ checked: complete, orphans: complete ? reconcileSynced(series) : [] })
})
/** POST /api/rss-sync/bulk → activer/désactiver la surveillance de plusieurs séries (avant /:id) */
router.post('/rss-sync/bulk', requireAdmin, (req, res) => {
const enabled = req.body?.enabled === true
const series = (Array.isArray(req.body?.series) ? req.body.series : [])
.map((s: any) => ({ id: Number(s?.id), name: String(s?.name ?? '') }))
.filter((s: { id: number; name: string }) => s.id && s.name)
if (series.length === 0) { res.status(400).json({ error: 'series requis' }); return }
const { changed, map } = setSyncBulk(series, enabled)
logger.info('rss-sync', `Surveillance ${enabled ? 'activée' : 'désactivée'} en masse — ${changed} série(s) modifiée(s) sur ${series.length}`)
res.json({ ok: true, changed, total: Object.keys(map).length })
})
/** GET /api/rss-sync/:id → statut sync pour une série */
router.get('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id)
+20 -20
View File
@@ -7,10 +7,11 @@ import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { dispatchList } from '../torrent-clients/index.js'
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
import { loadSynced } from '../lib/rss-sync.js'
import {
fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode,
fankaiGet, fetchSerieFromApi, normalizeSerie, normalizeSeason, normalizeEpisode,
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
deduplicateEpisodes,
deduplicateEpisodes, serieHasEpisodes,
} from '../lib/serie-helpers.js'
const router = Router()
@@ -22,14 +23,19 @@ router.get('/series', requireAuth, async (_req, res) => {
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const activeTorrents = new Set<string>()
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
const clientHashes = new Set<string>()
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (!t.hash) continue; clientHashes.add(t.hash.toLowerCase()); if (t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
const organizedEpisodeIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
const synced = loadSynced()
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
const serieDataMap = new Map<number, any>()
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
res.json({ series: seriesRaw.map((serie: any) => {
const serieData = serieDataMap.get(serie.id) ?? null
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) }
const serieData = serieDataMap.get(serie.id) ?? null
const torrents = serieData ? extractTorrentsFromSerieData(serieData) : []
const inClient = torrents.some(t => t.infohash && clientHashes.has(t.infohash.toLowerCase()))
const hasFiles = (serieData?.seasons ?? []).some((s: any) => (s.episodes ?? []).some((ep: any) => organizedEpisodeIds.has(String(ep.id))))
return { ...serie, torrent_count: torrents.length, has_torrents: torrents.length > 0, download_state: computeSerieDownloadState(serieData, organized, activeTorrents), in_client: inClient, has_files: hasFiles, rss_synced: !!synced[serie.id] }
})})
} catch (err) {
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
@@ -40,14 +46,9 @@ router.get('/series', requireAuth, async (_req, res) => {
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 [{ seasons, ...serieRaw }, serieData] = await Promise.all([fetchSerieFromApi(id), 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 }
}))
const seasonsWithEpisodes = seasons.map((season: any) => ({ ...normalizeSeason(season), episodes: season.episodes.map(normalizeEpisode) }))
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
@@ -57,12 +58,11 @@ router.get('/series/:id', requireAuth, async (req, res) => {
const integraleTorrents : any[] = []
const organizedEpisodeIds = new Set<number>()
const manualOrg = organized['manual'] ?? {}
if (serieData) {
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
}
// Importé = une entrée pour l'épisode sous n'importe quel hash : les torrents du scraper peuvent disparaître
const trackedIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
for (const season of seasonsWithEpisodes) {
for (const ep of season.episodes) {
if (trackedIds.has(String(ep.id))) organizedEpisodeIds.add(ep.id)
}
}
if (serieData) {
@@ -185,7 +185,7 @@ router.get('/series/:id', requireAuth, async (req, res) => {
}
})
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) })
res.json({ serie, seasons: enrichedSeasons, scraper_synced: serieHasEpisodes(serieData), torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) })
} catch (err) {
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+2 -1
View File
@@ -10,7 +10,7 @@ router.get('/settings', requireAuth, (_req, res) => {
})
router.post('/settings', requireAuth, (req, res) => {
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing, requestAutoDownloadUsers } = req.body
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing, requestAutoDownloadUsers, englishDirectory } = req.body
const patch: Record<string, any> = {}
if (mediaPath !== undefined) patch.mediaPath = mediaPath
if (completePath !== undefined) patch.completePath = completePath
@@ -22,6 +22,7 @@ router.post('/settings', requireAuth, (req, res) => {
if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing
if (requestAutoDownloadUsers !== undefined) patch.requestAutoDownloadUsers = requestAutoDownloadUsers
if (englishDirectory !== undefined) patch.englishDirectory = englishDirectory
const updated = writeSettings(patch)
logger.info('api', 'Paramètres mis à jour')
res.json(updated)
+28 -13
View File
@@ -6,8 +6,9 @@ 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 { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js'
import { readAvailable, cacheClear } from '../lib/github-cache.js'
import { loadCatalog } from '../lib/serie-helpers.js'
import { cacheSize } from '../lib/github-cache.js'
import { recentOrganized } from '../lib/notifs.js'
import { workerRunning } from '../organize.js'
@@ -87,10 +88,8 @@ router.get('/browse-files', requireAuth, (req, res) => {
const dirPath = (req.query.path as string) || '/'
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
try {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
logger.info('api', `Dossier créé : ${dirPath}`)
}
// Lecture seule : le dossier est créé au moment de l'import
if (!fs.existsSync(dirPath)) { res.json({ path: dirPath, files: [], exists: false }); return }
const files: { name: string; path: string; size: number }[] = []
function walk(dir: string) {
let entries: fs.Dirent[]
@@ -111,7 +110,7 @@ router.get('/browse-files', requireAuth, (req, res) => {
}
walk(dirPath)
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
res.json({ path: dirPath, files })
res.json({ path: dirPath, files, exists: true })
} catch (err) {
logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
@@ -126,18 +125,34 @@ router.get('/torrents/status', requireAuth, async (_req, res) => {
} catch { res.json({ exists: false, count: 0, empty: true }) }
})
router.post('/update', requireAuth, async (_req, res) => {
router.post('/update', requireAuth, async (req, res) => {
const force = req.body?.force === true
const organizedPath = path.join(DATA_DIR, 'organized.json')
try {
cacheClear()
logger.info('api', 'Cache GitHub vidé — rechargement forcé')
const availableIds = await readAvailable()
logger.info('api', force
? 'Forçage des correspondances de renommage — cache mémoire et CDN contournés'
: 'Cache GitHub vidé — rechargement forcé')
const availableIds = await readAvailable(force)
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
if (force) {
const seriesData = await loadCatalog(true)
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
const { removed } = dedupeOrganizedEpisodes(organizedPath)
const { renamed, errors } = await syncFilenameChanges(seriesData, organizedPath)
logger.info('api', `Forçage terminé — ${updated} ID(s) migré(s), ${removed} doublon(s) retiré(s), ${renamed} fichier(s) renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
res.json({ ok: true, count: availableIds.length, migrated: updated, deduped: removed, renamed, errors })
return
}
res.json({ ok: true, count: availableIds.length })
;(async () => {
try {
const seriesData = await loadEnrichedSeriesData()
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
const seriesData = await loadCatalog()
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0)
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
} catch (err) {
@@ -154,7 +169,7 @@ 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 seriesData = await loadCatalog()
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
res.json({ ok: true, ...result })
} catch (err) {
+2
View File
@@ -16,6 +16,7 @@ export interface Settings {
requestAutoDownloadUsers: 'all' | string[] // IDs users autorisés au dl auto sur approbation ; [] = désactivé
jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096)
jellyfinAdminToken : string // Token admin Jellyfin pour la sync
englishDirectory : boolean // Si true, les dossiers de séries sont en anglais (ex: "Season 1" au lieu de "Saison 1")
}
const defaults: Settings = {
@@ -30,6 +31,7 @@ const defaults: Settings = {
requestAutoDownloadUsers: [],
jellyfinUrl : '',
jellyfinAdminToken : '',
englishDirectory : false,
}
export function readSettings(): Settings {
-197
View File
@@ -1,197 +0,0 @@
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
const AD_BASE = 'https://api.alldebrid.com/v4.1'
const AD_BASE_V4 = 'https://api.alldebrid.com/v4'
const AD_AGENT = 'FanKarr'
function adUrl(config: Record<string, string | number>, path: string, base = AD_BASE): string {
const sep = path.includes('?') ? '&' : '?'
return `${base}${path}${sep}agent=${AD_AGENT}&apikey=${config.apiKey}`
}
async function adFetch(
config: Record<string, string | number>,
method: 'GET' | 'POST' | 'DELETE',
path : string,
body ?: URLSearchParams | FormData,
base = AD_BASE,
): Promise<any> {
const res = await fetch(adUrl(config, path, base), { method, body })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`AllDebrid HTTP ${res.status} : ${text}`)
}
const json = await res.json()
if (json.status === 'error') {
throw new Error(`AllDebrid : ${json.error?.message ?? json.error?.code ?? 'Erreur inconnue'}`)
}
return json.data
}
// statusCode : 0=Processing, 1=Downloading, 2=Compressing, 3=Uploading, 4=Ready,
// 5=Upload Fail, 6=Internal Error, 7=Not Downloaded, 8=File Not Available,
// 9=Banned BitTorrent, 10=Awaiting Slot, 11=Free Trial Limit Reached
// hasLocalMount : true si un chemin local (FUSE/WebDAV) est configuré.
// Sans montage local, statusCode 4 ("prêt sur les serveurs AD") ne doit pas
// déclencher l'import FanKarr — les fichiers ne sont pas encore sur le disque.
function mapStatus(statusCode: number, hasLocalMount: boolean): TorrentInfo['state'] {
switch (statusCode) {
case 4: return hasLocalMount ? 'seeding' : 'downloading'
case 1:
case 0:
case 2:
case 3:
case 10: return 'downloading'
case 5:
case 6:
case 7:
case 8:
case 9:
case 11: return 'error'
default: return 'unknown'
}
}
function flattenFiles(nodes: any[], prefix = '', startIdx = 0): TorrentFileProgress[] {
const result: TorrentFileProgress[] = []
let idx = startIdx
for (const node of nodes) {
if (node.e) {
const children = flattenFiles(node.e, prefix ? `${prefix}/${node.n}` : node.n, idx)
result.push(...children)
idx += children.length
} else if (node.n) {
result.push({ index: idx++, name: prefix ? `${prefix}/${node.n}` : node.n, progress: 0, priority: 1 })
}
}
return result
}
const adDriver: TorrentClientDriver = {
definition: {
id : 'alldebrid',
label : 'AllDebrid',
fields: [
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
{ key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/alldebrid', required: false },
],
},
async test(config) {
try {
const data = await adFetch(config, 'GET', '/user')
const username = data.user?.username ?? data.user?.login ?? '?'
logger.info('alldebrid', `Connexion réussie — utilisateur : ${username}`)
return { ok: true, message: `Connecté en tant que ${username}` }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('alldebrid', `Test de connexion échoué : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const data = await adFetch(config, 'GET', '/user')
const username = data.user?.username ?? data.user?.login ?? '?'
return { online: true, version: `AD — ${username}` }
} catch {
return { online: false }
}
},
async add(config, url, options?: DownloadOptions) {
if (options?.file_index != null)
logger.warn('alldebrid', `Sélection de fichier (index ${options.file_index}) non supportée par AllDebrid — pack complet téléchargé`)
const isMagnet = url.startsWith('magnet:')
if (isMagnet) {
const body = new URLSearchParams()
body.append('magnets[]', url)
const data = await adFetch(config, 'POST', '/magnet/upload', body)
const id = data.magnets?.[0]?.id
logger.info('alldebrid', `Magnet ajouté → ID ${id}`)
} else {
const torrentRes = await fetch(url)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`)
const torrentBuf = await torrentRes.arrayBuffer()
const formData = new FormData()
formData.append('files[]', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
const data = await adFetch(config, 'POST', '/magnet/upload/file', formData)
const id = data.magnets?.[0]?.id
logger.info('alldebrid', `.torrent uploadé → ID ${id}`)
}
},
async list(config, _category?) {
const data = await adFetch(config, 'GET', '/magnet/status')
const magnets: any[] = data.magnets ?? []
const localBase = String(config.localPath ?? '').replace(/\/+$/, '')
return magnets.map((m: any): TorrentInfo => {
const statusCode = typeof m.statusCode === 'number' ? m.statusCode : 0
const size = typeof m.size === 'number' ? m.size : 0
const downloaded = typeof m.downloaded === 'number' ? m.downloaded : 0
const progress = size > 0
? Math.round((downloaded / size) * 100)
: (statusCode === 4 ? 100 : 0)
// save_path = dossier PARENT (comme qBittorrent), sans le nom du torrent.
// Le worker construit lui-même le chemin complet via getFiles().
const save_path = localBase
const eta = (typeof m.completionDate === 'number' && m.completionDate > 0)
? Math.max(0, m.completionDate - Math.floor(Date.now() / 1000))
: -1
return {
hash : String(m.hash ?? m.id ?? '').toLowerCase(),
name : String(m.filename ?? m.id ?? ''),
state : mapStatus(statusCode, Boolean(localBase)),
progress,
size,
downloaded,
uploaded : typeof m.uploaded === 'number' ? m.uploaded : 0,
ratio : 0,
speed : typeof m.downloadSpeed === 'number' ? m.downloadSpeed : 0,
upspeed : typeof m.uploadSpeed === 'number' ? m.uploadSpeed : 0,
eta,
save_path,
category : '',
}
})
},
async remove(config, hash, _deleteFiles?) {
const data = await adFetch(config, 'GET', '/magnet/status')
const magnets: any[] = data.magnets ?? []
const target = magnets.find((m: any) => String(m.hash ?? m.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur AllDebrid`)
await adFetch(config, 'DELETE', `/magnet/delete?id=${target.id}`)
logger.info('alldebrid', `Torrent ${hash.slice(0, 8)}… supprimé de AllDebrid`)
},
async getFiles(config, hash) {
const data = await adFetch(config, 'GET', '/magnet/status')
const magnets: any[] = data.magnets ?? []
const target = magnets.find((m: any) => String(m.hash ?? m.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) return []
const statusCode = typeof target.statusCode === 'number' ? target.statusCode : 0
const size = typeof target.size === 'number' ? target.size : 0
const downloaded = typeof target.downloaded === 'number' ? target.downloaded : 0
const overallProgress = size > 0 ? downloaded / size : (statusCode === 4 ? 1 : 0)
const body = new URLSearchParams()
body.append('id[]', String(target.id))
const filesData = await adFetch(config, 'POST', '/magnet/files', body, AD_BASE_V4)
const magnetEntry = (filesData.magnets ?? [])[0]
if (!Array.isArray(magnetEntry?.files)) return []
return flattenFiles(magnetEntry.files).map(f => ({ ...f, progress: overallProgress }))
},
}
export default adDriver
+13 -10
View File
@@ -8,7 +8,8 @@
* Progress : float 0–100 (déjà en pourcentage, pas 0–1)
*/
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
// ─── Session cache ─────────────────────────────────────────────────────────────
@@ -18,13 +19,13 @@ import { logger } from '../logger.js'
interface SessionEntry { cookie: string; expires: number }
const _sessions = new Map<string, SessionEntry>()
function sessionKey(config: Record<string, string | number>): string {
function sessionKey(config: ClientConfig): string {
return `${config.url}::${config.password}`
}
// ─── Helpers HTTP ──────────────────────────────────────────────────────────────
function delugeUrl(config: Record<string, string | number>): string {
function delugeUrl(config: ClientConfig): string {
return String(config.url ?? '').replace(/\/+$/, '') + '/json'
}
@@ -34,12 +35,13 @@ function parseCookie(raw: string): string {
}
async function delugeRPC(
config : ClientConfig,
url : string,
method : string,
params : unknown[],
cookie : string,
): Promise<{ result: any; newCookie: string | null }> {
const res = await fetch(url, {
const res = await clientFetch(config, url, {
method : 'POST',
headers: {
'Content-Type': 'application/json',
@@ -68,9 +70,9 @@ async function delugeRPC(
// ─── Login ─────────────────────────────────────────────────────────────────────
async function delugeLogin(config: Record<string, string | number>): Promise<string> {
async function delugeLogin(config: ClientConfig): Promise<string> {
const url = delugeUrl(config)
const res = await fetch(url, {
const res = await clientFetch(config, url, {
method : 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body : JSON.stringify({ method: 'auth.login', params: [String(config.password ?? '')], id: 1 }),
@@ -94,7 +96,7 @@ async function delugeLogin(config: Record<string, string | number>): Promise<str
// ─── Call avec gestion de session ─────────────────────────────────────────────
async function delugeCall(
config : Record<string, string | number>,
config : ClientConfig,
method : string,
params : unknown[] = [],
): Promise<any> {
@@ -110,7 +112,7 @@ async function delugeCall(
}
try {
const { result, newCookie } = await delugeRPC(url, method, params, entry.cookie)
const { result, newCookie } = await delugeRPC(config, url, method, params, entry.cookie)
// Mise à jour du cookie si Deluge en renvoie un nouveau
if (newCookie) {
entry.cookie = newCookie
@@ -125,7 +127,7 @@ async function delugeCall(
const cookie = await delugeLogin(config)
const fresh = { cookie, expires: Date.now() + 30 * 60_000 }
_sessions.set(key, fresh)
const { result } = await delugeRPC(url, method, params, cookie)
const { result } = await delugeRPC(config, url, method, params, cookie)
return result
}
throw err
@@ -178,7 +180,7 @@ function flattenDelugeFiles(
// les priorités : fichier voulu = 1 (normal), tous les autres = 0 (skip).
async function applyFilePriority(
config : Record<string, string | number>,
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
@@ -211,6 +213,7 @@ const delugeDriver: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
+131 -15
View File
@@ -7,31 +7,38 @@
import { randomUUID } from 'crypto'
import fs from 'fs'
import path from 'path'
import { Agent, fetch as undiciFetch } from 'undici'
import { DATA_DIR } from '../config.js'
import { logger } from '../logger.js'
// ─── Types ────────────────────────────────────────────────────────────────────
/** Config d'un client torrent (les champs `boolean` sont stockés en booléens). */
export type ClientConfig = Record<string, string | number | boolean>
export interface FieldDef {
key : string
label : string
type : 'text' | 'password' | 'number' | 'url'
type : 'text' | 'password' | 'number' | 'url' | 'boolean'
placeholder?: string
required : boolean
default? : string | number
default? : string | number | boolean
}
export interface TorrentClientDefinition {
id : string
label : string
fields : FieldDef[]
id : string
label : string
fields : FieldDef[]
/** Si true, dispatchList filtre les résultats pour n'inclure que les
* torrents ajoutés via FanKarr (clients sans catégories natives). */
filterByManagedHashes?: boolean
}
export interface SavedClient {
uuid : string
name : string
type : string
config: Record<string, string | number>
config: ClientConfig
}
export interface TorrentFileProgress {
@@ -67,12 +74,50 @@ export interface DownloadOptions {
export interface TorrentClientDriver {
definition : TorrentClientDefinition
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
add : (config: Record<string, string | number>, url: string, options?: DownloadOptions) => Promise<void>
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
getFiles ?: (config: Record<string, string | number>, hash: string) => Promise<TorrentFileProgress[]>
test : (config: ClientConfig) => Promise<{ ok: boolean; message: string }>
healthcheck : (config: ClientConfig) => Promise<{ online: boolean; version?: string }>
add : (config: ClientConfig, url: string, options?: DownloadOptions) => Promise<void>
list : (config: ClientConfig, category?: string) => Promise<TorrentInfo[]>
remove : (config: ClientConfig, hash: string, deleteFiles?: boolean) => Promise<void>
getFiles ?: (config: ClientConfig, hash: string) => Promise<TorrentFileProgress[]>
}
// ─── Certificats TLS ──────────────────────────────────────────────────────────
// Option "ignoreCertificateErrors" (par client) : accepte les certificats
// auto-signés/invalides. Node → Agent undici (cf. plex.ts) ; Bun → option
// `tls` de son fetch natif (l'Agent undici n'y est pas honoré).
let _insecureAgent: Agent | null = null
function insecureAgent(): Agent {
if (!_insecureAgent) _insecureAgent = new Agent({ connect: { rejectUnauthorized: false } })
return _insecureAgent
}
export function ignoreCertificateErrors(config: ClientConfig): boolean {
const v = config.ignoreCertificateErrors
return v === true || v === 'true' || v === 1
}
// Option `tls` propre au fetch de Bun, absente des types standards.
interface BunRequestInit extends RequestInit {
tls?: { rejectUnauthorized?: boolean }
}
const isBun = 'bun' in process.versions
/** fetch du client : accepte les certificats invalides si l'option est active. */
export function clientFetch(
config : ClientConfig,
url : string,
init : RequestInit = {},
): Promise<Response> {
if (!ignoreCertificateErrors(config)) return fetch(url, init)
if (isBun) {
const bunInit: BunRequestInit = { ...init, tls: { rejectUnauthorized: false } }
return fetch(url, bunInit)
}
return undiciFetch(url, { ...init, dispatcher: insecureAgent() } as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>
}
// ─── Registry ─────────────────────────────────────────────────────────────────
@@ -94,7 +139,64 @@ export function getAvailableClients(): TorrentClientDefinition[] {
// ─── Persistence ──────────────────────────────────────────────────────────────
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
// ─── Managed tasks ─────────────────────────────────────────────────────────────
// Stocke les infohashes des torrents ajoutés via FanKarr, par UUID client.
// Utilisé par dispatchList pour filtrer les résultats des clients sans
// catégories natives (ex: Synology Download Station).
// Structure : { clientUuid: [infohash, ...] }
const MANAGED_PATH = path.join(DATA_DIR, 'managed_tasks.json')
function loadManagedMap(): Record<string, string[]> {
try {
if (!fs.existsSync(MANAGED_PATH)) return {}
return JSON.parse(fs.readFileSync(MANAGED_PATH, 'utf-8'))
} catch { return {} }
}
function saveManagedMap(data: Record<string, string[]>) {
try {
fs.mkdirSync(path.dirname(MANAGED_PATH), { recursive: true })
fs.writeFileSync(MANAGED_PATH, JSON.stringify(data, null, 2), 'utf-8')
} catch {}
}
/** Retourne le Set des infohashes gérés pour un client, ou null si vide. */
function getManagedSet(clientUuid: string): Set<string> | null {
const list = loadManagedMap()[clientUuid]
if (!list || list.length === 0) return null
return new Set(list)
}
/** Enregistre un infohash comme géré pour un client (idempotent). */
export function trackManagedHash(clientUuid: string, infohash: string) {
const data = loadManagedMap()
if (!data[clientUuid]) data[clientUuid] = []
const h = infohash.toLowerCase()
if (!data[clientUuid].includes(h)) {
data[clientUuid].push(h)
saveManagedMap(data)
}
}
/** Retire un infohash de la liste gérée pour un client. */
export function untrackManagedHash(clientUuid: string, infohash: string) {
const data = loadManagedMap()
if (!data[clientUuid]) return
data[clientUuid] = data[clientUuid].filter(h => h !== infohash.toLowerCase())
if (data[clientUuid].length === 0) delete data[clientUuid]
saveManagedMap(data)
}
/** Supprime toutes les tâches gérées d'un client (appelé lors de la suppression du client). */
function clearManagedHashes(clientUuid: string) {
const data = loadManagedMap()
if (!data[clientUuid]) return
delete data[clientUuid]
saveManagedMap(data)
}
function loadClients(): SavedClient[] {
try {
@@ -114,7 +216,7 @@ export function listClients(): SavedClient[] {
return loadClients()
}
export function addClient(name: string, type: string, config: Record<string, string | number>): SavedClient {
export function addClient(name: string, type: string, config: ClientConfig): SavedClient {
const clients = loadClients()
const client: SavedClient = { uuid: randomUUID(), name, type, config }
clients.push(client)
@@ -129,6 +231,7 @@ export function removeClient(uuid: string): boolean {
const filtered = clients.filter(c => c.uuid !== uuid)
if (filtered.length === clients.length) return false
saveClients(filtered)
clearManagedHashes(uuid)
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
return true
}
@@ -137,7 +240,7 @@ export function getClient(uuid: string): SavedClient | undefined {
return loadClients().find(c => c.uuid === uuid)
}
export function updateClient(uuid: string, name: string, type: string, config: Record<string, string | number>): SavedClient | null {
export function updateClient(uuid: string, name: string, type: string, config: ClientConfig): SavedClient | null {
const clients = loadClients()
const index = clients.findIndex(c => c.uuid === uuid)
if (index === -1) return null
@@ -243,6 +346,10 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
}
try {
await driver.add(client.config, url, options)
if (options?.infohash) {
trackManagedHash(client.uuid, options.infohash)
logger.debug('torrent-clients', `Tâche ${options.infohash.slice(0, 8)}… suivie pour "${client.name}"`)
}
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
results.push({ uuid: client.uuid, name: client.name, ok: true })
} catch (err) {
@@ -266,6 +373,7 @@ export async function dispatchRemove(hash: string, deleteFiles = false): Promise
try {
await driver.remove(client.config, hash, deleteFiles)
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
untrackManagedHash(client.uuid, hash)
found = true
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
@@ -328,6 +436,14 @@ export async function dispatchList(
}
}
// Filtre par hashes gérés pour les clients sans catégories natives.
// Si le Set de tâches suivies est non-vide et que ce hash n'y figure pas,
// on ignore ce torrent (il n'a pas été ajouté via FanKarr).
if (driver.definition.filterByManagedHashes) {
const managed = getManagedSet(client.uuid)
if (managed && !managed.has(hash)) continue
}
results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
}
} catch (err) {
+24 -22
View File
@@ -3,7 +3,8 @@
* Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication)
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
// qBittorrent répond normalement "Ok." mais certains setups (reverse proxy, etc.)
@@ -27,7 +28,7 @@ function mapState(state: string): TorrentInfo['state'] {
}
// Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID
async function qbAuth(config: Record<string, string | number>): Promise<Record<string, string>> {
async function qbAuth(config: ClientConfig): Promise<Record<string, string>> {
if (config.apiKey) {
return { Authorization: `Bearer ${config.apiKey}` }
}
@@ -35,12 +36,12 @@ async function qbAuth(config: Record<string, string | number>): Promise<Record<s
return { Cookie: cookie }
}
async function qbLogin(config: Record<string, string | number>): Promise<string> {
async function qbLogin(config: ClientConfig): Promise<string> {
const form = new URLSearchParams()
form.append('username', String(config.username ?? ''))
form.append('password', String(config.password ?? ''))
const res = await fetch(`${config.url}/api/v2/auth/login`, {
const res = await clientFetch(config, `${config.url}/api/v2/auth/login`, {
method : 'POST',
body : form,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -55,9 +56,9 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
return cookie
}
async function qbTorrentExists(config: Record<string, string | number>, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
async function qbTorrentExists(config: ClientConfig, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
try {
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, {
const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?hashes=${hash}`, {
headers: authHeaders,
})
if (!res.ok) return false
@@ -73,7 +74,7 @@ async function qbTorrentExists(config: Record<string, string | number>, authHead
* puis applique les priorités (désactive tout sauf la cible).
*/
async function qbApplyFilePriority(
config : Record<string, string | number>,
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
@@ -82,7 +83,7 @@ async function qbApplyFilePriority(
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}`, {
const res = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: authH,
})
if (!res.ok) continue
@@ -105,13 +106,13 @@ async function qbApplyFilePriority(
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 })
const r = await clientFetch(config, `${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`, {
const r2 = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, {
method: 'POST',
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
headers,
@@ -119,7 +120,7 @@ async function qbApplyFilePriority(
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 })
const verif = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
if (verif.ok) {
const verifiedFiles: any[] = await verif.json()
const target = verifiedFiles[fileIndex]
@@ -152,13 +153,14 @@ const QB: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
async test(config) {
try {
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/app/version`, { headers: authH })
const res = await clientFetch(config, `${config.url}/api/v2/app/version`, { headers: authH })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
@@ -172,7 +174,7 @@ const QB: TorrentClientDriver = {
async healthcheck(config) {
try {
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/app/version`, {
const res = await clientFetch(config, `${config.url}/api/v2/app/version`, {
headers: authH,
})
if (!res.ok) return { online: false }
@@ -190,7 +192,7 @@ const QB: TorrentClientDriver = {
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}`, {
const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?${params}`, {
headers: authH,
})
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
@@ -200,7 +202,7 @@ const QB: TorrentClientDriver = {
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}`, {
const r = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
headers: authH,
})
if (r.ok) fileMap.set(t.hash, await r.json())
@@ -237,7 +239,7 @@ const QB: TorrentClientDriver = {
async getFiles(config, hash) {
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
const res = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: authH,
})
if (!res.ok) return []
@@ -275,7 +277,7 @@ const QB: TorrentClientDriver = {
let knownHashes: Set<string> = new Set()
if (!hash) {
try {
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: authH })
const lr = await clientFetch(config, `${config.url}/api/v2/torrents/info`, { headers: authH })
if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
} catch {}
}
@@ -292,7 +294,7 @@ const QB: TorrentClientDriver = {
params.set('urls', url)
if (config.category) params.set('category', String(config.category))
if (config.savePath) params.set('savepath', String(config.savePath))
res = await fetch(`${config.url}/api/v2/torrents/add`, {
res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, {
method: 'POST', body: params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
@@ -306,7 +308,7 @@ const QB: TorrentClientDriver = {
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent')
res = await fetch(`${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
}
const text = await res.text()
if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`)
@@ -325,7 +327,7 @@ const QB: TorrentClientDriver = {
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 })
const lr = await clientFetch(config, `${config.url}/api/v2/torrents/info`, { headers: ah })
if (!lr.ok) continue
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
if (!newT) continue
@@ -347,7 +349,7 @@ const QB: TorrentClientDriver = {
if (config.category) params.set('category', String(config.category))
if (config.savePath) params.set('savepath', String(config.savePath))
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
const res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, {
method: 'POST', body: params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
@@ -359,7 +361,7 @@ const QB: TorrentClientDriver = {
async remove(config, hash, deleteFiles = false) {
const authH = await qbAuth(config)
const params = new URLSearchParams({ hashes: hash, deleteFiles: deleteFiles ? 'true' : 'false' })
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
const res = await clientFetch(config, `${config.url}/api/v2/torrents/delete`, {
method : 'POST',
body : params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
+3 -3
View File
@@ -13,12 +13,12 @@
* (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 type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
import { logger } from '../logger.js'
const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
function rdHeaders(config: Record<string, string | number>) {
function rdHeaders(config: ClientConfig) {
return {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
@@ -26,7 +26,7 @@ function rdHeaders(config: Record<string, string | number>) {
}
async function rdFetch(
config : Record<string, string | number>,
config : ClientConfig,
method : 'GET' | 'POST' | 'DELETE',
path : string,
body ?: URLSearchParams,
+8 -6
View File
@@ -5,7 +5,8 @@
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
// ─── XML-RPC helpers ──────────────────────────────────────────
@@ -55,7 +56,7 @@ function parseXmlValue(node: Element): any {
}
async function rpcCall(
config : Record<string, string | number>,
config : ClientConfig,
method : string,
params : any[] = [],
): Promise<any> {
@@ -69,7 +70,7 @@ async function rpcCall(
const rpcPath = String(config.rpcPath || '/RPC2')
const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}`
const res = await fetch(url, { method: 'POST', headers, body })
const res = await clientFetch(config, url, { method: 'POST', headers, body })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const text = await res.text()
@@ -109,7 +110,7 @@ function parseInnerValue(inner: string): any {
// multicall pour récupérer plusieurs champs en une requête
async function d_multicall(
config : Record<string, string | number>,
config : ClientConfig,
view : string,
methods: string[],
): Promise<any[][]> {
@@ -118,7 +119,7 @@ async function d_multicall(
return Array.isArray(result) ? result : []
}
async function rtTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
async function rtTorrentExists(config: ClientConfig, hash: string): Promise<boolean> {
try {
const rows = await d_multicall(config, 'main', ['d.hash'])
return rows.some(r => String(r[0]).toLowerCase() === hash.toLowerCase())
@@ -134,7 +135,7 @@ async function rtTorrentExists(config: Record<string, string | number>, hash: st
* 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>,
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
@@ -192,6 +193,7 @@ const RT: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
+266 -78
View File
@@ -2,22 +2,89 @@
* Synology Download Station Driver
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
import fs from 'fs'
import path from 'path'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
// ─── Cache URI → infohash ──────────────────────────────────────────────────────
// Synology n'expose pas toujours le hash BT via l'API (ancien DSM).
// On persiste l'association URI → infohash au moment du add() pour la réutiliser
// dans list() via le champ additional.detail.uri de chaque tâche.
const URI_HASH_PATH = path.join(DATA_DIR, 'synology_uri_hash.json')
// ─── Cache hash BT → ID Synology (en mémoire) ─────────────────────────────────
// Synology utilise des IDs internes (dbid_X), pas des infohash BT.
// Quand list() réussit à résoudre le vrai hash BT, on mémorise l'association
// infohash → dbid_X pour que remove() / getFiles() puissent retrouver la tâche
// même quand detail.hash et detail.uri sont vides.
const _hashToSynoId = new Map<string, string>()
function loadUriHashMap(): Record<string, string> {
try {
if (fs.existsSync(URI_HASH_PATH)) return JSON.parse(fs.readFileSync(URI_HASH_PATH, 'utf-8'))
} catch {}
return {}
}
const _uriToHash: Map<string, string> = (() => {
const m = new Map<string, string>()
for (const [k, v] of Object.entries(loadUriHashMap())) m.set(k, v)
return m
})()
function storeUriHash(uri: string, hash: string) {
const key = uri.toLowerCase()
if (_uriToHash.get(key) === hash) return // déjà connu, pas besoin d'écrire
_uriToHash.set(key, hash)
try {
const map = loadUriHashMap()
map[key] = hash
fs.mkdirSync(path.dirname(URI_HASH_PATH), { recursive: true })
fs.writeFileSync(URI_HASH_PATH, JSON.stringify(map, null, 2), 'utf-8')
} catch {}
}
function resolveHash(detailHash: string, detailUri: string, taskId: string): string {
// 1. Hash direct depuis l'API Synology (DSM récent)
if (detailHash.length > 0) return detailHash.toLowerCase()
// 2. Magnet URI dans detail.uri → extraction directe du hash (hex 40 chars)
if (detailUri.startsWith('magnet:')) {
const m = detailUri.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
if (m) return m[1].toLowerCase()
}
// 3. Notre cache URI → infohash (stocké au moment du add())
if (detailUri) {
const cached = _uriToHash.get(detailUri.toLowerCase())
if (cached) return cached
}
// 4. Fallback : ID Synology natif (déjà au format "dbid_X")
logger.debug('synology-ds', `Hash BT non résolu — fallback ID Synology : ${taskId}`)
return String(taskId ?? '').toLowerCase()
}
// ─── State mapping ─────────────────────────────────────────────────────────────
// Synology Download Station task status codes
function mapState(status: string): TorrentInfo['state'] {
if (status === 'downloading') return 'downloading'
if (status === 'seeding' || status === 'finished') return 'seeding'
if (status === 'paused') return 'paused'
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
if (status === 'hash_checking') return 'checking'
if (status === 'error') return 'error'
if (status === 'downloading') return 'downloading'
if (status === 'seeding' || status === 'finished') return 'seeding'
if (status === 'finishing' || status === 'extracting') return 'seeding'
if (status === 'paused') return 'paused'
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
if (status === 'hash_checking') return 'checking'
if (status === 'error') return 'error'
return 'unknown'
}
// Authentification DSM — retourne un SID
async function dsLogin(config: Record<string, string | number>): Promise<string> {
// ─── Auth ──────────────────────────────────────────────────────────────────────
async function dsLogin(config: ClientConfig): Promise<string> {
const params = new URLSearchParams({
api : 'SYNO.API.Auth',
version: '3',
@@ -27,8 +94,7 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
session: 'DownloadStation',
format : 'sid',
})
const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`)
const res = await clientFetch(config, `${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)}`)
@@ -36,7 +102,7 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
}
async function dsRequest(
config : Record<string, string | number>,
config : ClientConfig,
api : string,
method : string,
version: string,
@@ -44,25 +110,29 @@ async function dsRequest(
sid : string,
): Promise<any> {
const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra })
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
const res = await clientFetch(config, `${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
}
// ─── Driver ────────────────────────────────────────────────────────────────────
const DS: TorrentClientDriver = {
definition: {
id : 'synology-ds',
label : 'Synology Download Station',
fields: [
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
id : 'synology-ds',
label : 'Synology Download Station',
filterByManagedHashes: true,
fields : [
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -87,15 +157,13 @@ const DS: TorrentClientDriver = {
method : 'getinfo',
_sid : sid,
})
const res = await fetch(`${config.url}/webapi/DownloadStation/info.cgi?${params}`)
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/info.cgi?${params}`)
if (!res.ok) return { online: false }
const data = await res.json()
if (!data.success) return { online: false }
const version = data.data?.version_string ?? 'inconnue'
logger.debug('synology-ds', `Healthcheck OK — version ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('synology-ds', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
} catch {
return { online: false }
}
},
@@ -103,76 +171,196 @@ const DS: TorrentClientDriver = {
async list(config, category) {
const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
{ additional: 'transfer,detail' }, sid)
{ additional: 'transfer,detail,file' }, sid)
const tasks: any[] = data?.tasks ?? []
return tasks
.map(t => {
const transfer = t.additional?.transfer ?? {}
const size = t.size ?? 0
const dl = transfer.size_downloaded ?? 0
return {
hash : t.id,
name : t.title,
state : mapState(t.status),
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
size,
downloaded: dl,
uploaded : transfer.size_uploaded ?? 0,
ratio : size > 0 ? Math.round(((transfer.size_uploaded ?? 0) / size) * 100) / 100 : 0,
speed : transfer.speed_download ?? 0,
upspeed : transfer.speed_upload ?? 0,
eta : -1,
save_path : t.additional?.detail?.destination ?? '',
category : category ?? '',
} satisfies TorrentInfo
// Filtrage par dossier cible — Synology n'a pas de système de labels/catégories.
// Si savePath est configuré, on ne garde que les tâches dont la destination correspond.
const normalizedSavePath = String(config.savePath ?? '').replace(/\/+$/, '').toLowerCase()
const filtered = normalizedSavePath
? tasks.filter(t => {
const dest = String(t.additional?.detail?.destination ?? '').replace(/\/+$/, '').toLowerCase()
return dest === normalizedSavePath || dest.startsWith(normalizedSavePath + '/')
})
: tasks
return filtered.map(t => {
const transfer = t.additional?.transfer ?? {}
const detail = t.additional?.detail ?? {}
const fileList: any[] = t.additional?.file ?? []
const size = t.size ?? 0
const dl = transfer.size_downloaded ?? 0
const hash = resolveHash(
String(detail.hash ?? '').trim(),
String(detail.uri ?? '').trim(),
String(t.id ?? ''),
)
// Mémoriser l'association hash BT → ID Synology (pour remove/getFiles)
const synoIdStr = String(t.id ?? '').toLowerCase()
if (!hash.startsWith('dbid_') && synoIdStr) {
_hashToSynoId.set(hash, synoIdStr)
}
const files = fileList.length > 0
? fileList.map((f: any, i: number) => {
const fSize = typeof f.size === 'number' ? f.size : 0
const fDl = typeof f.size_downloaded === 'number' ? f.size_downloaded : 0
return {
index : i,
name : String(f.filename ?? ''),
progress: fSize > 0 ? fDl / fSize : 0,
priority: String(f.priority ?? 'normal') === 'skip' ? 0 : 1,
}
})
: undefined
return {
hash,
name : t.title,
state : mapState(t.status),
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
size,
downloaded: dl,
uploaded : transfer.size_uploaded ?? 0,
ratio : size > 0 ? Math.round(((transfer.size_uploaded ?? 0) / size) * 100) / 100 : 0,
speed : transfer.speed_download ?? 0,
upspeed : transfer.speed_upload ?? 0,
eta : -1,
save_path : detail.destination ?? '',
category : category ?? '',
files,
} satisfies TorrentInfo
})
},
async add(config, url, options?: DownloadOptions) {
if (options?.file_index != null) logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
if (options?.file_index != null)
logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
// Mémoriser l'association URI → infohash AVANT l'envoi (le hash est connu côté FanKarr)
if (options?.infohash) {
storeUriHash(url, options.infohash)
logger.debug('synology-ds', `URI→hash mémorisé : ${options.infohash.slice(0, 8)}… (${url.slice(-50)})`)
}
const sid = await dsLogin(config)
const extra: Record<string, string> = { uri: url }
if (config.savePath) extra.destination = String(config.savePath)
// Snapshot des IDs existants avant l'ajout — pour identifier la nouvelle tâche Synology
const beforeIds = new Set<string>()
try {
const before = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
for (const t of before?.tasks ?? []) beforeIds.add(String(t.id))
} catch {}
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'create',
_sid : sid,
...extra,
})
const doAdd = async (withDestination: boolean) => {
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'create',
_sid : sid,
uri : url,
})
if (withDestination && config.savePath) params.append('destination', String(config.savePath))
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
let data = await doAdd(true)
// Code 403 = dossier cible introuvable sur le NAS → réessai sans destination
if (!data.success && data.error?.code === 403 && config.savePath) {
logger.warn('synology-ds', `Dossier cible "${config.savePath}" introuvable (code 403) — ajout sans destination`)
data = await doAdd(false)
}
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
// Snapshot après ajout — identifier l'ID natif Synology (dbid_X) de la tâche créée
// et le stocker dans _hashToSynoId pour remove/getFiles sans attendre le prochain list()
if (options?.infohash) {
try {
const after = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
const newTask = (after?.tasks ?? []).find((t: any) => !beforeIds.has(String(t.id)))
if (newTask) {
const synoId = String(newTask.id).toLowerCase()
_hashToSynoId.set(options.infohash.toLowerCase(), synoId)
logger.debug('synology-ds', `ID Synology capturé : ${synoId} → ${options.infohash.slice(0, 8)}…`)
}
} catch {}
}
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
},
async remove(config, hash, deleteFiles = false) {
const sid = await dsLogin(config)
// Synology utilise son ID interne — on cherche via la liste
async getFiles(config, hash) {
const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
{ additional: 'detail,file' }, sid)
const tasks: any[] = data?.tasks ?? []
const h = hash.toLowerCase()
const synoIdFromHash = _hashToSynoId.get(h) ?? ''
const task = tasks.find((t: any) => {
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
const detailUri = String(t.additional?.detail?.uri ?? '').toLowerCase()
const synoId = String(t.id ?? '').toLowerCase()
const cached = _uriToHash.get(detailUri) ?? ''
return detailHash === h
|| synoId === h
|| cached === h
|| (synoIdFromHash && synoId === synoIdFromHash)
})
if (!task) return []
const fileList: any[] = task.additional?.file ?? []
return fileList.map((f: any, i: number) => {
const fSize = typeof f.size === 'number' ? f.size : 0
const fDl = typeof f.size_downloaded === 'number' ? f.size_downloaded : 0
return {
index : i,
name : String(f.filename ?? ''),
progress: fSize > 0 ? fDl / fSize : 0,
priority: String(f.priority ?? 'normal') === 'skip' ? 0 : 1,
}
})
},
async remove(config, hash, _deleteFiles?) {
const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid)
const tasks: any[] = data?.tasks ?? []
// Matcher par hash si résolu, sinon par id
const found = tasks.find(t => t.id?.toLowerCase() === hash.toLowerCase())
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 ? 'true' : 'false',
_sid : sid,
const h = hash.toLowerCase()
const synoIdFromHash = _hashToSynoId.get(h) ?? ''
const found = tasks.find((t: any) => {
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
const detailUri = String(t.additional?.detail?.uri ?? '').toLowerCase()
const synoId = String(t.id ?? '').toLowerCase()
const cachedHash = _uriToHash.get(detailUri) ?? ''
return detailHash === h
|| synoId === h
|| cachedHash === h
|| (synoIdFromHash && synoId === synoIdFromHash)
})
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version : '1',
method : 'delete',
id : found.id,
force_complete: 'false',
_sid : sid,
})
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
logger.info('synology-ds', `Torrent ${found.id} supprimé`)
},
}
export default DS
export default DS
+7 -5
View File
@@ -2,7 +2,8 @@
* Transmission Driver
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
function mapState(status: number): TorrentInfo['state'] {
@@ -17,7 +18,7 @@ function mapState(status: number): TorrentInfo['state'] {
}
async function trRequest(
config: Record<string, string | number>,
config: ClientConfig,
method: string,
args: Record<string, unknown> = {},
sessionId = ''
@@ -31,7 +32,7 @@ async function trRequest(
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
}
const res = await fetch(`${config.url}/transmission/rpc`, {
const res = await clientFetch(config, `${config.url}/transmission/rpc`, {
method : 'POST',
headers,
body : JSON.stringify({ method, arguments: args }),
@@ -52,7 +53,7 @@ async function trRequest(
return { ...data, sessionId }
}
async function trGetIdByHash(config: Record<string, string | number>, hash: string): Promise<number | null> {
async function trGetIdByHash(config: ClientConfig, hash: string): Promise<number | null> {
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
const torrents = data.arguments?.torrents ?? []
return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null
@@ -63,7 +64,7 @@ async function trGetIdByHash(config: Record<string, string | number>, hash: stri
* puis applique les priorités : fichier cible voulu, tous les autres ignorés.
*/
async function trApplyFilePriority(
config : Record<string, string | number>,
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
@@ -102,6 +103,7 @@ const TR: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
+12 -10
View File
@@ -2,7 +2,8 @@
* uTorrent Driver
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
// uTorrent status flags (bitmask) :
@@ -21,7 +22,7 @@ function mapState(status: number): TorrentInfo['state'] {
// uTorrent nécessite un token CSRF + cookie à chaque session
interface UTSession { token: string; cookie: string }
async function utTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
async function utTorrentExists(config: ClientConfig, hash: string): Promise<boolean> {
try {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getfiles', hash: hash.toUpperCase() }, session)
@@ -35,7 +36,7 @@ async function utTorrentExists(config: Record<string, string | number>, hash: st
* tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal).
*/
async function utApplyFilePriority(
config : Record<string, string | number>,
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
@@ -54,13 +55,13 @@ async function utApplyFilePriority(
// Passer tous les fichiers à priorité 0 (skip)
const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' })
for (let i = 0; i < files.length; i++) skipQs.append('f', String(i))
await fetch(`${config.url}/gui/?${skipQs}`, {
await clientFetch(config, `${config.url}/gui/?${skipQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
// Activer le fichier cible (priorité 2 = normal)
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
await fetch(`${config.url}/gui/?${selectQs}`, {
await clientFetch(config, `${config.url}/gui/?${selectQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
@@ -71,13 +72,13 @@ async function utApplyFilePriority(
throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`)
}
async function utGetSession(config: Record<string, string | number>): Promise<UTSession> {
async function utGetSession(config: ClientConfig): Promise<UTSession> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const headers : Record<string, string> = {
'Authorization': `Basic ${auth}`,
}
const res = await fetch(`${config.url}/gui/token.html`, { headers })
const res = await clientFetch(config, `${config.url}/gui/token.html`, { headers })
if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`)
const text = await res.text()
@@ -90,14 +91,14 @@ async function utGetSession(config: Record<string, string | number>): Promise<UT
}
async function utRequest(
config : Record<string, string | number>,
config : ClientConfig,
params : Record<string, string>,
session: UTSession,
): Promise<any> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const qs = new URLSearchParams({ token: session.token, ...params })
const res = await fetch(`${config.url}/gui/?${qs}`, {
const res = await clientFetch(config, `${config.url}/gui/?${qs}`, {
headers: {
'Authorization': `Basic ${auth}`,
'Cookie' : session.cookie,
@@ -142,6 +143,7 @@ const UT: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -213,7 +215,7 @@ const UT: TorrentClientDriver = {
const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url })
if (config.savePath) qs.set('path', String(config.savePath))
const res = await fetch(`${config.url}/gui/?${qs}`, {
const res = await clientFetch(config, `${config.url}/gui/?${qs}`, {
method : 'GET',
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="w-24 flex-shrink-0">
<img
v-if="ep.cover"
:src="imageUrl(ep.cover)"
:src="imageUrl(ep.cover) ?? undefined"
:alt="ep.name"
class="w-full aspect-video object-cover"
/>
+11 -30
View File
@@ -20,8 +20,10 @@
<!-- 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>
<p class="text-sm text-muted">{{ folderMissing ? 'Ce dossier n\'existe pas encore.' : 'Aucun fichier vidéo trouvé dans ce dossier.' }}</p>
<p class="text-xs text-muted">
{{ folderMissing ? 'Créez' : 'Placez vos fichiers dans' }} <span class="font-mono text-primary">{{ serieFolder }}</span>{{ folderMissing ? ', placez-y vos fichiers' : '' }} puis réessayez.
</p>
<button @click="scan" class="btn-secondary mt-2">Rescanner</button>
</div>
@@ -77,7 +79,7 @@
<!-- Sélecteur épisode -->
<select
v-model="items[i].episode_id"
v-model="item.episode_id"
class="settings-input text-xs py-1 max-w-[260px] shrink-0"
@change="onEpisodeChange(i)"
>
@@ -154,6 +156,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import { matchEpisodeFile } from '@/utils/episode-match'
const { add: toast } = useToast()
@@ -176,6 +179,7 @@ const scanning = ref(false)
const importing = ref(false)
const importError = ref('')
const scanError = ref('')
const folderMissing = ref(false)
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
// Dossier de la série dans la médiathèque
@@ -230,33 +234,8 @@ function buildOrganizedByPath(): Map<string, number> {
}
function autoMatch(filename: string): { episode_id: number | null; hash: string | null } {
const nameNoExt = filename.replace(/\.[^.]+$/, '').toLowerCase()
for (const season of props.seasons) {
for (const ep of season.episodes) {
const candidates = [
ep.original_filename,
ep.nfo_filename?.replace(/\.nfo$/, ''),
ep.formatted_name?.replace(/[<>:"/\\|?*]/g, '').trim(),
].filter(Boolean).map((s: string) => s.toLowerCase().replace(/\.[^.]+$/, ''))
if (candidates.some(c => c === nameNoExt || nameNoExt.startsWith(c + '.'))) {
return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
}
}
}
// Fallback numéro d'épisode
const numMatch = filename.match(/[.\s_-]0*(\d{1,3})[.\s_-]/i)
if (numMatch) {
const num = parseInt(numMatch[1], 10)
for (const season of props.seasons) {
const ep = season.episodes.find((e: any) => e.episode_number === num)
if (ep) return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
}
}
return { episode_id: null, hash: null }
const episodeId = matchEpisodeFile(filename, props.seasons)
return { episode_id: episodeId, hash: episodeId === null ? null : getHashForEpisode(episodeId) }
}
function formatSize(bytes: number): string {
@@ -268,6 +247,7 @@ function formatSize(bytes: number): string {
function onEpisodeChange(i: number) {
const item = items.value[i]
if (!item) return
const newEpId = item.episode_id // v-model a déjà mis à jour la valeur
item.hash = getHashForEpisode(Number(newEpId))
@@ -297,6 +277,7 @@ async function scan() {
return
}
const data = await res.json()
folderMissing.value = data.exists === false
const files: { name: string; path: string; size: number }[] = data.files ?? []
const organizedByPath = buildOrganizedByPath()
+2 -2
View File
@@ -19,7 +19,7 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const STORAGE_KEY = 'fankarr-theme'
@@ -32,7 +32,7 @@ const themes = [
const current = ref('indigo')
function setTheme(id) {
function setTheme(id: string) {
current.value = id
document.documentElement.setAttribute('data-theme', id)
localStorage.setItem(STORAGE_KEY, id)
+36 -30
View File
@@ -115,7 +115,7 @@
</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"
v-for="(t, i) in (season.torrents as any[])"
:key="i"
:title="t.raw ?? t.torrent_name ?? ''"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
@@ -231,7 +231,7 @@
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-72 shadow-xl flex flex-col gap-0.5">
<p class="px-3 pt-1.5 pb-0.5 text-[10px] text-muted font-medium uppercase tracking-wide">Choisir le torrent</p>
<button
v-for="(t, i) in ep.torrents"
v-for="(t, i) in (ep.torrents as any[])"
:key="i"
:title="t.raw ?? t.torrent_name ?? ''"
class="flex items-start gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
@@ -246,7 +246,7 @@
<div v-if="!requestMode && epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1"
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
<button
v-for="(t, i) in ep.torrents"
v-for="(t, i) in (ep.torrents as any[])"
:key="i"
:title="t.raw ?? t.torrent_name ?? ''"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
@@ -433,16 +433,6 @@ function isAlreadyQueued(torrent: any): boolean {
// ── 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}`)
@@ -540,7 +530,7 @@ function handleEpRequestClick(ep: any) {
return
}
// Torrent unique ou aucun → émettre directement
emit('requestEpisode', season.season_number, ep.id, ep.torrent ?? undefined)
emit('requestEpisode', props.season.season_number, ep.id, ep.torrent ?? undefined)
}
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
@@ -606,33 +596,49 @@ const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parse
*/
function _parseEpChap(plot: string | null | undefined): {
episodes: string | null
chapters: string | null
season: string | null
chapters: string |null
cleanPlot: string
} {
const text = plot ?? ''
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
const epMatch = text.match(
/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?(?:\s+de\s+la\s+saison\s+(\d+))?/i
)
const seasonMatch = text.match(/saison\s+(\d+)/i)
const season = epMatch?.[3] ?? seasonMatch?.[1] ?? null
const chapMatch = text.match(
/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i
)
const episodes = epMatch
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`)
: null
const chapters = chapMatch
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
: null
? `${season ? `S${season} · ` : ''}${
epMatch[2]
? `Ép. ${epMatch[1]}–${epMatch[2]}`
: `Ép. ${epMatch[1]}`
}`
: null
const chapters = chapMatch
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
: null
// Retirer les lignes contenant les références d'épisodes ou chapitres
let cleanPlot = text
if (episodes || chapters) {
if (episodes || chapters || season) {
cleanPlot = text
.split(/\r?\n/)
.filter(line => !/[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+/i.test(line))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
.split(/\r?\n/)
.filter(line => !/(?:[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+|saison\s+\d+)/i.test(line))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
return { episodes, chapters, cleanPlot }
return { episodes, season, chapters, cleanPlot }
}
function parseEpChap(plot: string | null | undefined) {
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
+16 -1
View File
@@ -75,6 +75,18 @@
</button>
</div>
</div>
<!-- Sélection multiple -->
<button
v-if="selectable"
@click="emit('update:selecting', !selecting)"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 shrink-0"
:class="selecting ? 'border-accent text-accent' : ''"
title="Sélectionner plusieurs séries"
>
<ListChecks :size="15" />
<span class="hidden md:inline text-xs">Sélection</span>
</button>
</div>
<!-- Ligne 2 : tri + taille + count -->
@@ -121,7 +133,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
import { Search, SlidersHorizontal, ChevronDown, ListChecks } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
defineProps<{
@@ -136,6 +148,8 @@ defineProps<{
filtersImport : { label: string; value: string; color: string }[]
sortOptions : { label: string; value: string }[]
legend : { label: string; color: string }[]
selectable? : boolean
selecting? : boolean
}>()
const emit = defineEmits<{
@@ -143,6 +157,7 @@ const emit = defineEmits<{
'update:activeFilter': [value: string]
'update:activeSort' : [value: string]
'update:posterSize' : [value: string]
'update:selecting' : [value: boolean]
}>()
const filterOpen = ref(false)
+1 -4
View File
@@ -11,11 +11,8 @@ interface Toast {
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)
@@ -44,7 +41,7 @@ function buildGroupMessage(type: Toast['type'], count: number): string {
function remove(id: number) {
const i = toasts.value.findIndex(t => t.id === id)
if (i !== -1) {
clearTimeout(toasts.value[i].timer)
clearTimeout(toasts.value[i]?.timer)
toasts.value.splice(i, 1)
}
}
+2 -2
View File
@@ -40,7 +40,7 @@ import { Menu } from 'lucide-vue-next'
import SidebarNav from '@/components/nav/SidebarNav.vue'
import { useDownloadsStore } from '@/stores/downloads'
import { useAuthStore } from '@/stores/auth'
import type { NavItem } from '@/types/nav'
import type { NavItem, NavChild } from '@/types/nav'
const mobileOpen = ref(false)
const dlStore = useDownloadsStore()
@@ -79,7 +79,7 @@ const navItems = computed<NavItem[]>(() => {
items.push({ separator: true })
const settingsChildren: NavItem[] = [
const settingsChildren: NavChild[] = [
{ label: 'Mon profil', to: '/settings/profile' },
]
if (auth.isAdmin) {
+20 -3
View File
@@ -2,10 +2,16 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface TorrentRef {
torrent_raw: string
torrent_url: string | null
magnet: string | null
type: string
raw?: string | null
infohash?: string | null
torrent_name?: string | null
file_index?: number | null
file_path?: string | null
fankai?: boolean
manual?: boolean
}
export interface Episode {
@@ -20,7 +26,10 @@ export interface Episode {
has_thumbnail: boolean
season_id: number
available: boolean
torrent: TorrentRef | null // non-null seulement si type=episode
organized: boolean
fankai: boolean | null
torrent: TorrentRef | null
torrents: TorrentRef[]
}
export interface Season {
@@ -33,6 +42,9 @@ export interface Season {
premiered: string | null
episodes: Episode[]
torrent: TorrentRef | null
torrents: TorrentRef[]
organized_state: 'none' | 'partial' | 'complete'
organized_count: number
}
export interface Serie {
@@ -46,12 +58,17 @@ export interface Serie {
status: string | null
torrent_count: number
has_torrents: boolean
download_state: 'none' | 'downloading' | 'partial' | 'complete'
in_client: boolean
has_files: boolean
rss_synced: boolean
}
export interface SerieDetail {
serie: Serie
seasons: Season[]
torrents_integrale: { raw: string; torrent_url: string | null; magnet: string | null }[]
scraper_synced: boolean
torrents_integrale: { label: string; raw: string; torrent_url: string | null; magnet: string | null; infohash: string | null; torrent_name: string | null }[]
}
export const useSeriesStore = defineStore('series', () => {
+46
View File
@@ -0,0 +1,46 @@
export interface MatchEpisode {
id : number
episode_number : number
original_filename?: string | null
nfo_filename? : string | null
formatted_name? : string | null
}
export interface MatchSeason {
season_number: number
episodes : MatchEpisode[]
}
// Associe un fichier à un épisode : nom exact, puis SxxEyy, puis numéro seul
export function matchEpisodeFile(filename: string, seasons: MatchSeason[]): number | null {
const nameNoExt = filename.replace(/\.[^.]+$/, '').toLowerCase()
for (const season of seasons) {
for (const ep of season.episodes) {
const candidates = [
ep.original_filename,
ep.nfo_filename?.replace(/\.nfo$/, ''),
ep.formatted_name?.replace(/[<>:"/\\|?*]/g, '').trim(),
].filter((s): s is string => !!s).map(s => s.toLowerCase().replace(/\.[^.]+$/, ''))
if (candidates.some(c => c === nameNoExt || nameNoExt.startsWith(c + '.'))) return ep.id
}
}
const se = filename.match(/S(\d{1,2})[\s._-]?E(\d{1,3})/i)
if (se) {
const ep = seasons.find(s => s.season_number === Number(se[1]))?.episodes.find(e => e.episode_number === Number(se[2]))
if (ep) return ep.id
}
const numMatch = filename.match(/[.\s_-]0*(\d{1,3})[.\s_-]/i)
if (numMatch) {
const num = Number(numMatch[1])
for (const season of seasons) {
const ep = season.episodes.find(e => e.episode_number === num)
if (ep) return ep.id
}
}
return null
}
+2 -2
View File
@@ -118,7 +118,7 @@
<div v-for="r in req.requesters" :key="r.userId"
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-shell border border-border text-xs">
<span class="w-4 h-4 rounded-full bg-border flex items-center justify-center text-[9px] font-semibold text-muted shrink-0">
{{ r.username[0].toUpperCase() }}
{{ r.username.charAt(0).toUpperCase() }}
</span>
<span class="text-muted">{{ r.username }}</span>
<span v-if="r.episodes?.length > 0" class="text-muted/60">
@@ -179,7 +179,7 @@ const filters = [
{ value: 'approved', label: 'Approuvées' },
{ value: 'completed', label: 'Disponibles' },
{ value: 'rejected', label: 'Refusées' },
]
] as const
const counts = computed(() => ({
all : requests.value.length,
+108 -30
View File
@@ -103,6 +103,34 @@
</div>
</div>
<!-- Série absente du scraper -->
<div v-if="auth.isAdmin && !data.scraper_synced" class="mx-4 md:mx-8 mt-4 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">Série absente du scraper</p>
<p class="text-xs text-muted">
Téléchargements indisponibles pour le moment. L'import manuel et le renommage s'appuient sur l'API Fankai en attendant la prochaine synchronisation.
</p>
</div>
</div>
<!-- Dossier série à renommer -->
<div v-if="auth.isAdmin && staleFolders.length > 0" class="mx-4 md:mx-8 mt-4 flex items-center justify-between gap-3 flex-wrap px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<div class="flex items-start gap-3 min-w-0">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5 min-w-0">
<p class="text-sm text-yellow-500 font-medium">Dossier différent du titre de la série</p>
<p class="text-xs text-muted break-all">
<span class="font-mono text-primary">{{ staleFolders.map(f => basename(f.path)).join(', ') }}</span>
→ <span class="font-mono text-primary">{{ folderTarget }}</span>
</p>
</div>
</div>
<button @click="renameFolder" :disabled="renamingFolder" class="btn-secondary text-xs shrink-0">
{{ renamingFolder ? 'Renommage…' : 'Renommer le dossier' }}
</button>
</div>
<!-- Barre utilisateur (mode demande) -->
<div v-if="!auth.isAdmin" class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
<div v-if="myRequest" class="flex items-center gap-3">
@@ -377,6 +405,12 @@ const downloadMenuOpen = ref(false)
const downloadingAll = ref(false)
const downloadingSeason = ref<Record<number, boolean>>({})
const rssSync = ref(false)
const folderStatus = ref<{ expected: string; current: { path: string; entries: number; exists: boolean }[] } | null>(null)
const renamingFolder = ref(false)
const basename = (p: string) => p.split(/[\\/]/).pop() ?? p
const staleFolders = computed(() => folderStatus.value?.current.filter(f => f.exists && f.path !== folderStatus.value?.expected) ?? [])
const folderTarget = computed(() => folderStatus.value ? basename(folderStatus.value.expected) : '')
export interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[]; save_path?: string; name?: string }
const activeTorrents = ref<ActiveTorrent[]>([])
@@ -405,6 +439,16 @@ function integraleGroupLabels(): string[] {
}
// Épisodes couverts par une intégrale donnée (hash commun)
// Épisodes déjà présents dans la médiathèque — sert de garde-fou pour ne pas
// redescendre une autre version (x264 / x265) de fichiers déjà importés.
const organizedEpisodeIds = computed(() => {
const ids = new Set<number>()
for (const season of data.value?.seasons ?? [])
for (const ep of season.episodes)
if (ep.organized) ids.add(ep.id)
return ids
})
function integraleEpisodeCoverage(integraleIndex: number): Set<number> {
const covered = new Set<number>()
const t = data.value?.torrents_integrale[integraleIndex]
@@ -463,6 +507,27 @@ async function fetchOrganized() {
const res = await fetch(`/api/organized/${route.params.id}`, { credentials: 'include' })
if (res.ok) organizedByEpisode.value = await res.json()
} catch {}
fetchFolderStatus()
}
async function fetchFolderStatus() {
try {
const res = await fetch(`/api/organized/${route.params.id}/folder`, { credentials: 'include' })
folderStatus.value = res.ok ? await res.json() : null
} catch {}
}
async function renameFolder() {
if (!confirm(`Déplacer le contenu vers « ${folderTarget.value} » ? Les fichiers existants ne sont jamais écrasés.`)) return
renamingFolder.value = true
try {
const res = await fetch(`/api/organized/${route.params.id}/folder`, { method: 'POST', credentials: 'include' })
const d = await res.json()
if (!res.ok) { toast(d.error ?? 'Erreur lors du renommage du dossier', 'error'); return }
toast(`Dossier renommé ✓ (${d.moved} fichier${d.moved > 1 ? 's' : ''} déplacé${d.moved > 1 ? 's' : ''})`, 'success')
await fetchOrganized()
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { renamingFolder.value = false }
}
async function fetchSettings() {
@@ -503,6 +568,8 @@ async function fetchActiveDownloads() {
const list: any[] = await res.json()
const isFirstPoll = _prevTorrentStates.size === 0
// Un seul import par torrent, même si plusieurs de ses fichiers viennent de se terminer
const toOrganize = new Map<string, any>()
for (const t of list) {
const prevState = _prevTorrentStates.get(t.hash)
@@ -515,7 +582,7 @@ async function fetchActiveDownloads() {
_prevFileProgresses.set(key, f.progress)
if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) {
_recentlyOrganized.add(key)
triggerOrganize(t)
toOrganize.set(t.hash, t)
}
}
}
@@ -523,12 +590,14 @@ async function fetchActiveDownloads() {
// 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)
toOrganize.set(t.hash, t)
}
_prevTorrentStates.set(t.hash, t.state)
}
for (const t of toOrganize.values()) triggerOrganize(t)
activeTorrents.value = list.map(t => ({
hash : t.hash,
progress : t.progress ?? 0,
@@ -582,6 +651,12 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
const result: DlItem[] = []
const covered = new Set<number>()
// Une autre version déjà envoyée couvre déjà ses épisodes
data.value.torrents_integrale.forEach((t: any, i: number) => {
if (isAlreadyQueued(t) || isDownloaded(`integrale-${i}`))
for (const id of integraleEpisodeCoverage(i)) covered.add(id)
})
// ── 1. Intégrale(s) ───────────────────────────────────────────
const integrales: { t: any; i: number }[] = integraleIndex !== undefined
? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }]
@@ -589,15 +664,23 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
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
// Épisodes apportés par CETTE intégrale
const hash = t.infohash?.toLowerCase() ?? extractHash(t)
const coveredByThis = new Set<number>()
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)
coveredByThis.add(ep.id)
const bringsSomething = coveredByThis.size === 0
|| [...coveredByThis].some(id => !covered.has(id) && !organizedEpisodeIds.value.has(id))
if (bringsSomething && !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet })
for (const id of coveredByThis) covered.add(id)
}
// ── 2. Packs saison non couverts ──────────────────────────────
@@ -606,12 +689,11 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
// 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)
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
const alreadyHandled = season.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
const t = season.torrents[0]
if (!alreadyHandled && t) result.push({ key: packKey(0), torrent_url: t.torrent_url, magnet: t.magnet })
for (const ep of season.episodes) covered.add(ep.id)
}
// ── 3. Épisodes individuels non couverts ─────────────────────
@@ -625,17 +707,9 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
}
// 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
})
// Dérivé de collectDownloadables pour que le bouton n'apparaisse jamais alors
// que le garde-fou versions ne laisserait passer aucun torrent.
const hasSomethingToDownload = computed(() => collectDownloadables().length > 0)
async function downloadAll(integraleIndex?: number) {
downloadingAll.value = true
@@ -651,15 +725,19 @@ async function downloadAll(integraleIndex?: number) {
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 torrents: DlItem[] = []
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 })
}
})
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
const alreadyHandled = seasonAny.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
if (!alreadyHandled) {
const chosen = packHash
? seasonAny.torrents.findIndex((t: any) => (t.infohash ?? extractHash(t)) === packHash)
: 0
const i = chosen >= 0 ? chosen : 0
const t = seasonAny.torrents[i]
torrents.push({ key: packKey(i), 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
+102 -9
View File
@@ -13,6 +13,8 @@
:filters-import="filtersImport"
:sort-options="sortOptions"
:legend="legend"
:selectable="auth.isAdmin"
v-model:selecting="selecting"
/>
<!-- Grille -->
@@ -33,14 +35,22 @@
</div>
<div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }">
<RouterLink
<component
:is="selecting ? 'div' : RouterLink"
v-for="serie in filtered"
:key="serie.id"
:to="`/series/${serie.id}`"
v-bind="selecting ? {} : { 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' }"
:class="{
'opacity-40': !serie.has_torrents && serie.download_state === 'none' && !selected.has(serie.id),
'cursor-pointer select-none': selecting,
}"
@click="selecting && toggleSelected(serie.id)"
>
<div class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border border-border">
<div
class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border"
:class="selected.has(serie.id) ? 'border-accent ring-2 ring-accent' : 'border-border'"
>
<img
v-if="serie.poster_image"
:src="serie.poster_image"
@@ -52,10 +62,22 @@
<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">
<div v-if="!selecting" 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
v-if="selecting"
class="absolute top-2 left-2 w-5 h-5 rounded-md border flex items-center justify-center"
:class="selected.has(serie.id) ? 'bg-accent border-accent text-white' : 'bg-black/40 border-white/60'"
>
<Check v-if="selected.has(serie.id)" :size="13" />
</div>
<div v-if="auth.isAdmin && serie.rss_synced" class="absolute top-2 right-2 p-1 rounded-md bg-black/60 text-green-400" title="Surveillée">
<Rss :size="11" />
</div>
<div
class="absolute bottom-0 left-0 right-0 h-1"
:style="{ background: stateColor(serie.download_state) }"
@@ -77,7 +99,20 @@
</span>
</div>
</div>
</RouterLink>
</component>
</div>
</div>
<!-- Actions de masse -->
<div v-if="selecting" class="sticky bottom-0 z-10 px-4 md:px-6 py-3 border-t border-border bg-shell flex items-center gap-3 flex-wrap">
<span class="text-xs text-primary font-medium">{{ selected.size }} sélectionnée{{ selected.size > 1 ? 's' : '' }}</span>
<button @click="selectAllFiltered" class="text-xs text-accent hover:underline">Tout sélectionner ({{ filtered.length }})</button>
<button v-if="selected.size > 0" @click="clearSelection" class="text-xs text-muted hover:text-primary">Désélectionner</button>
<div class="flex items-center gap-2 ml-auto">
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Renommer</button>
<button @click="bulkSync(true)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Surveiller</button>
<button @click="bulkSync(false)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Ne plus surveiller</button>
<button @click="selecting = false" class="btn-secondary" title="Quitter la sélection"><X :size="14" /></button>
</div>
</div>
@@ -85,10 +120,10 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, onActivated } from 'vue'
import { RouterLink } from 'vue-router'
import { Tv } from 'lucide-vue-next'
import { useSeriesStore } from '@/stores/series'
import { Tv, Check, Rss, X } from 'lucide-vue-next'
import { useSeriesStore, type Serie } from '@/stores/series'
import { useDownloadsStore } from '@/stores/downloads'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
@@ -117,6 +152,7 @@ const filtersImport = [
{ label: 'Importés', value: 'complete', color: '#22c55e' },
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
{ label: 'Partiel', value: 'partial', color: '#9b59b6' },
{ label: 'Non suivies', value: 'untracked', color: 'var(--border)' },
]
const sortOptions = [
@@ -143,6 +179,7 @@ const filtered = computed(() => {
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 (activeFilter.value === 'untracked') list = list.filter(s => !s.in_client && !s.has_files)
if (search.value.trim()) {
const q = search.value.toLowerCase()
@@ -163,6 +200,62 @@ const filtered = computed(() => {
return list
})
// ── Sélection multiple ───────────────────────────────────────
const selecting = ref(false)
const selected = ref<Set<number>>(new Set())
const bulkBusy = ref(false)
watch(selecting, on => { if (!on) clearSelection() })
function toggleSelected(id: number) {
const next = new Set(selected.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selected.value = next
}
function selectAllFiltered() { selected.value = new Set(filtered.value.map(s => s.id)) }
function clearSelection() { selected.value = new Set() }
async function bulkRename() {
bulkBusy.value = true
try {
const res = await fetch('/api/rename-all', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ serie_ids: [...selected.value] }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors du renommage', 'error'); return }
const errs = data.errors?.length ?? 0
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} renommé${data.done > 1 ? 's' : ''}${errs ? ` · ${errs} erreur${errs > 1 ? 's' : ''}` : ''}`, errs ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
bulkBusy.value = false
}
}
async function bulkSync(enabled: boolean) {
bulkBusy.value = true
try {
const targets = store.series.filter((s: Serie) => selected.value.has(s.id))
const res = await fetch('/api/rss-sync/bulk', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ enabled, series: targets.map((s: Serie) => ({ id: s.id, name: s.title })) }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors de la mise à jour de la surveillance', 'error'); return }
for (const s of targets) s.rss_synced = enabled
const n = data.changed
toast(enabled
? `${n} série${n > 1 ? 's' : ''} ajoutée${n > 1 ? 's' : ''} à la surveillance`
: `${n} série${n > 1 ? 's' : ''} retirée${n > 1 ? 's' : ''} de la surveillance`, 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
bulkBusy.value = false
}
}
function stateColor(state: string): string {
return ({
complete : '#22c55e',
+44
View File
@@ -48,6 +48,29 @@
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
</div>
<!-- Surveillances orphelines -->
<div v-if="syncOrphans.length > 0" class="settings-card flex flex-col gap-2">
<div>
<p class="text-sm font-medium text-primary">Surveillances orphelines ({{ syncOrphans.length }})</p>
<p class="text-xs text-muted mt-0.5">
Séries surveillées qui n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom).
Réactivez la surveillance sur la nouvelle fiche si besoin.
</p>
</div>
<div class="flex flex-col divide-y divide-border/50 -mx-4 px-4">
<div v-for="s in syncOrphans" :key="s.serieId" class="flex items-center gap-3 py-2">
<span class="flex-1 min-w-0 text-sm text-primary truncate">{{ s.serieName }}</span>
<span class="text-xs text-muted font-mono shrink-0">#{{ s.serieId }}</span>
<button
@click="removeSyncOrphan(s.serieId)"
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors shrink-0"
>
Retirer
</button>
</div>
</div>
</div>
</template>
</div>
@@ -62,13 +85,33 @@ const { add: toast } = useToast()
const updating = ref(false)
const loaded = ref(false)
const status = ref({ exists: false, count: 0, empty: true })
const syncOrphans = ref<{ serieId: number; serieName: string }[]>([])
onMounted(async () => {
fetchSyncOrphans()
const res = await fetch('/api/torrents/status', { credentials: 'include' })
if (res.ok) status.value = await res.json()
loaded.value = true
})
async function fetchSyncOrphans() {
try {
const res = await fetch('/api/rss-sync/orphans', { credentials: 'include' })
if (res.ok) syncOrphans.value = (await res.json()).orphans ?? []
} catch {}
}
async function removeSyncOrphan(serieId: number) {
try {
const res = await fetch(`/api/rss-sync/${serieId}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { toast('Erreur lors du retrait de la surveillance', 'error'); return }
syncOrphans.value = syncOrphans.value.filter(s => s.serieId !== serieId)
toast('Surveillance retirée ✓', 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
}
}
async function update() {
updating.value = true
try {
@@ -77,6 +120,7 @@ async function update() {
const { count } = await res.json()
status.value = { exists: true, count, empty: count === 0 }
toast(`${count} séries chargées ✓`, 'success')
fetchSyncOrphans()
} else {
const { error } = await res.json()
toast(error ?? 'Erreur lors de la mise à jour', 'error')
+57 -35
View File
@@ -138,16 +138,25 @@
<!-- 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"
<SettingsToggle
v-if="field.type === 'boolean'"
:model-value="toBool(modal.config[field.key])"
:label="field.label"
:description="fieldTooltip(field.key)"
@update:model-value="modal.config[field.key] = $event"
/>
<template v-else>
<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"
/>
</template>
</div>
<!-- Paramètres avancés -->
@@ -167,24 +176,33 @@
<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"
<SettingsToggle
v-if="field.type === 'boolean'"
:model-value="toBool(modal.config[field.key])"
:label="field.label"
:description="fieldTooltip(field.key)"
@update:model-value="modal.config[field.key] = $event"
/>
<template v-else>
<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="field.placeholder ?? ''"
class="settings-input"
/>
</template>
</div>
</div>
</div>
@@ -222,6 +240,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { ChevronDown } from 'lucide-vue-next'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
@@ -254,11 +273,7 @@ const clientMatrix = [
{
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
},
{
id: 'alldebrid', label: 'AllDebrid', version: 'API v4',
auth: 'Clé API', fileSelect: false, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
},
}
]
const modal = ref({
@@ -266,24 +281,30 @@ const modal = ref({
uuid : null as string | null,
name : '',
type : '',
config : {} as Record<string, string>,
config : {} as Record<string, string | number | boolean>,
testing: false,
saving : false,
tested : false,
})
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath']
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath', 'ignoreCertificateErrors']
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.',
ignoreCertificateErrors: 'Accepte les certificats auto-signés ou invalides en HTTPS. À n\'utiliser que sur un réseau de confiance.',
}
function fieldTooltip(key: string): string {
return FIELD_TOOLTIPS[key] ?? ''
}
// Tolère la forme string "true" en plus du booléen natif
function toBool(v: unknown): boolean {
return v === true || v === 'true'
}
const advancedOpen = ref(false)
const currentDefinition = computed(() =>
@@ -367,7 +388,8 @@ function onTypeChange() {
const def = currentDefinition.value
if (def) {
for (const field of def.fields) {
if (field.default !== undefined) modal.value.config[field.key] = String(field.default)
if (field.default === undefined) continue
modal.value.config[field.key] = field.type === 'boolean' ? field.default : String(field.default)
}
}
}
@@ -385,7 +407,7 @@ watch(() => modal.value.config, (newConfig) => {
// 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)
const field = driver.fields.find((f: any) => f.key === key)
if (!field) continue
if (field.type === 'password' && val === '••••••••') continue
modal.value.tested = false
@@ -22,6 +22,14 @@
</svg>
Actualiser
</button>
<button
@click="forceMetadataRefresh"
:disabled="forcingRefresh"
class="btn-secondary"
title="Recharge les correspondances de renommage depuis le scraper Fankai en ignorant tous les caches, migre les IDs d'épisodes, puis renomme les fichiers importés"
>
{{ forcingRefresh ? 'Rechargement…' : 'Forcer la MAJ des correspondances' }}
</button>
<button
@click="purgeNfo"
:disabled="purgingNfo"
@@ -164,6 +172,35 @@
</div>
</div>
<!-- Entrées orphelines -->
<div v-if="!loading && orphans.length > 0" class="settings-card flex flex-col gap-2">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-primary">Entrées orphelines ({{ orphans.length }})</p>
<p class="text-xs text-muted mt-0.5">Épisodes suivis qui n'existent plus dans le catalogue. Les retirer du suivi ne supprime aucun fichier.</p>
</div>
<button @click="removeOrphans()" :disabled="removingOrphans" class="btn-secondary text-xs shrink-0">
{{ removingOrphans ? '…' : 'Tout retirer' }}
</button>
</div>
<div class="flex flex-col divide-y divide-border/50 -mx-4 px-4">
<div v-for="o in orphans" :key="`${o.hash}:${o.episode_id}`" class="flex items-center gap-3 py-2">
<span class="text-xs text-muted font-mono shrink-0 w-14">
{{ o.season != null && o.episode != null ? `S${pad(o.season)}E${pad(o.episode)}` : `#${o.episode_id}` }}
</span>
<span class="flex-1 min-w-0 text-[11px] font-mono text-muted truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? '—' }}</span>
<span v-if="!o.file_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20 shrink-0">Introuvable</span>
<button
@click="removeOrphans([o.episode_id])"
:disabled="removingOrphans"
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors shrink-0"
>
Retirer
</button>
</div>
</div>
</div>
</div>
</template>
@@ -177,12 +214,17 @@ const { add: toast } = useToast()
const loading = ref(true)
const renamingAll = ref(false)
const purgingNfo = ref(false)
const forcingRefresh = 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)
const orphans = ref<{ hash: string; episode_id: number; season: number | null; episode: number | null; dest_path: string | null; file_exists: boolean }[]>([])
const removingOrphans = ref(false)
const pad = (n: number) => String(n).padStart(2, '0')
// ─── Computed ──────────────────────────────────────────────────
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
@@ -200,6 +242,7 @@ async function load() {
const data = await res.json()
series.value = data.series ?? []
nfoSupport.value = data.nfo_support ?? false
orphans.value = data.orphans ?? []
// 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 {
@@ -254,6 +297,30 @@ async function renameSingle(ep: any, serie: any) {
}
}
async function forceMetadataRefresh() {
forcingRefresh.value = true
try {
const res = await fetch('/api/update', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ force: true }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors du rechargement', 'error'); return }
const parts = [`${data.count} séries rechargées`]
if (data.migrated > 0) parts.push(`${data.migrated} ID(s) migré(s)`)
if (data.deduped > 0) parts.push(`${data.deduped} doublon(s) retiré(s)`)
parts.push(`${data.renamed} fichier${data.renamed > 1 ? 's' : ''} renommé${data.renamed > 1 ? 's' : ''}`)
toast(parts.join(' · ') + ' ✓', data.errors > 0 ? 'error' : 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
forcingRefresh.value = 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
@@ -307,5 +374,26 @@ async function unimportEp(ep: any, deleteFile: boolean) {
}
}
async function removeOrphans(episodeIds?: number[]) {
if (!episodeIds && !confirm(`Retirer ${orphans.value.length} entrée(s) orpheline(s) du suivi ? Aucun fichier n'est supprimé.`)) return
removingOrphans.value = true
try {
const res = await fetch('/api/organized-summary/orphans', {
method : 'DELETE',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify(episodeIds ? { episode_ids: episodeIds } : {}),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
toast(`${data.removed} entrée${data.removed > 1 ? 's' : ''} retirée${data.removed > 1 ? 's' : ''} du suivi ✓`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
removingOrphans.value = false
}
}
onMounted(load)
</script>
-10
View File
@@ -103,7 +103,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { copyToClipboard } from '@/composables/useClipboard'
const jellyfinUrl = ref('')
const jellyfinAdminToken = ref('')
@@ -116,8 +115,6 @@ const syncing = ref(false)
const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null)
const syncError = ref<string | null>(null)
const origin = window.location.origin
const apiEndpoints = [
{ method: 'POST', path: '/api/v1/auth/jellyfin', desc: 'Échanger un token Jellyfin contre un token FanKarr' },
{ method: 'GET', path: '/api/v1/auth/me', desc: 'Infos de l\'utilisateur authentifié' },
@@ -179,13 +176,6 @@ async function syncUsers() {
syncing.value = false
}
async function copyToken() {
try {
await copyToClipboard(myToken.value)
tokenCopied.value = true
setTimeout(() => { tokenCopied.value = false }, 2000)
} catch {}
}
</script>
+9 -3
View File
@@ -90,7 +90,7 @@
: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.)."
description="Télécharge les NFO et images depuis GitLab lors de l'import (Infuse, etc.)."
/>
<SettingsToggle
v-if="form.organizeMode === 'move'"
@@ -103,6 +103,11 @@
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."
/>
<SettingsToggle
v-model="form.englishDirectory"
label="Utilise 'Season' au lieu de 'Saison' dans les dossiers"
description="Si activé, les dossiers de saisons seront nommés 'Season 01' au lieu de 'Saison 01'."
/>
</div>
<!-- Plex -->
@@ -159,12 +164,12 @@
<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>,
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex, Emby ou Kodi</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>
Activez cette option uniquement si vous utilisez <span class="text-primary">Infuse</span>
ou un lecteur qui lit les fichiers NFO locaux.
</p>
</div>
@@ -215,6 +220,7 @@ const form = ref({
autoImport : true,
deleteTorrentOnMove : false,
autoUnimportMissing : false,
englishDirectory : false,
})
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
+2 -2
View File
@@ -25,7 +25,7 @@
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
>
{{ u.username[0].toUpperCase() }}
{{ u.username.charAt(0).toUpperCase() }}
</span>
<div>
<p class="text-sm text-primary font-medium">{{ u.username }}</p>
@@ -137,7 +137,7 @@
<span
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
>{{ u.username[0].toUpperCase() }}</span>
>{{ u.username.charAt(0).toUpperCase() }}</span>
<span class="text-sm text-primary">{{ u.username }}</span>
<span class="text-xs text-muted">{{ u.role === 'admin' ? 'Admin' : 'Utilisateur' }}</span>
</div>
+3
View File
@@ -3,6 +3,9 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
+1 -1
View File
@@ -1 +1 @@
3.18.0
3.23.1