Compare commits

...
69 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
52 changed files with 2222 additions and 632 deletions

No files matched your search

+1
View File
@@ -6,3 +6,4 @@ config
.env .env
.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 *.njsproj
*.sln *.sln
*.sw? *.sw?
# Instance de test locale
.test-sandbox
scripts/seed-test-sandbox.mjs
docker-compose.test.yml
+104
View File
@@ -1,5 +1,109 @@
# Changelog # 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) ## [3.18.2](https://github.com/Masutayunikon/FanKarr/compare/v3.18.1...v3.18.2) (2026-05-27)
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Serif+Display&display=swap" rel="stylesheet"> <link 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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fankarr</title> <title>Fankarr</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+4 -1
View File
@@ -7,7 +7,9 @@
"dev": "concurrently \"vite\" \"tsx watch server/index.ts\"", "dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
"dev:server": "tsx watch server/index.ts", "dev:server": "tsx watch server/index.ts",
"dev:client": "vite", "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: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:windows": "bun build server/index.ts --compile --target bun-windows-x64 --outfile binaries/fankarr-windows-x64.exe && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
"build:bun:macos": "bun build server/index.ts --compile --target bun-darwin-arm64 --outfile binaries/fankarr-macos && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js", "build:bun: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", "tsx": "^4.21.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^5.0.0",
"vue-tsc": "^3.1.5" "vue-tsc": "^3.1.5"
} }
} }
+228
View File
@@ -87,6 +87,9 @@ importers:
vite: vite:
specifier: ^7.3.1 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) 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: vue-tsc:
specifier: ^3.1.5 specifier: ^3.1.5
version: 3.2.5(typescript@5.9.3) version: 3.2.5(typescript@5.9.3)
@@ -283,6 +286,9 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5': '@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/sourcemap-codec@1.6.0':
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
@@ -323,66 +329,79 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0': '@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0': '@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0': '@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0': '@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0': '@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0': '@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0': '@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0': '@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0': '@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0': '@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0': '@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0': '@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0': '@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -452,24 +471,28 @@ packages:
engines: {node: '>= 20'} engines: {node: '>= 20'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1': '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1': '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.1': '@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.1': '@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
@@ -510,6 +533,9 @@ packages:
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@@ -521,6 +547,9 @@ packages:
'@types/cors@2.8.19': '@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 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 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
vue: ^3.2.25 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': '@volar/language-core@2.4.28':
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
@@ -676,6 +719,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'} engines: {node: '>=8'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-kit@2.2.0: ast-kit@2.2.0:
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
@@ -710,6 +757,10 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -828,6 +879,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-module-lexer@2.3.2:
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
es-object-atoms@1.1.1: es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -847,10 +901,17 @@ packages:
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
etag@1.8.1: etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
express@5.2.1: express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@@ -1017,24 +1078,28 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1: lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1: lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1: lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1: lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -1089,6 +1154,9 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magic-string@1.3.1:
resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==}
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1147,6 +1215,10 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'} 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: on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1180,6 +1252,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'} engines: {node: '>=12'}
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
pinia@3.0.4: pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies: peerDependencies:
@@ -1289,6 +1365,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1297,10 +1376,16 @@ packages:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@2.0.2: statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
string-width@4.2.3: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1328,10 +1413,22 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'} 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: tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
toidentifier@1.0.1: toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@@ -1423,6 +1520,47 @@ packages:
yaml: yaml:
optional: true 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: vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
@@ -1462,6 +1600,11 @@ packages:
webpack-virtual-modules@0.6.2: webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} 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: wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1601,6 +1744,8 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/sourcemap-codec@1.6.0': {}
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
dependencies: dependencies:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
@@ -1760,6 +1905,11 @@ snapshots:
'@types/connect': 3.4.38 '@types/connect': 3.4.38
'@types/node': 24.12.0 '@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': '@types/connect@3.4.38':
dependencies: dependencies:
'@types/node': 24.12.0 '@types/node': 24.12.0
@@ -1772,6 +1922,8 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.12.0 '@types/node': 24.12.0
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/express-serve-static-core@5.1.1': '@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) 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) 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': '@volar/language-core@2.4.28':
dependencies: dependencies:
'@volar/source-map': 2.4.28 '@volar/source-map': 2.4.28
@@ -1971,6 +2134,8 @@ snapshots:
dependencies: dependencies:
color-convert: 2.0.1 color-convert: 2.0.1
assertion-error@2.0.1: {}
ast-kit@2.2.0: ast-kit@2.2.0:
dependencies: dependencies:
'@babel/parser': 7.29.0 '@babel/parser': 7.29.0
@@ -2013,6 +2178,8 @@ snapshots:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
chai@6.2.2: {}
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -2110,6 +2277,8 @@ snapshots:
es-errors@1.3.0: {} es-errors@1.3.0: {}
es-module-lexer@2.3.2: {}
es-object-atoms@1.1.1: es-object-atoms@1.1.1:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -2149,8 +2318,14 @@ snapshots:
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
etag@1.8.1: {} etag@1.8.1: {}
expect-type@1.4.0: {}
express@5.2.1: express@5.2.1:
dependencies: dependencies:
accepts: 2.0.0 accepts: 2.0.0
@@ -2190,6 +2365,10 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
fetch-blob@3.2.0: fetch-blob@3.2.0:
dependencies: dependencies:
node-domexception: 1.0.0 node-domexception: 1.0.0
@@ -2390,6 +2569,10 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
magic-string@1.3.1:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
media-typer@1.1.0: {} media-typer@1.1.0: {}
@@ -2431,6 +2614,8 @@ snapshots:
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
obug@2.2.1: {}
on-finished@2.4.1: on-finished@2.4.1:
dependencies: dependencies:
ee-first: 1.1.1 ee-first: 1.1.1
@@ -2455,6 +2640,8 @@ snapshots:
picomatch@4.0.3: {} picomatch@4.0.3: {}
picomatch@4.0.7: {}
pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)): pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)):
dependencies: dependencies:
'@vue/devtools-api': 7.7.9 '@vue/devtools-api': 7.7.9
@@ -2618,12 +2805,18 @@ snapshots:
side-channel-map: 1.0.1 side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2 side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
speakingurl@14.0.1: {} speakingurl@14.0.1: {}
stackback@0.0.2: {}
statuses@2.0.2: {} statuses@2.0.2: {}
std-env@4.2.0: {}
string-width@4.2.3: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0
@@ -2650,11 +2843,20 @@ snapshots:
tapable@2.3.0: {} tapable@2.3.0: {}
tinybench@6.1.4: {}
tinyexec@1.3.0: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
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: {} toidentifier@1.0.1: {}
tree-kill@1.2.2: {} tree-kill@1.2.2: {}
@@ -2713,6 +2915,27 @@ snapshots:
tsx: 4.21.0 tsx: 4.21.0
yaml: 2.8.2 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: {} 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)): 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: {} 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: wrap-ansi@7.0.0:
dependencies: dependencies:
ansi-styles: 4.3.0 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 -33
View File
@@ -14,10 +14,12 @@ import utorrentDriver from './torrent-clients/utorrent.js'
import rtorrentDriver from './torrent-clients/rtorrent.js' import rtorrentDriver from './torrent-clients/rtorrent.js'
import realDebridDriver from './torrent-clients/real-debrid.js' import realDebridDriver from './torrent-clients/real-debrid.js'
import delugeDriver from './torrent-clients/deluge.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 { logger } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.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 { pushNotif } from './lib/notifs.js'
import { readRequests, completeRequest } from './requests.js' import { readRequests, completeRequest } from './requests.js'
import { checkNfoUpdates } from './lib/nfo.js' import { checkNfoUpdates } from './lib/nfo.js'
@@ -91,6 +93,9 @@ const ADMIN_PREFIXES = [
'/users', '/jellyfin', '/settings', '/torrent-clients', '/users', '/jellyfin', '/settings', '/torrent-clients',
'/downloads', '/download', '/organize', '/import', '/downloads', '/download', '/organize', '/import',
'/system', '/nfo-updates', '/plex', '/rss-sync', '/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) => { app.use('/api', (req, res, next) => {
if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) { if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) {
@@ -135,48 +140,46 @@ server.listen(PORT, async () => {
logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`) 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() const { mediaPath } = readSettings()
// Migration organized.json : si ancien format (valeurs string) → reset + rescan // Migration organized.json : si ancien format (valeurs string) → reset + rescan
try { try {
if (fs.existsSync(ORGANIZED_PATH)) { const raw = readOrganized()
const raw = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) const isOldFormat = Object.values(raw).some((entries: any) =>
const isOldFormat = Object.values(raw).some((entries: any) => Object.values(entries).some(v => typeof v === 'string')
Object.values(entries).some(v => typeof v === 'string') )
) if (isOldFormat) {
if (isOldFormat) { logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation') writeOrganized({})
fs.writeFileSync(ORGANIZED_PATH, '{}', 'utf-8')
}
} }
} 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 // Migration organized.json : backfill dest_dir manquant sur les anciennes entrées
try { try {
if (fs.existsSync(ORGANIZED_PATH)) { const raw = readOrganized()
const raw: Record<string, Record<string, any>> = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) let patched = 0
let patched = 0 for (const episodes of Object.values(raw)) {
for (const episodes of Object.values(raw)) { for (const entry of Object.values(episodes)) {
for (const entry of Object.values(episodes)) { if (!entry.dest_dir && entry.dest_path) {
if (!entry.dest_dir && entry.dest_path) { entry.dest_dir = path.dirname(entry.dest_path)
entry.dest_dir = path.dirname(entry.dest_path) patched++
patched++
}
} }
} }
if (patched > 0) { }
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(raw, null, 2)) if (patched > 0) {
logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`) writeOrganized(raw)
} logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`)
} }
} catch (err) { } catch (err) {
logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`) logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`)
} }
loadEnrichedSeriesData() loadCatalog()
.then(async seriesData => { .then(async seriesData => {
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData) await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
dedupeOrganizedEpisodes(ORGANIZED_PATH)
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData) return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
}) })
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`)) .catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
@@ -185,7 +188,7 @@ server.listen(PORT, async () => {
try { try {
const { category } = readSettings() const { category } = readSettings()
const infohashMap = await readInfohashMap() const infohashMap = await readInfohashMap()
const seriesData = await loadEnrichedSeriesData() const seriesData = await loadCatalog()
await autoOrganizeAll( await autoOrganizeAll(
() => dispatchList(category ?? 'fankai', infohashMap), () => dispatchList(category ?? 'fankai', infohashMap),
seriesData, seriesData,
@@ -210,11 +213,11 @@ server.listen(PORT, async () => {
} }
const { organizeMode, deleteTorrentOnMove } = readSettings() const { organizeMode, deleteTorrentOnMove } = readSettings()
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) { if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
try { const removeResult = await dispatchRemove(result.hash, false)
await dispatchRemove(result.hash, false) if (removeResult.ok) {
logger.info('api', `Torrent "${result.name}" supprimé après move`) logger.info('api', `Torrent "${result.name}" supprimé après move`)
} catch (err) { } else {
logger.warn('api', `Impossible de supprimer le torrent "${result.name}" : ${err instanceof Error ? err.message : err}`) logger.warn('api', `Torrent "${result.name}" — suppression échouée après move : ${removeResult.error ?? 'aucun client n\'a pu supprimer'}`)
} }
} }
} }
@@ -240,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. // Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches.
setInterval(async () => { setInterval(async () => {
try { try {
const seriesData = await loadEnrichedSeriesData() const seriesData = await loadCatalog()
const organizedPath = path.join(DATA_DIR, 'organized.json') const organizedPath = path.join(DATA_DIR, 'organized.json')
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData) const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
if (updated > 0) if (updated > 0)
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`) 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) const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0) if (renamed > 0)
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`) 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 path from 'path'
import fs from 'fs' import fs from 'fs'
const GITHUB_BASE = process.env.GITHUB_BASE const GITHUB_BASE =
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main' process.env.GITHUB_BASE?.trim() ||
'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
const CACHE_TTL_MS = 1 * 60 * 60 * 1000 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 function cacheSize(): number { return _cache.size }
export async function githubGet(urlPath: string): Promise<any> { /**
const cached = cacheGet(urlPath) * @param force Ignore le cache mémoire ET le cache CDN de raw.githubusercontent
if (cached) return cached * (~5 min).
const res = await fetch(`${GITHUB_BASE}/${urlPath}`) */
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}`) if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
const data = await res.json() const data = await res.json()
cacheSet(urlPath, data) cacheSet(urlPath, data)
return data return data
} }
export async function readAvailable(): Promise<number[]> { export async function readAvailable(force = false): Promise<number[]> {
try { return await githubGet('available.json') as number[] } try { return await githubGet('available.json', force) as number[] }
catch { return [] } catch { return [] }
} }
export async function readInfohashMap(): Promise<Record<string, string>> { export async function readInfohashMap(force = false): Promise<Record<string, string>> {
try { return await githubGet('infohash_map.json') as Record<string, string> } try { return await githubGet('infohash_map.json', force) as Record<string, string> }
catch { return {} } catch { return {} }
} }
export async function readSerieData(serieId: number): Promise<any | null> { export async function readSerieData(serieId: number, force = false): Promise<any | null> {
try { return await githubGet(`series/${serieId}.json`) } try { return await githubGet(`series/${serieId}.json`, force) }
catch { return null } catch { return null }
} }
export async function loadEnrichedSeriesData(): Promise<any[]> { export async function loadEnrichedSeriesData(force = false): Promise<any[]> {
const ids = await readAvailable() const ids = await readAvailable(force)
const allSd = await Promise.all(ids.map(id => readSerieData(id))) const allSd = await Promise.all(ids.map(id => readSerieData(id, force)))
return allSd.filter(Boolean) 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 fs from 'fs'
import { DATA_DIR } from '../config.js' import { DATA_DIR } from '../config.js'
import { logger } from '../logger.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 { dispatchDownload, dispatchList } from '../torrent-clients/index.js'
import { readSettings } from '../settings.js' import { readSettings } from '../settings.js'
@@ -53,10 +54,48 @@ export function setSync(serieId: number, serieName: string, enabled: boolean): S
return map return map
} }
// Une série déjà surveillée garde sa date d'activation
export function setSyncBulk(series: { id: number; name: string }[], enabled: boolean): { changed: number; map: SyncedMap } {
const map = loadSynced()
let changed = 0
for (const { id, name } of series) {
if (enabled && !map[id]) { map[id] = { serieId: id, serieName: name, addedAt: new Date().toISOString() }; changed++ }
if (!enabled && map[id]) { delete map[id]; changed++ }
}
if (changed > 0) saveSynced(map)
return { changed, map }
}
export function isSynced(serieId: number): boolean { export function isSynced(serieId: number): boolean {
return !!loadSynced()[serieId] 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 ─────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────
function readOrganized(): Record<string, Record<string, any>> { 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> { function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string, any>>): Set<number> {
const ids = new 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 season of sd.seasons ?? [])
for (const ep of season.episodes ?? []) 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 ?? []) { for (const t of sd.torrents ?? []) {
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
@@ -107,14 +147,16 @@ function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string
// ── Logique de sync ─────────────────────────────────────────── // ── Logique de sync ───────────────────────────────────────────
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> { 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 synced = loadSynced()
const ids = Object.keys(synced).map(Number) 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)`) logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`)
const organized = readOrganized() const organized = readOrganized()
const seriesData = await loadEnrichedSeriesData()
const activeHashes = new Set<string>() const activeHashes = new Set<string>()
try { try {
@@ -134,7 +176,7 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const syncedEntry = synced[serieId] const syncedEntry = synced[serieId]
const sd = seriesData.find((s: any) => s.id === serieId) const sd = seriesData.find((s: any) => s.id === serieId)
if (!sd) { 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 continue
} }
+79 -9
View File
@@ -1,4 +1,6 @@
import path from 'path' import path from 'path'
import { logger } from '../logger.js'
import { githubGet, loadEnrichedSeriesData, readAvailable } from './github-cache.js'
const FANKAI_API = 'https://metadata.fankai.fr' const FANKAI_API = 'https://metadata.fankai.fr'
@@ -8,6 +10,80 @@ export async function fankaiGet(endpoint: string): Promise<any> {
return res.json() 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 { export function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
if (!url) return null if (!url) return null
const stripped = url.replace(/^https?:\/\//, '') const stripped = url.replace(/^https?:\/\//, '')
@@ -170,15 +246,9 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
} }
} }
const manualOrg = organized['manual'] ?? {} const trackedIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
for (const season of serieData.seasons ?? []) { for (const [id, key] of epIdToKey)
for (const ep of season.episodes ?? []) { if (trackedIds.has(String(id))) organizedEpKeys.add(key)
if (manualOrg[String(ep.id)]) {
const key = epIdToKey.get(ep.id)
if (key) organizedEpKeys.add(key)
}
}
}
if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none' if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none'
if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete' 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 LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
const MAX_LINES = 2000 // rotation auto au-delà 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' const IS_PROD = process.env.NODE_ENV === 'production'
// ── Écriture ────────────────────────────────────────────────── // ── É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) { function writeLine(entry: LogEntry) {
try { try {
lineCount ??= countLines()
fs.appendFileSync(LOGS_PATH, JSON.stringify(entry) + '\n', 'utf-8') fs.appendFileSync(LOGS_PATH, JSON.stringify(entry) + '\n', 'utf-8')
// Rotation si trop grand if (++lineCount > MAX_LINES + ROTATE_SLACK) {
const content = fs.readFileSync(LOGS_PATH, 'utf-8') const lines = fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).slice(-MAX_LINES)
const lines = content.split('\n').filter(Boolean) fs.writeFileSync(LOGS_PATH, lines.join('\n') + '\n', 'utf-8')
if (lines.length > MAX_LINES) { lineCount = lines.length
fs.writeFileSync(LOGS_PATH, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8')
} }
} catch {} } catch {}
} }
@@ -98,7 +106,7 @@ export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
} }
export function clearLogs(): void { 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 { export function logsFileSize(): number {
+93 -25
View File
@@ -10,8 +10,7 @@ import path from 'path'
import { parentPort } from 'worker_threads' import { parentPort } from 'worker_threads'
import { DATA_DIR } from './config.js' import { DATA_DIR } from './config.js'
import { getGitlabTitle } from './gitlab-map.js' import { getGitlabTitle } from './gitlab-map.js'
import { readOrganized } from './lib/organized-store.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// ─── Utils log ──────────────────────────────────────────────── // ─── Utils log ────────────────────────────────────────────────
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) } 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>> type Organized = Record<string, Record<string, OrgEntry>>
// ─── Settings ───────────────────────────────────────────────── // ─── Settings ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } { function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean, englishDirectory: boolean } {
try { try {
const p = path.join(DATA_DIR, 'settings.json') 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')) 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 ──────────────────────────────────────────── // ─── 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) { function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
const data = loadOrganized() parentPort?.postMessage({ type: 'mark', hash, episodeId, entry })
if (!data[hash]) data[hash] = {}
data[hash][String(episodeId)] = entry
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
} }
function isOrganized(hash: string, episodeId: number): boolean { function isOrganized(hash: string, episodeId: number): boolean {
return !!loadOrganized()[hash]?.[String(episodeId)] return !!readOrganized()[hash]?.[String(episodeId)]
} }
// ─── Lookup torrent dans les seriesData ─────────────────────── // ─── Lookup torrent dans les seriesData ───────────────────────
/**
* Recherche par infohash (méthode principale).
*/
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null { function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
const h = hash.toLowerCase() const h = hash.toLowerCase()
for (const sd of seriesData) { for (const sd of seriesData) {
@@ -101,6 +94,53 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
return null 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( function buildFileMap(
serieData : any, serieData : any,
hash : string, hash : string,
@@ -251,7 +291,8 @@ function swapExtension(filename: string, ext: string): string {
} }
function seasonFolder(n: number): 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 { try {
fs.mkdirSync(destDir, { recursive: true }) fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'copy') { 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') { } else if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest) if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
markOrganized(hash, episode_id, { markOrganized(hash, episode_id, {
@@ -493,13 +539,35 @@ parentPort?.on('message', async (msg: any) => {
const torrents: any[] = msg.torrents const torrents: any[] = msg.torrents
const seriesData: any[] = msg.seriesData ?? [] const seriesData: any[] = msg.seriesData ?? []
const organized = loadOrganized()
const { nfoSupport } = readSettings() 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) { for (const t of torrents) {
if (t.state !== 'seeding') continue 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) { if (!found) {
debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`) debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
continue continue
@@ -507,8 +575,8 @@ parentPort?.on('message', async (msg: any) => {
const { torrent, serieData } = found const { torrent, serieData } = found
const seasonFilter = torrent._season?.season_number const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter) const fileMap = buildFileMap(serieData, effectiveHash, nfoSupport, seasonFilter)
const orgHash = organized[t.hash] ?? {} const orgHash = organized[effectiveHash] ?? {}
const allDone = fileMap.size > 0 const allDone = fileMap.size > 0
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)]) ? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
: Object.keys(orgHash).length > 0 : 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) const result = await organizeTorrent(effectiveHash, t.name, t.save_path, seriesData, completedFileNames)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, serieId: serieData.id ?? null, ...result }) parentPort?.postMessage({ type: 'result', hash: effectiveHash, name: t.name, serieId: serieData.id ?? null, ...result })
} catch (err) { } catch (err) {
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : 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 path from 'path'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { logger } from './logger.js' import { logger } from './logger.js'
import { DATA_DIR } from './config.js'
import { readSettings } from './settings.js' import { readSettings } from './settings.js'
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from './lib/organized-store.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
export let workerRunning = false export let workerRunning = false
const _prevStates = new Map<string, string>() const _prevStates = new Map<string, string>()
@@ -30,18 +28,54 @@ export interface OrganizeResult {
errors : { file: string; error: string }[] errors : { file: string; error: string }[]
} }
interface OrgEntry { type WorkerResult = { hash: string; name: string; serieId?: number | null; total: number; done: number; skipped: number; errors: { file: string; error: string }[] }
at : string
season : number // ── File d'attente des workers ────────────────────────────────
episode : number let _queue: Promise<unknown> = Promise.resolve()
episode_id : number let _queued = 0
src_filename : string const _pendingManual = new Map<string, Promise<OrganizeResult>>()
dest_filename: string
dest_path : string function enqueue<T>(job: () => Promise<T>): Promise<T> {
dest_dir? : string // absent dans les anciennes entrées — backfillé au démarrage _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 ────────────────────────────────────────────── // ── Scan initial ──────────────────────────────────────────────
@@ -65,10 +99,8 @@ export async function scanMediaPath(
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`) 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, { const filenameIndex = new Map<string, {
hash : string hashes : string[]
srcFilename : string srcFilename : string
episodeId : number episodeId : number
season : number season : number
@@ -84,8 +116,13 @@ export async function scanMediaPath(
const idx = (filename: string, hash: string, destFilename?: string) => { const idx = (filename: string, hash: string, destFilename?: string) => {
const base = filename.replace(/\.[^.]+$/, '') const base = filename.replace(/\.[^.]+$/, '')
if (!base) return 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, { filenameIndex.set(base, {
hash, hashes : [hash],
srcFilename : filename, srcFilename : filename,
episodeId : ep.id, episodeId : ep.id,
season : season.season_number, season : season.season_number,
@@ -123,16 +160,27 @@ export async function scanMediaPath(
} }
} }
let organized: Organized = {} let organized: Organized
try { try { organized = readOrganized(organizedPath) }
if (fs.existsSync(organizedPath)) catch (err) {
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) logger.error('organize', `Scan annulé : ${err instanceof Error ? err.message : err}`)
} catch {} return result
}
// Track les paires hash:episodeId présentes // Track les paires hash:episodeId présentes
const presentFiles = new Set<string>() const presentFiles = new Set<string>()
let noMatch = 0 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) { function walk(dir: string) {
let entries: fs.Dirent[] let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
@@ -143,6 +191,14 @@ export async function scanMediaPath(
walk(full) walk(full)
} else if (entry.isFile() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) { } else if (entry.isFile() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) {
result.found++ 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(/\.[^.]+$/, '') const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '')
let match = filenameIndex.get(nameWithoutExt) let match = filenameIndex.get(nameWithoutExt)
@@ -167,7 +223,8 @@ export async function scanMediaPath(
continue 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}`) presentFiles.add(`${hash}:${episodeId}`)
if (organized[hash]?.[String(episodeId)]) continue if (organized[hash]?.[String(episodeId)]) continue
@@ -190,11 +247,17 @@ export async function scanMediaPath(
walk(mediaPath) 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 // 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 let removed = 0
for (const [hash, episodes] of Object.entries(organized)) { for (const [hash, episodes] of Object.entries(organized)) {
if (mediaEmpty) break
for (const episodeId of Object.keys(episodes)) { for (const episodeId of Object.keys(episodes)) {
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) { if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
delete organized[hash][episodeId] delete organized[hash][episodeId]
@@ -207,7 +270,7 @@ export async function scanMediaPath(
// Désimport automatique des fichiers manquants sur le disque // Désimport automatique des fichiers manquants sur le disque
const { autoUnimportMissing } = readSettings() as any const { autoUnimportMissing } = readSettings() as any
let autoRemoved = 0 let autoRemoved = 0
if (autoUnimportMissing) { if (autoUnimportMissing && !mediaEmpty) {
for (const [hash, episodes] of Object.entries(organized)) { for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) { for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
const destPath = entry?.dest_path const destPath = entry?.dest_path
@@ -222,7 +285,7 @@ export async function scanMediaPath(
} }
if (result.added > 0 || removed > 0 || autoRemoved > 0) { 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` : ''}`) 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 return
} }
if (workerRunning) { if (_queued > 0) {
logger.debug('organize', 'Worker déjà en cours — skip') logger.debug('organize', 'Import déjà en cours — skip')
return return
} }
@@ -268,11 +331,12 @@ export async function autoOrganizeAll(
for (const t of torrents) _prevStates.set(t.hash, t.state) for (const t of torrents) _prevStates.set(t.hash, t.state)
let organized: Organized = {} let organized: Organized
try { try { organized = readOrganized() }
if (fs.existsSync(ORGANIZED_PATH)) catch (err) {
organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) logger.error('organize', `Import automatique annulé : ${err instanceof Error ? err.message : err}`)
} catch {} return
}
const hasUnorganized = torrents.some(t => const hasUnorganized = torrents.some(t =>
t.state === 'seeding' && !organized[t.hash?.toLowerCase()] 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)`) logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
} }
workerRunning = true try {
const worker = new Worker(resolveWorkerPath()) await enqueue(() => runWorker(torrents, seriesData, msg => {
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') {
if (msg.errors.length > 0) { if (msg.errors.length > 0) {
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`) logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) { for (const e of msg.errors) {
@@ -316,24 +375,11 @@ export async function autoOrganizeAll(
errors : msg.errors.length, errors : msg.errors.length,
errorFiles: msg.errors, errorFiles: msg.errors,
}) })
} else if (msg.type === 'done') { }))
logger.debug('organize', 'Worker terminé') logger.debug('organize', 'Worker terminé')
workerRunning = false } catch (err) {
worker.terminate() logger.error('organize', `Worker erreur : ${err instanceof Error ? err.message : err}`)
} }
})
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 })
} }
// ── Migration des IDs épisodes ──────────────────────────────── // ── Migration des IDs épisodes ────────────────────────────────
@@ -368,11 +414,12 @@ export async function migrateOrganizedEpisodeIds(
} }
// 2. Charger organized.json // 2. Charger organized.json
let organized: Organized = {} let organized: Organized
try { try { organized = readOrganized(organizedPath) }
if (fs.existsSync(organizedPath)) catch (err) {
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) logger.error('organize', `Migration IDs annulée : ${err instanceof Error ? err.message : err}`)
} catch { return { updated: 0, orphaned: 0 } } return { updated: 0, orphaned: 0 }
}
let updated = 0 let updated = 0
let orphaned = 0 let orphaned = 0
@@ -422,7 +469,7 @@ export async function migrateOrganizedEpisodeIds(
} }
if (changed) { 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` : ''}`) logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`)
} else { } else {
logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`) 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 } 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 ──── // ── Sync des noms de fichiers après mise à jour du scraper ────
export async function syncFilenameChanges( export async function syncFilenameChanges(
@@ -438,11 +528,12 @@ export async function syncFilenameChanges(
organizedPath: string, organizedPath: string,
): Promise<{ renamed: number; errors: number }> { ): Promise<{ renamed: number; errors: number }> {
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
let organized: Organized = {} let organized: Organized
try { try { organized = readOrganized(organizedPath) }
if (fs.existsSync(organizedPath)) catch (err) {
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) logger.error('organize', `Sync noms annulée : ${err instanceof Error ? err.message : err}`)
} catch { return { renamed: 0, errors: 0 } } return { renamed: 0, errors: 0 }
}
let renamed = 0 let renamed = 0
let errors = 0 let errors = 0
@@ -494,6 +585,7 @@ export async function syncFilenameChanges(
dest_path : newPath, dest_path : newPath,
at : new Date().toISOString(), at : new Date().toISOString(),
} }
retargetEntries(organized, new Map([[oldPath, newPath]]))
renamed++ renamed++
changed = true changed = true
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`) logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
@@ -506,7 +598,7 @@ export async function syncFilenameChanges(
} }
if (changed) if (changed)
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') writeOrganized(organized, organizedPath)
if (renamed > 0 || errors > 0) if (renamed > 0 || errors > 0)
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`) logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
@@ -520,19 +612,27 @@ export async function organizeTorrent(
hash : string, hash : string,
name : string, name : string,
savePath : string, savePath : string,
seriesData: any[] seriesData: any[],
files? : { name?: string; progress: number; priority?: number }[],
): Promise<OrganizeResult> { ): 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) => { // files : progression par fichier récupérée depuis le client torrent.
const worker = new Worker(resolveWorkerPath()) // Permet au worker de ne traiter que les fichiers à 100% et d'éviter
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' } // les EBUSY sur Windows quand qBit est encore en train d'écrire les autres.
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] } 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) => { const job = enqueue(async () => {
if (msg.type === 'log') { _pendingManual.delete(key)
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg) try {
} else if (msg.type === 'result') { await runWorker([fakeTorrent], seriesData, msg => {
result.total = msg.total result.total = msg.total
result.skipped = msg.skipped result.skipped = msg.skipped
result.done = msg.done result.done = msg.done
@@ -545,21 +645,13 @@ export async function organizeTorrent(
} else { } else {
logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`) logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
} }
} else if (msg.type === 'done') { })
worker.terminate() } catch (err) {
resolve(result) logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err instanceof Error ? err.message : err}`)
} throw err
}) }
return 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 })
}) })
_pendingManual.set(key, job)
return job
} }
+261 -80
View File
@@ -1,28 +1,77 @@
import { Router } from 'express' import { Router } from 'express'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import { requireAuth } from '../auth.js' import { requireAuth, requireAdmin } from '../auth.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js' import { readSettings } from '../settings.js'
import { getGitlabTitle } from '../gitlab-map.js' import { getGitlabTitle } from '../gitlab-map.js'
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js' import {
import { resolveEpNaming, computeExpectedName } from '../lib/serie-helpers.js' resolveEpNaming, computeExpectedName, resolveSerieData, loadCatalog, loadCatalogStatus,
serieFolderName, seasonFolderName,
} from '../lib/serie-helpers.js'
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js' import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
import { dispatchRemove } from '../torrent-clients/index.js' import { dispatchRemove } from '../torrent-clients/index.js'
import { readRequests, deleteRequest } from '../requests.js' import { readRequests, deleteRequest } from '../requests.js'
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from '../lib/organized-store.js'
const router = Router() 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>> { // Entrée d'un épisode sous n'importe quel hash (torrent absent des paths du scraper)
try { if (fs.existsSync(ORGANIZED_PATH)) return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {} function findEntry(organized: Organized, episodeId: number): { hash: string; entry: any } | null {
return {} 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>>) { // Entrées dont l'épisode n'existe plus dans le catalogue
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8') 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 ────────────────────────────────────────────── // ── Import manuel ──────────────────────────────────────────────
@@ -31,18 +80,16 @@ router.post('/manual-import', requireAuth, async (req, res) => {
if (!serie_id || !Array.isArray(items) || items.length === 0) { if (!serie_id || !Array.isArray(items) || items.length === 0) {
res.status(400).json({ error: 'serie_id et items requis' }); return res.status(400).json({ error: 'serie_id et items requis' }); return
} }
const { mediaPath, organizeMode, nfoSupport } = readSettings() const { mediaPath, organizeMode, nfoSupport, englishDirectory } = readSettings()
const organized = loadOrganizedJson() let organized: Organized
let sd = await readSerieData(Number(serie_id)) try { organized = readOrganized() }
if (!sd) { catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
// readSerieData ne couvre que le cache local — fallback sur le catalogue complet // Les copies de fichiers sont asynchrones : les modifications sont rejouées sur une relecture fraîche à la fin
// (utile pour les séries sans torrent officiel dont les données sont peut-être absentes du cache individuel) const ops: ((data: Organized) => void)[] = []
const all = await loadEnrichedSeriesData() const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) }
sd = all.find((s: any) => s.id === Number(serie_id)) ?? null const sd = await resolveSerieData(Number(serie_id), 'Import manuel')
} if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return } const serieTitle = serieFolderName(sd.title ?? sd.show_title ?? '')
const rawTitle = sd.title ?? sd.show_title ?? ''
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
const episodeIndex = new Map<number, { ep: any; season: any }>() const episodeIndex = new Map<number, { ep: any; season: any }>()
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
@@ -68,8 +115,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
const destFilename = resolvedExt && resolvedExt !== srcExt const destFilename = resolvedExt && resolvedExt !== srcExt
? resolvedName.slice(0, -resolvedExt.length) + srcExt ? resolvedName.slice(0, -resolvedExt.length) + srcExt
: resolvedName : resolvedName
const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}` const destDir = path.join(mediaPath, serieTitle, seasonFolderName(season.season_number, englishDirectory))
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
const destPath = path.join(destDir, destFilename) const destPath = path.join(destDir, destFilename)
try { try {
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable') 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) // sous un épisode différent (réassignation → l'ancien enregistrement devient invalide)
for (const [h, eps] of Object.entries(organized)) { for (const [h, eps] of Object.entries(organized)) {
for (const [epId, entry] of Object.entries(eps)) { for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) { if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id))
delete (organized[h] as any)[epId]
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (fichier réassigné)`) 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 }) fs.mkdirSync(destDir, { recursive: true })
if (file_path !== destPath) { if (file_path !== destPath) {
@@ -96,18 +144,21 @@ router.post('/manual-import', requireAuth, async (req, res) => {
fs.unlinkSync(destPath) fs.unlinkSync(destPath)
logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`) logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`)
// Nettoyer toute entrée organized qui pointait sur ce fichier supprimé // 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)) { for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === destPath) { if (entry?.dest_path === destPath)
delete (organized[h] as any)[epId]
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (ancien fichier écrasé)`) 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)) { if (isInSeriePath && !fs.existsSync(destPath)) {
fs.renameSync(file_path, destPath) fs.renameSync(file_path, destPath)
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`) logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
} else if (!isInSeriePath && !fs.existsSync(destPath)) { } else if (!isInSeriePath && !fs.existsSync(destPath)) {
if (organizeMode === 'hardlink') { if (organizeMode === 'hardlink') {
@@ -116,6 +167,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
} else if (organizeMode === 'move') { } else if (organizeMode === 'move') {
try { fs.renameSync(file_path, destPath) } try { fs.renameSync(file_path, destPath) }
catch { await fs.promises.copyFile(file_path, destPath); await fs.promises.unlink(file_path) } catch { await fs.promises.copyFile(file_path, destPath); await fs.promises.unlink(file_path) }
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
} else { } else {
await fs.promises.copyFile(file_path, destPath) 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`) logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`)
} }
const torrentHash = String(hash || '').toLowerCase() || 'manual' const torrentHash = String(hash || '').toLowerCase() || 'manual'
if (!organized[torrentHash]) organized[torrentHash] = {} const entry = {
organized[torrentHash][String(episode_id)] = {
at: new Date().toISOString(), season: season.season_number, at: new Date().toISOString(), season: season.season_number,
episode: ep.episode_number, episode_id: ep.id, episode: ep.episode_number, episode_id: ep.id,
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath, src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
} }
apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry })
done.push(episode_id) done.push(episode_id)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' 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}`) 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) { if (nfoSupport && done.length > 0) {
const gitlabTitle = getGitlabTitle(serieTitle) const gitlabTitle = getGitlabTitle(serieTitle)
@@ -173,9 +231,9 @@ router.post('/manual-import', requireAuth, async (req, res) => {
router.get('/organized/:serieId', requireAuth, async (req, res) => { router.get('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId) const serieId = Number(req.params.serieId)
try { try {
const sd = await readSerieData(serieId) const sd = await resolveSerieData(serieId)
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
const organized = loadOrganizedJson() const organized = readOrganized()
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
const result: Record<string, any> = {} const result: Record<string, any> = {}
for (const season of sd.seasons ?? []) { 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)]) { if (!entry && organized['manual']?.[String(ep.id)]) {
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 if (!entry) continue
const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport) 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 const { serie_id, episode_id, torrent_hash } = req.body
if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return } if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return }
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
const organized = loadOrganizedJson() const sd = await resolveSerieData(Number(serie_id), 'Renommage')
const sd = await readSerieData(Number(serie_id)) if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); 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 let foundEp: any = null
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { 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 orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual' let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
if (!orgEntry) { if (!orgEntry) {
for (const [h, eps] of Object.entries(organized)) { const found = findEntry(organized, Number(episode_id))
if (eps[String(episode_id)]) { orgEntry = eps[String(episode_id)]; entryHash = h; break } if (found) { orgEntry = found.entry; entryHash = found.hash }
}
} }
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return } if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
const { currentName, expectedName: newName, needsRename } = computeExpectedName(ep, orgEntry, entryHash, nfoSupport) 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}`) if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
fs.renameSync(oldPath, newPath) fs.renameSync(oldPath, newPath)
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() } 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}"`) logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName }) res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
} catch (err) { } catch (err) {
@@ -252,9 +316,11 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
router.delete('/organized/:serieId', requireAuth, async (req, res) => { router.delete('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId) const serieId = String(req.params.serieId)
const deleteFile = req.query.deleteFile === 'true' const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson() const sd = await resolveSerieData(Number(serieId), 'Désimport série')
const sd = await readSerieData(Number(serieId)) if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); 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>() const episodeIds = new Set<string>()
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id)) 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) } if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
} }
saveOrganizedJson(organized) writeOrganized(organized)
if (deleteFile) { if (deleteFile) {
for (const hash of emptyHashes) { for (const hash of emptyHashes) {
if (hash === 'manual') continue if (hash === 'manual') continue
@@ -312,9 +378,11 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
const serieId = String(req.params.serieId) const serieId = String(req.params.serieId)
const seasonId = Number(req.params.seasonId) const seasonId = Number(req.params.seasonId)
const deleteFile = req.query.deleteFile === 'true' const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson() const sd = await resolveSerieData(Number(serieId), 'Désimport saison')
const sd = await readSerieData(Number(serieId)) if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); 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) const season = sd.seasons?.find((s: any) => s.id === seasonId)
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return } if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? []) 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) } if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
} }
saveOrganizedJson(organized) writeOrganized(organized)
if (deleteFile) { if (deleteFile) {
for (const hash of emptyHashes) { for (const hash of emptyHashes) {
if (hash === 'manual') continue 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) => { router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const episodeId = String(req.params.episodeId) const episodeId = String(req.params.episodeId)
const deleteFile = req.query.deleteFile === 'true' const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson() let organized: Organized
let foundHash: string | null = null try { organized = readOrganized() }
let foundEntry: any = null 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)) { 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 { try {
if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) { const deletedPaths = new Set<string>()
fs.unlinkSync(foundEntry.dest_path) if (deleteFile) {
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`) 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 emptyHashes: string[] = []
const torrentEmpty = Object.keys(organized[foundHash]).length === 0 for (const { hash } of found) {
if (torrentEmpty) delete organized[foundHash] delete organized[hash][episodeId]
saveOrganizedJson(organized) if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
// Si plus aucun épisode de ce torrent n'est importé → retirer le torrent du client
if (deleteFile && torrentEmpty && foundHash !== 'manual') {
dispatchRemove(foundHash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${foundHash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
} }
logger.info('api', `Désimport ep ${episodeId}${deleteFile && torrentEmpty ? ` + torrent ${foundHash.slice(0, 8)}… retiré du client` : ''}`) 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 }) res.json({ ok: true })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' 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) => { router.get('/organized-summary', requireAuth, async (_req, res) => {
try { try {
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
const organized = loadOrganizedJson() const { series: seriesData, complete } = await loadCatalogStatus()
const seriesData = await loadEnrichedSeriesData() const organized = readOrganized()
const result: any[] = [] const result: any[] = []
for (const sd of seriesData) { for (const sd of seriesData) {
const rawTitle = sd.title ?? sd.show_title ?? '' 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[] = [] const episodes: any[] = []
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { 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)]) { if (!orgEntry && organized['manual']?.[String(ep.id)]) {
orgEntry = organized['manual'][String(ep.id)]; orgHash = 'manual' 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 if (!orgEntry) continue
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport) const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false 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 }) 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) { } catch (err) {
logger.error('api', `organized-summary échoué : ${err instanceof Error ? err.message : 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' }) 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 ──────────────────────────────────────────── // ── Rename en masse ────────────────────────────────────────────
router.post('/rename-all', requireAuth, async (req, res) => { router.post('/rename-all', requireAdmin, async (req, res) => {
const { serie_id } = req.body 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 { nfoSupport } = readSettings()
const organized = loadOrganizedJson() const seriesData = await loadCatalog()
const seriesData = await loadEnrichedSeriesData() 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 done: number[] = []
const errors: { episode_id: number; error: string }[] = [] const errors: { episode_id: number; error: string }[] = []
for (const sd of seriesData) { 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 season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
let orgEntry: any = null let orgEntry: any = null
@@ -455,6 +635,7 @@ router.post('/rename-all', requireAuth, async (req, res) => {
} }
fs.renameSync(oldPath, newPath) fs.renameSync(oldPath, newPath)
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() } 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) done.push(ep.id)
logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`) logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`)
} catch (err) { } 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 }) 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 { requireAuth } from '../auth.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.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 { recentOrganized, pushNotif } from '../lib/notifs.js'
import { dispatchGetFiles } from '../torrent-clients/index.js'
import { DATA_DIR } from '../config.js' import { DATA_DIR } from '../config.js'
import path from 'path' import path from 'path'
@@ -11,7 +12,7 @@ const router = Router()
router.post('/organize/migrate-ids', requireAuth, async (_req, res) => { router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
try { try {
const seriesData = await loadEnrichedSeriesData() const seriesData = await loadCatalog()
const organizedPath = path.join(DATA_DIR, 'organized.json') const organizedPath = path.join(DATA_DIR, 'organized.json')
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData) const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
res.json({ ok: true, ...result }) res.json({ ok: true, ...result })
@@ -35,8 +36,14 @@ router.post('/organize', requireAuth, async (req, res) => {
const { hash, name, save_path } = req.body const { hash, name, save_path } = req.body
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return } if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
try { try {
const seriesData = await loadEnrichedSeriesData() const seriesData = await loadCatalog()
const result = await organizeTorrent(hash, name, save_path, seriesData)
// 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) { if (result.done > 0 || result.errors.length > 0) {
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() }) pushNotif({ hash, name, 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 { Router } from 'express'
import { requireAuth } from '../auth.js' import { requireAuth, requireAdmin } from '../auth.js'
import { logger } from '../logger.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() 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 */ /** GET /api/rss-sync/:id → statut sync pour une série */
router.get('/rss-sync/:id', requireAuth, (req, res) => { router.get('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id) 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 { readSettings } from '../settings.js'
import { dispatchList } from '../torrent-clients/index.js' import { dispatchList } from '../torrent-clients/index.js'
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js' import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
import { loadSynced } from '../lib/rss-sync.js'
import { import {
fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode, fankaiGet, fetchSerieFromApi, normalizeSerie, normalizeSeason, normalizeEpisode,
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState, extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
deduplicateEpisodes, deduplicateEpisodes, serieHasEpisodes,
} from '../lib/serie-helpers.js' } from '../lib/serie-helpers.js'
const router = Router() const router = Router()
@@ -22,14 +23,19 @@ router.get('/series', requireAuth, async (_req, res) => {
let organized: Record<string, Record<string, any>> = {} 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 {} 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>() 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 seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
const serieDataMap = new Map<number, any>() 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) })) 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) => { res.json({ series: seriesRaw.map((serie: any) => {
const serieData = serieDataMap.get(serie.id) ?? null const serieData = serieDataMap.get(serie.id) ?? null
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false const torrents = serieData ? extractTorrentsFromSerieData(serieData) : []
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) } 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) { } catch (err) {
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : 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) => { router.get('/series/:id', requireAuth, async (req, res) => {
const id = Number(req.params.id) const id = Number(req.params.id)
try { 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 serie = { ...normalizeSerie(serieRaw), wiki: serieData?.wiki ?? null }
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? []) const seasonsWithEpisodes = seasons.map((season: any) => ({ ...normalizeSeason(season), episodes: season.episodes.map(normalizeEpisode) }))
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
return { ...normalizeSeason(season), episodes }
}))
let organized: Record<string, Record<string, any>> = {} 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 {} 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 integraleTorrents : any[] = []
const organizedEpisodeIds = new Set<number>() const organizedEpisodeIds = new Set<number>()
const manualOrg = organized['manual'] ?? {} // Importé = une entrée pour l'épisode sous n'importe quel hash : les torrents du scraper peuvent disparaître
if (serieData) { const trackedIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
for (const season of serieData.seasons ?? []) { for (const season of seasonsWithEpisodes) {
for (const ep of season.episodes ?? []) { for (const ep of season.episodes) {
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id) if (trackedIds.has(String(ep.id))) organizedEpisodeIds.add(ep.id)
}
} }
} }
if (serieData) { 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) { } catch (err) {
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : 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' }) 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) => { 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> = {} const patch: Record<string, any> = {}
if (mediaPath !== undefined) patch.mediaPath = mediaPath if (mediaPath !== undefined) patch.mediaPath = mediaPath
if (completePath !== undefined) patch.completePath = completePath if (completePath !== undefined) patch.completePath = completePath
@@ -22,6 +22,7 @@ router.post('/settings', requireAuth, (req, res) => {
if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing
if (requestAutoDownloadUsers !== undefined) patch.requestAutoDownloadUsers = requestAutoDownloadUsers if (requestAutoDownloadUsers !== undefined) patch.requestAutoDownloadUsers = requestAutoDownloadUsers
if (englishDirectory !== undefined) patch.englishDirectory = englishDirectory
const updated = writeSettings(patch) const updated = writeSettings(patch)
logger.info('api', 'Paramètres mis à jour') logger.info('api', 'Paramètres mis à jour')
res.json(updated) 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 { DATA_DIR, BASE_DIR } from '../config.js'
import { readSettings } from '../settings.js' import { readSettings } from '../settings.js'
import { systemInfo } from '../system.js' import { systemInfo } from '../system.js'
import { scanMediaPath, syncFilenameChanges } from '../organize.js' import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js'
import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js' import { readAvailable, cacheClear } from '../lib/github-cache.js'
import { loadCatalog } from '../lib/serie-helpers.js'
import { cacheSize } from '../lib/github-cache.js' import { cacheSize } from '../lib/github-cache.js'
import { recentOrganized } from '../lib/notifs.js' import { recentOrganized } from '../lib/notifs.js'
import { workerRunning } from '../organize.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 dirPath = (req.query.path as string) || '/'
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv']) const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
try { try {
if (!fs.existsSync(dirPath)) { // Lecture seule : le dossier est créé au moment de l'import
fs.mkdirSync(dirPath, { recursive: true }) if (!fs.existsSync(dirPath)) { res.json({ path: dirPath, files: [], exists: false }); return }
logger.info('api', `Dossier créé : ${dirPath}`)
}
const files: { name: string; path: string; size: number }[] = [] const files: { name: string; path: string; size: number }[] = []
function walk(dir: string) { function walk(dir: string) {
let entries: fs.Dirent[] let entries: fs.Dirent[]
@@ -111,7 +110,7 @@ router.get('/browse-files', requireAuth, (req, res) => {
} }
walk(dirPath) walk(dirPath)
files.sort((a, b) => a.name.localeCompare(b.name, 'fr')) files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
res.json({ path: dirPath, files }) res.json({ path: dirPath, files, exists: true })
} catch (err) { } catch (err) {
logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : 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' }) 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 }) } } 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 { try {
cacheClear() cacheClear()
logger.info('api', 'Cache GitHub vidé — rechargement forcé') logger.info('api', force
const availableIds = await readAvailable() ? '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') if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`) 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 }) res.json({ ok: true, count: availableIds.length })
;(async () => { ;(async () => {
try { try {
const seriesData = await loadEnrichedSeriesData() const seriesData = await loadCatalog()
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json')) const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0) if (renamed > 0)
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`) logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
} catch (err) { } catch (err) {
@@ -154,7 +169,7 @@ router.post('/scan', requireAuth, async (_req, res) => {
try { try {
const { mediaPath } = readSettings() const { mediaPath } = readSettings()
logger.info('api', 'Scan médiathèque manuel lancé') 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) const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
res.json({ ok: true, ...result }) res.json({ ok: true, ...result })
} catch (err) { } 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é requestAutoDownloadUsers: 'all' | string[] // IDs users autorisés au dl auto sur approbation ; [] = désactivé
jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096) jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096)
jellyfinAdminToken : string // Token admin Jellyfin pour la sync 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 = { const defaults: Settings = {
@@ -30,6 +31,7 @@ const defaults: Settings = {
requestAutoDownloadUsers: [], requestAutoDownloadUsers: [],
jellyfinUrl : '', jellyfinUrl : '',
jellyfinAdminToken : '', jellyfinAdminToken : '',
englishDirectory : false,
} }
export function readSettings(): Settings { export function readSettings(): Settings {
+13 -10
View File
@@ -8,7 +8,8 @@
* Progress : float 0–100 (déjà en pourcentage, pas 0–1) * 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' import { logger } from '../logger.js'
// ─── Session cache ───────────────────────────────────────────────────────────── // ─── Session cache ─────────────────────────────────────────────────────────────
@@ -18,13 +19,13 @@ import { logger } from '../logger.js'
interface SessionEntry { cookie: string; expires: number } interface SessionEntry { cookie: string; expires: number }
const _sessions = new Map<string, SessionEntry>() const _sessions = new Map<string, SessionEntry>()
function sessionKey(config: Record<string, string | number>): string { function sessionKey(config: ClientConfig): string {
return `${config.url}::${config.password}` return `${config.url}::${config.password}`
} }
// ─── Helpers HTTP ────────────────────────────────────────────────────────────── // ─── Helpers HTTP ──────────────────────────────────────────────────────────────
function delugeUrl(config: Record<string, string | number>): string { function delugeUrl(config: ClientConfig): string {
return String(config.url ?? '').replace(/\/+$/, '') + '/json' return String(config.url ?? '').replace(/\/+$/, '') + '/json'
} }
@@ -34,12 +35,13 @@ function parseCookie(raw: string): string {
} }
async function delugeRPC( async function delugeRPC(
config : ClientConfig,
url : string, url : string,
method : string, method : string,
params : unknown[], params : unknown[],
cookie : string, cookie : string,
): Promise<{ result: any; newCookie: string | null }> { ): Promise<{ result: any; newCookie: string | null }> {
const res = await fetch(url, { const res = await clientFetch(config, url, {
method : 'POST', method : 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -68,9 +70,9 @@ async function delugeRPC(
// ─── Login ───────────────────────────────────────────────────────────────────── // ─── Login ─────────────────────────────────────────────────────────────────────
async function delugeLogin(config: Record<string, string | number>): Promise<string> { async function delugeLogin(config: ClientConfig): Promise<string> {
const url = delugeUrl(config) const url = delugeUrl(config)
const res = await fetch(url, { const res = await clientFetch(config, url, {
method : 'POST', method : 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body : JSON.stringify({ method: 'auth.login', params: [String(config.password ?? '')], id: 1 }), 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 ───────────────────────────────────────────── // ─── Call avec gestion de session ─────────────────────────────────────────────
async function delugeCall( async function delugeCall(
config : Record<string, string | number>, config : ClientConfig,
method : string, method : string,
params : unknown[] = [], params : unknown[] = [],
): Promise<any> { ): Promise<any> {
@@ -110,7 +112,7 @@ async function delugeCall(
} }
try { 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 // Mise à jour du cookie si Deluge en renvoie un nouveau
if (newCookie) { if (newCookie) {
entry.cookie = newCookie entry.cookie = newCookie
@@ -125,7 +127,7 @@ async function delugeCall(
const cookie = await delugeLogin(config) const cookie = await delugeLogin(config)
const fresh = { cookie, expires: Date.now() + 30 * 60_000 } const fresh = { cookie, expires: Date.now() + 30 * 60_000 }
_sessions.set(key, fresh) _sessions.set(key, fresh)
const { result } = await delugeRPC(url, method, params, cookie) const { result } = await delugeRPC(config, url, method, params, cookie)
return result return result
} }
throw err throw err
@@ -178,7 +180,7 @@ function flattenDelugeFiles(
// les priorités : fichier voulu = 1 (normal), tous les autres = 0 (skip). // les priorités : fichier voulu = 1 (normal), tous les autres = 0 (skip).
async function applyFilePriority( async function applyFilePriority(
config : Record<string, string | number>, config : ClientConfig,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
): Promise<void> { ): Promise<void> {
@@ -211,6 +213,7 @@ const delugeDriver: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, { 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: '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: '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 { randomUUID } from 'crypto'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { Agent, fetch as undiciFetch } from 'undici'
import { DATA_DIR } from '../config.js' import { DATA_DIR } from '../config.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── 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 { export interface FieldDef {
key : string key : string
label : string label : string
type : 'text' | 'password' | 'number' | 'url' type : 'text' | 'password' | 'number' | 'url' | 'boolean'
placeholder?: string placeholder?: string
required : boolean required : boolean
default? : string | number default? : string | number | boolean
} }
export interface TorrentClientDefinition { export interface TorrentClientDefinition {
id : string id : string
label : string label : string
fields : FieldDef[] 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 { export interface SavedClient {
uuid : string uuid : string
name : string name : string
type : string type : string
config: Record<string, string | number> config: ClientConfig
} }
export interface TorrentFileProgress { export interface TorrentFileProgress {
@@ -67,12 +74,50 @@ export interface DownloadOptions {
export interface TorrentClientDriver { export interface TorrentClientDriver {
definition : TorrentClientDefinition definition : TorrentClientDefinition
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }> test : (config: ClientConfig) => Promise<{ ok: boolean; message: string }>
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }> healthcheck : (config: ClientConfig) => Promise<{ online: boolean; version?: string }>
add : (config: Record<string, string | number>, url: string, options?: DownloadOptions) => Promise<void> add : (config: ClientConfig, url: string, options?: DownloadOptions) => Promise<void>
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]> list : (config: ClientConfig, category?: string) => Promise<TorrentInfo[]>
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void> remove : (config: ClientConfig, hash: string, deleteFiles?: boolean) => Promise<void>
getFiles ?: (config: Record<string, string | number>, hash: string) => Promise<TorrentFileProgress[]> 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 ───────────────────────────────────────────────────────────────── // ─── Registry ─────────────────────────────────────────────────────────────────
@@ -94,7 +139,64 @@ export function getAvailableClients(): TorrentClientDefinition[] {
// ─── Persistence ────────────────────────────────────────────────────────────── // ─── 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[] { function loadClients(): SavedClient[] {
try { try {
@@ -114,7 +216,7 @@ export function listClients(): SavedClient[] {
return loadClients() 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 clients = loadClients()
const client: SavedClient = { uuid: randomUUID(), name, type, config } const client: SavedClient = { uuid: randomUUID(), name, type, config }
clients.push(client) clients.push(client)
@@ -129,6 +231,7 @@ export function removeClient(uuid: string): boolean {
const filtered = clients.filter(c => c.uuid !== uuid) const filtered = clients.filter(c => c.uuid !== uuid)
if (filtered.length === clients.length) return false if (filtered.length === clients.length) return false
saveClients(filtered) saveClients(filtered)
clearManagedHashes(uuid)
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`) logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
return true return true
} }
@@ -137,7 +240,7 @@ export function getClient(uuid: string): SavedClient | undefined {
return loadClients().find(c => c.uuid === uuid) 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 clients = loadClients()
const index = clients.findIndex(c => c.uuid === uuid) const index = clients.findIndex(c => c.uuid === uuid)
if (index === -1) return null if (index === -1) return null
@@ -243,6 +346,10 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
} }
try { try {
await driver.add(client.config, url, options) 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}"`) logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
results.push({ uuid: client.uuid, name: client.name, ok: true }) results.push({ uuid: client.uuid, name: client.name, ok: true })
} catch (err) { } catch (err) {
@@ -266,6 +373,7 @@ export async function dispatchRemove(hash: string, deleteFiles = false): Promise
try { try {
await driver.remove(client.config, hash, deleteFiles) await driver.remove(client.config, hash, deleteFiles)
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`) logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
untrackManagedHash(client.uuid, hash)
found = true found = true
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' 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 }) results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
} }
} catch (err) { } catch (err) {
+24 -22
View File
@@ -3,7 +3,8 @@
* Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication) * 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' import { logger } from '../logger.js'
// qBittorrent répond normalement "Ok." mais certains setups (reverse proxy, etc.) // 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 // 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) { if (config.apiKey) {
return { Authorization: `Bearer ${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 } return { Cookie: cookie }
} }
async function qbLogin(config: Record<string, string | number>): Promise<string> { async function qbLogin(config: ClientConfig): Promise<string> {
const form = new URLSearchParams() const form = new URLSearchParams()
form.append('username', String(config.username ?? '')) form.append('username', String(config.username ?? ''))
form.append('password', String(config.password ?? '')) 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', method : 'POST',
body : form, body : form,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -55,9 +56,9 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
return cookie 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 { 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, headers: authHeaders,
}) })
if (!res.ok) return false 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). * puis applique les priorités (désactive tout sauf la cible).
*/ */
async function qbApplyFilePriority( async function qbApplyFilePriority(
config : Record<string, string | number>, config : ClientConfig,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
): Promise<void> { ): Promise<void> {
@@ -82,7 +83,7 @@ async function qbApplyFilePriority(
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500)) await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
try { try {
const authH = await qbAuth(config) 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, headers: authH,
}) })
if (!res.ok) continue if (!res.ok) continue
@@ -105,13 +106,13 @@ async function qbApplyFilePriority(
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex) const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
if (unwanted.length > 0) { if (unwanted.length > 0) {
const body = new URLSearchParams({ hash, id: unwanted.join('|'), priority: '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 } if (!r.ok) { logger.warn('qbittorrent', `filePrio désactivation échouée (${r.status}) — retry`); continue }
} }
} }
// Activer le fichier cible (dans tous les cas) // 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', method: 'POST',
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }), body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
headers, headers,
@@ -119,7 +120,7 @@ async function qbApplyFilePriority(
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue } if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
// Vérification : le fichier cible est bien à priorité > 0 // 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) { if (verif.ok) {
const verifiedFiles: any[] = await verif.json() const verifiedFiles: any[] = await verif.json()
const target = verifiedFiles[fileIndex] const target = verifiedFiles[fileIndex]
@@ -152,13 +153,14 @@ const QB: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, { 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: '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: '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) { async test(config) {
try { try {
const authH = await qbAuth(config) 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}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`) logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' } return { ok: true, message: 'Connexion réussie' }
@@ -172,7 +174,7 @@ const QB: TorrentClientDriver = {
async healthcheck(config) { async healthcheck(config) {
try { try {
const authH = await qbAuth(config) 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, headers: authH,
}) })
if (!res.ok) return { online: false } if (!res.ok) return { online: false }
@@ -190,7 +192,7 @@ const QB: TorrentClientDriver = {
const authH = await qbAuth(config) const authH = await qbAuth(config)
const params = new URLSearchParams() const params = new URLSearchParams()
if (category) params.set('category', category) 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, headers: authH,
}) })
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`) if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
@@ -200,7 +202,7 @@ const QB: TorrentClientDriver = {
const fileMap = new Map<string, any[]>() const fileMap = new Map<string, any[]>()
await Promise.all(data.map(async (t: any) => { await Promise.all(data.map(async (t: any) => {
try { 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, headers: authH,
}) })
if (r.ok) fileMap.set(t.hash, await r.json()) if (r.ok) fileMap.set(t.hash, await r.json())
@@ -237,7 +239,7 @@ const QB: TorrentClientDriver = {
async getFiles(config, hash) { async getFiles(config, hash) {
const authH = await qbAuth(config) 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, headers: authH,
}) })
if (!res.ok) return [] if (!res.ok) return []
@@ -275,7 +277,7 @@ const QB: TorrentClientDriver = {
let knownHashes: Set<string> = new Set() let knownHashes: Set<string> = new Set()
if (!hash) { if (!hash) {
try { 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())) if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
} catch {} } catch {}
} }
@@ -292,7 +294,7 @@ const QB: TorrentClientDriver = {
params.set('urls', url) params.set('urls', url)
if (config.category) params.set('category', String(config.category)) if (config.category) params.set('category', String(config.category))
if (config.savePath) params.set('savepath', String(config.savePath)) 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, method: 'POST', body: params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }, 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.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath)) if (config.savePath) form.append('savepath', String(config.savePath))
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent') 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() const text = await res.text()
if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`) if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`)
@@ -325,7 +327,7 @@ const QB: TorrentClientDriver = {
await new Promise(r => setTimeout(r, 500)) await new Promise(r => setTimeout(r, 500))
try { try {
const ah = await qbAuth(config) 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 if (!lr.ok) continue
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase())) const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
if (!newT) continue if (!newT) continue
@@ -347,7 +349,7 @@ const QB: TorrentClientDriver = {
if (config.category) params.set('category', String(config.category)) if (config.category) params.set('category', String(config.category))
if (config.savePath) params.set('savepath', String(config.savePath)) 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, method: 'POST', body: params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
}) })
@@ -359,7 +361,7 @@ const QB: TorrentClientDriver = {
async remove(config, hash, deleteFiles = false) { async remove(config, hash, deleteFiles = false) {
const authH = await qbAuth(config) const authH = await qbAuth(config)
const params = new URLSearchParams({ hashes: hash, deleteFiles: deleteFiles ? 'true' : 'false' }) 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', method : 'POST',
body : params, body : params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }, 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). * (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' import { logger } from '../logger.js'
const RD_BASE = 'https://api.real-debrid.com/rest/1.0' const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
function rdHeaders(config: Record<string, string | number>) { function rdHeaders(config: ClientConfig) {
return { return {
Authorization: `Bearer ${config.apiKey}`, Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@@ -26,7 +26,7 @@ function rdHeaders(config: Record<string, string | number>) {
} }
async function rdFetch( async function rdFetch(
config : Record<string, string | number>, config : ClientConfig,
method : 'GET' | 'POST' | 'DELETE', method : 'GET' | 'POST' | 'DELETE',
path : string, path : string,
body ?: URLSearchParams, body ?: URLSearchParams,
+8 -6
View File
@@ -5,7 +5,8 @@
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC). * 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' import { logger } from '../logger.js'
// ─── XML-RPC helpers ────────────────────────────────────────── // ─── XML-RPC helpers ──────────────────────────────────────────
@@ -55,7 +56,7 @@ function parseXmlValue(node: Element): any {
} }
async function rpcCall( async function rpcCall(
config : Record<string, string | number>, config : ClientConfig,
method : string, method : string,
params : any[] = [], params : any[] = [],
): Promise<any> { ): Promise<any> {
@@ -69,7 +70,7 @@ async function rpcCall(
const rpcPath = String(config.rpcPath || '/RPC2') const rpcPath = String(config.rpcPath || '/RPC2')
const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}` 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}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
const text = await res.text() 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 // multicall pour récupérer plusieurs champs en une requête
async function d_multicall( async function d_multicall(
config : Record<string, string | number>, config : ClientConfig,
view : string, view : string,
methods: string[], methods: string[],
): Promise<any[][]> { ): Promise<any[][]> {
@@ -118,7 +119,7 @@ async function d_multicall(
return Array.isArray(result) ? result : [] 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 { try {
const rows = await d_multicall(config, 'main', ['d.hash']) const rows = await d_multicall(config, 'main', ['d.hash'])
return rows.some(r => String(r[0]).toLowerCase() === hash.toLowerCase()) 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. * On set les priorités dès que les fichiers sont disponibles, avant que le download réel commence.
*/ */
async function rtApplyFilePriority( async function rtApplyFilePriority(
config : Record<string, string | number>, config : ClientConfig,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
): Promise<void> { ): Promise<void> {
@@ -192,6 +193,7 @@ const RT: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, { 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: '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: '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 },
], ],
}, },
+240 -84
View File
@@ -2,23 +2,89 @@
* Synology Download Station Driver * Synology Download Station Driver
*/ */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js' import fs from 'fs'
import { logger } from '../logger.js' 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'] { function mapState(status: string): TorrentInfo['state'] {
if (status === 'downloading') return 'downloading' if (status === 'downloading') return 'downloading'
if (status === 'seeding' || status === 'finished') return 'seeding' if (status === 'seeding' || status === 'finished') return 'seeding'
if (status === 'finishing' || status === 'extracting') return 'seeding' if (status === 'finishing' || status === 'extracting') return 'seeding'
if (status === 'paused') return 'paused' if (status === 'paused') return 'paused'
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading' if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
if (status === 'hash_checking') return 'checking' if (status === 'hash_checking') return 'checking'
if (status === 'error') return 'error' if (status === 'error') return 'error'
return 'unknown' return 'unknown'
} }
// Authentification DSM — retourne un SID // ─── Auth ──────────────────────────────────────────────────────────────────────
async function dsLogin(config: Record<string, string | number>): Promise<string> {
async function dsLogin(config: ClientConfig): Promise<string> {
const params = new URLSearchParams({ const params = new URLSearchParams({
api : 'SYNO.API.Auth', api : 'SYNO.API.Auth',
version: '3', version: '3',
@@ -28,8 +94,7 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
session: 'DownloadStation', session: 'DownloadStation',
format : 'sid', format : 'sid',
}) })
const res = await clientFetch(config, `${config.url}/webapi/auth.cgi?${params}`)
const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() const data = await res.json()
if (!data.success) throw new Error(`Auth échouée : ${JSON.stringify(data.error)}`) if (!data.success) throw new Error(`Auth échouée : ${JSON.stringify(data.error)}`)
@@ -37,7 +102,7 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
} }
async function dsRequest( async function dsRequest(
config : Record<string, string | number>, config : ClientConfig,
api : string, api : string,
method : string, method : string,
version: string, version: string,
@@ -45,25 +110,29 @@ async function dsRequest(
sid : string, sid : string,
): Promise<any> { ): Promise<any> {
const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra }) 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}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() const data = await res.json()
if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`) if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`)
return data.data return data.data
} }
// ─── Driver ────────────────────────────────────────────────────────────────────
const DS: TorrentClientDriver = { const DS: TorrentClientDriver = {
definition: { definition: {
id : 'synology-ds', id : 'synology-ds',
label : 'Synology Download Station', label : 'Synology Download Station',
fields: [ filterByManagedHashes: true,
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true }, fields : [
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true }, { key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true }, { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false }, { key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false }, { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', 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 },
], ],
}, },
@@ -88,15 +157,13 @@ const DS: TorrentClientDriver = {
method : 'getinfo', method : 'getinfo',
_sid : sid, _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 } if (!res.ok) return { online: false }
const data = await res.json() const data = await res.json()
if (!data.success) return { online: false } if (!data.success) return { online: false }
const version = data.data?.version_string ?? 'inconnue' const version = data.data?.version_string ?? 'inconnue'
logger.debug('synology-ds', `Healthcheck OK — version ${version}`)
return { online: true, version } return { online: true, version }
} catch (err) { } catch {
logger.debug('synology-ds', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false } return { online: false }
} }
}, },
@@ -104,104 +171,193 @@ const DS: TorrentClientDriver = {
async list(config, category) { async list(config, category) {
const sid = await dsLogin(config) const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
{ additional: 'transfer,detail' }, sid) { additional: 'transfer,detail,file' }, sid)
const tasks: any[] = data?.tasks ?? [] const tasks: any[] = data?.tasks ?? []
return tasks // Filtrage par dossier cible — Synology n'a pas de système de labels/catégories.
.map(t => { // Si savePath est configuré, on ne garde que les tâches dont la destination correspond.
const transfer = t.additional?.transfer ?? {} const normalizedSavePath = String(config.savePath ?? '').replace(/\/+$/, '').toLowerCase()
const detail = t.additional?.detail ?? {} const filtered = normalizedSavePath
const size = t.size ?? 0 ? tasks.filter(t => {
const dl = transfer.size_downloaded ?? 0 const dest = String(t.additional?.detail?.destination ?? '').replace(/\/+$/, '').toLowerCase()
return dest === normalizedSavePath || dest.startsWith(normalizedSavePath + '/')
// Synology expose le hash BT dans additional.detail.hash
// Fallback sur dbid_ pour les tâches non-BT (DDL, etc.)
const rawHash = detail.hash
const hash = (typeof rawHash === 'string' && rawHash.length > 0)
? rawHash.toLowerCase()
: `dbid_${t.id}`
return {
hash,
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 ?? '',
} satisfies TorrentInfo
}) })
: 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) { 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 sid = await dsLogin(config)
const makeBody = (withDestination: boolean) => { // Snapshot des IDs existants avant l'ajout — pour identifier la nouvelle tâche Synology
const body = new URLSearchParams({ 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 doAdd = async (withDestination: boolean) => {
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task', api : 'SYNO.DownloadStation.Task',
version: '1', version: '1',
method : 'create', method : 'create',
_sid : sid, _sid : sid,
uri : url, uri : url,
}) })
if (withDestination && config.savePath) body.append('destination', String(config.savePath)) if (withDestination && config.savePath) params.append('destination', String(config.savePath))
return body const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
}
const doRequest = async (body: URLSearchParams) => {
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi`, {
method : 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body : body.toString(),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() return res.json()
} }
let data = await doRequest(makeBody(true)) let data = await doAdd(true)
// Code 403 = destination inexistante sur le NAS → réessai sans destination // Code 403 = dossier cible introuvable sur le NAS → réessai sans destination
if (!data.success && data.error?.code === 403 && config.savePath) { if (!data.success && data.error?.code === 403 && config.savePath) {
logger.warn('synology-ds', `Dossier cible "${config.savePath}" introuvable (code 403) — ajout sans destination`) logger.warn('synology-ds', `Dossier cible "${config.savePath}" introuvable (code 403) — ajout sans destination`)
data = await doRequest(makeBody(false)) data = await doAdd(false)
} }
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`) 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})` : ''}`) logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
}, },
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?) { async remove(config, hash, _deleteFiles?) {
const sid = await dsLogin(config) const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid) const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid)
const tasks: any[] = data?.tasks ?? [] const tasks: any[] = data?.tasks ?? []
const h = hash.toLowerCase() const h = hash.toLowerCase()
const synoIdFromHash = _hashToSynoId.get(h) ?? ''
const found = tasks.find((t: any) => { const found = tasks.find((t: any) => {
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase() const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
const detailUri = String(t.additional?.detail?.uri ?? '').toLowerCase()
const synoId = String(t.id ?? '').toLowerCase() const synoId = String(t.id ?? '').toLowerCase()
return detailHash === h || synoId === h || `dbid_${synoId}` === h const cachedHash = _uriToHash.get(detailUri) ?? ''
return detailHash === h
|| synoId === h
|| cachedHash === h
|| (synoIdFromHash && synoId === synoIdFromHash)
}) })
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`) if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
const params = new URLSearchParams({ const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task', api : 'SYNO.DownloadStation.Task',
version: '1', version : '1',
method : 'delete', method : 'delete',
id : found.id, id : found.id,
force_complete: 'false', force_complete: 'false',
_sid : sid, _sid : sid,
}) })
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}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
logger.info('synology-ds', `Torrent ${found.id} supprimé`) logger.info('synology-ds', `Torrent ${found.id} supprimé`)
}, },
+7 -5
View File
@@ -2,7 +2,8 @@
* Transmission Driver * 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' import { logger } from '../logger.js'
function mapState(status: number): TorrentInfo['state'] { function mapState(status: number): TorrentInfo['state'] {
@@ -17,7 +18,7 @@ function mapState(status: number): TorrentInfo['state'] {
} }
async function trRequest( async function trRequest(
config: Record<string, string | number>, config: ClientConfig,
method: string, method: string,
args: Record<string, unknown> = {}, args: Record<string, unknown> = {},
sessionId = '' sessionId = ''
@@ -31,7 +32,7 @@ async function trRequest(
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`) 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', method : 'POST',
headers, headers,
body : JSON.stringify({ method, arguments: args }), body : JSON.stringify({ method, arguments: args }),
@@ -52,7 +53,7 @@ async function trRequest(
return { ...data, sessionId } 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 data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
const torrents = data.arguments?.torrents ?? [] const torrents = data.arguments?.torrents ?? []
return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null 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. * puis applique les priorités : fichier cible voulu, tous les autres ignorés.
*/ */
async function trApplyFilePriority( async function trApplyFilePriority(
config : Record<string, string | number>, config : ClientConfig,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
): Promise<void> { ): Promise<void> {
@@ -102,6 +103,7 @@ const TR: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, { 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: '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: '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 * 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' import { logger } from '../logger.js'
// uTorrent status flags (bitmask) : // uTorrent status flags (bitmask) :
@@ -21,7 +22,7 @@ function mapState(status: number): TorrentInfo['state'] {
// uTorrent nécessite un token CSRF + cookie à chaque session // uTorrent nécessite un token CSRF + cookie à chaque session
interface UTSession { token: string; cookie: string } 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 { try {
const session = await utGetSession(config) const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getfiles', hash: hash.toUpperCase() }, session) 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). * tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal).
*/ */
async function utApplyFilePriority( async function utApplyFilePriority(
config : Record<string, string | number>, config : ClientConfig,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
): Promise<void> { ): Promise<void> {
@@ -54,13 +55,13 @@ async function utApplyFilePriority(
// Passer tous les fichiers à priorité 0 (skip) // Passer tous les fichiers à priorité 0 (skip)
const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' }) 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)) 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 }, headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
}) })
// Activer le fichier cible (priorité 2 = normal) // Activer le fichier cible (priorité 2 = normal)
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) }) 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 }, 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)}…`) 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 auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const headers : Record<string, string> = { const headers : Record<string, string> = {
'Authorization': `Basic ${auth}`, '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`) if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`)
const text = await res.text() const text = await res.text()
@@ -90,14 +91,14 @@ async function utGetSession(config: Record<string, string | number>): Promise<UT
} }
async function utRequest( async function utRequest(
config : Record<string, string | number>, config : ClientConfig,
params : Record<string, string>, params : Record<string, string>,
session: UTSession, session: UTSession,
): Promise<any> { ): Promise<any> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const qs = new URLSearchParams({ token: session.token, ...params }) 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: { headers: {
'Authorization': `Basic ${auth}`, 'Authorization': `Basic ${auth}`,
'Cookie' : session.cookie, 'Cookie' : session.cookie,
@@ -142,6 +143,7 @@ const UT: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, { 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: '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: '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 }) const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url })
if (config.savePath) qs.set('path', String(config.savePath)) 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', method : 'GET',
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie }, headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
}) })
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="w-24 flex-shrink-0"> <div class="w-24 flex-shrink-0">
<img <img
v-if="ep.cover" v-if="ep.cover"
:src="imageUrl(ep.cover)" :src="imageUrl(ep.cover) ?? undefined"
:alt="ep.name" :alt="ep.name"
class="w-full aspect-video object-cover" class="w-full aspect-video object-cover"
/> />
+11 -30
View File
@@ -20,8 +20,10 @@
<!-- Dossier vide / introuvable --> <!-- 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"> <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-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">Placez vos fichiers dans <span class="font-mono text-primary">{{ serieFolder }}</span> puis réessayez.</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> <button @click="scan" class="btn-secondary mt-2">Rescanner</button>
</div> </div>
@@ -77,7 +79,7 @@
<!-- Sélecteur épisode --> <!-- Sélecteur épisode -->
<select <select
v-model="items[i].episode_id" v-model="item.episode_id"
class="settings-input text-xs py-1 max-w-[260px] shrink-0" class="settings-input text-xs py-1 max-w-[260px] shrink-0"
@change="onEpisodeChange(i)" @change="onEpisodeChange(i)"
> >
@@ -154,6 +156,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { matchEpisodeFile } from '@/utils/episode-match'
const { add: toast } = useToast() const { add: toast } = useToast()
@@ -176,6 +179,7 @@ const scanning = ref(false)
const importing = ref(false) const importing = ref(false)
const importError = ref('') const importError = ref('')
const scanError = ref('') const scanError = ref('')
const folderMissing = ref(false)
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] }) const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
// Dossier de la série dans la médiathèque // 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 } { function autoMatch(filename: string): { episode_id: number | null; hash: string | null } {
const nameNoExt = filename.replace(/\.[^.]+$/, '').toLowerCase() const episodeId = matchEpisodeFile(filename, props.seasons)
return { episode_id: episodeId, hash: episodeId === null ? null : getHashForEpisode(episodeId) }
for (const season of props.seasons) {
for (const ep of season.episodes) {
const candidates = [
ep.original_filename,
ep.nfo_filename?.replace(/\.nfo$/, ''),
ep.formatted_name?.replace(/[<>:"/\\|?*]/g, '').trim(),
].filter(Boolean).map((s: string) => s.toLowerCase().replace(/\.[^.]+$/, ''))
if (candidates.some(c => c === nameNoExt || nameNoExt.startsWith(c + '.'))) {
return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
}
}
}
// Fallback numéro d'épisode
const numMatch = filename.match(/[.\s_-]0*(\d{1,3})[.\s_-]/i)
if (numMatch) {
const num = parseInt(numMatch[1], 10)
for (const season of props.seasons) {
const ep = season.episodes.find((e: any) => e.episode_number === num)
if (ep) return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
}
}
return { episode_id: null, hash: null }
} }
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
@@ -268,6 +247,7 @@ function formatSize(bytes: number): string {
function onEpisodeChange(i: number) { function onEpisodeChange(i: number) {
const item = items.value[i] const item = items.value[i]
if (!item) return
const newEpId = item.episode_id // v-model a déjà mis à jour la valeur const newEpId = item.episode_id // v-model a déjà mis à jour la valeur
item.hash = getHashForEpisode(Number(newEpId)) item.hash = getHashForEpisode(Number(newEpId))
@@ -297,6 +277,7 @@ async function scan() {
return return
} }
const data = await res.json() const data = await res.json()
folderMissing.value = data.exists === false
const files: { name: string; path: string; size: number }[] = data.files ?? [] const files: { name: string; path: string; size: number }[] = data.files ?? []
const organizedByPath = buildOrganizedByPath() const organizedByPath = buildOrganizedByPath()
+2 -2
View File
@@ -19,7 +19,7 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
const STORAGE_KEY = 'fankarr-theme' const STORAGE_KEY = 'fankarr-theme'
@@ -32,7 +32,7 @@ const themes = [
const current = ref('indigo') const current = ref('indigo')
function setTheme(id) { function setTheme(id: string) {
current.value = id current.value = id
document.documentElement.setAttribute('data-theme', id) document.documentElement.setAttribute('data-theme', id)
localStorage.setItem(STORAGE_KEY, id) localStorage.setItem(STORAGE_KEY, id)
+36 -30
View File
@@ -115,7 +115,7 @@
</button> </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"> <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 <button
v-for="(t, i) in season.torrents" v-for="(t, i) in (season.torrents as any[])"
:key="i" :key="i"
:title="t.raw ?? t.torrent_name ?? ''" :title="t.raw ?? t.torrent_name ?? ''"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors" class="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"> 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> <p class="px-3 pt-1.5 pb-0.5 text-[10px] text-muted font-medium uppercase tracking-wide">Choisir le torrent</p>
<button <button
v-for="(t, i) in ep.torrents" v-for="(t, i) in (ep.torrents as any[])"
:key="i" :key="i"
:title="t.raw ?? t.torrent_name ?? ''" :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" 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" <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"> 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 <button
v-for="(t, i) in ep.torrents" v-for="(t, i) in (ep.torrents as any[])"
:key="i" :key="i"
:title="t.raw ?? t.torrent_name ?? ''" :title="t.raw ?? t.torrent_name ?? ''"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors" class="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 ──────────────────────────────────────────── // ── Computed saison ────────────────────────────────────────────
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length) 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(() => const hasDownloadable = computed(() =>
props.season.episodes.some((ep: any) => props.season.episodes.some((ep: any) =>
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`) ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)
@@ -540,7 +530,7 @@ function handleEpRequestClick(ep: any) {
return return
} }
// Torrent unique ou aucun → émettre directement // 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' { 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): { function _parseEpChap(plot: string | null | undefined): {
episodes: string | null episodes: string | null
chapters: string | null season: string | null
chapters: string |null
cleanPlot: string cleanPlot: string
} { } {
const text = plot ?? '' const text = plot ?? ''
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i) const epMatch = text.match(
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i) /[é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 const episodes = epMatch
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`) ? `${season ? `S${season} · ` : ''}${
: null epMatch[2]
const chapters = chapMatch ? `Ép. ${epMatch[1]}–${epMatch[2]}`
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`) : `Ép. ${epMatch[1]}`
: null }`
: 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 let cleanPlot = text
if (episodes || chapters) { if (episodes || chapters || season) {
cleanPlot = text cleanPlot = text
.split(/\r?\n/) .split(/\r?\n/)
.filter(line => !/[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+/i.test(line)) .filter(line => !/(?:[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+|saison\s+\d+)/i.test(line))
.join('\n') .join('\n')
.replace(/\n{3,}/g, '\n\n') .replace(/\n{3,}/g, '\n\n')
.trim() .trim()
} }
return { episodes, chapters, cleanPlot } return { episodes, season, chapters, cleanPlot }
} }
function parseEpChap(plot: string | null | undefined) { function parseEpChap(plot: string | null | undefined) {
if (_epChapCache.has(plot)) return _epChapCache.get(plot)! if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
+16 -1
View File
@@ -75,6 +75,18 @@
</button> </button>
</div> </div>
</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> </div>
<!-- Ligne 2 : tri + taille + count --> <!-- Ligne 2 : tri + taille + count -->
@@ -121,7 +133,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' 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' import { onClickOutside } from '@vueuse/core'
defineProps<{ defineProps<{
@@ -136,6 +148,8 @@ defineProps<{
filtersImport : { label: string; value: string; color: string }[] filtersImport : { label: string; value: string; color: string }[]
sortOptions : { label: string; value: string }[] sortOptions : { label: string; value: string }[]
legend : { label: string; color: string }[] legend : { label: string; color: string }[]
selectable? : boolean
selecting? : boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -143,6 +157,7 @@ const emit = defineEmits<{
'update:activeFilter': [value: string] 'update:activeFilter': [value: string]
'update:activeSort' : [value: string] 'update:activeSort' : [value: string]
'update:posterSize' : [value: string] 'update:posterSize' : [value: string]
'update:selecting' : [value: boolean]
}>() }>()
const filterOpen = ref(false) const filterOpen = ref(false)
+1 -4
View File
@@ -11,11 +11,8 @@ interface Toast {
const toasts = ref<Toast[]>([]) const toasts = ref<Toast[]>([])
let nextId = 0 let nextId = 0
const DURATION = 3500 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') { function add(message: string, type: Toast['type'] = 'info') {
const now = Date.now()
// Cherche un toast récent du même type à grouper // Cherche un toast récent du même type à grouper
const existing = toasts.value.find(t => t.type === type) 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) { function remove(id: number) {
const i = toasts.value.findIndex(t => t.id === id) const i = toasts.value.findIndex(t => t.id === id)
if (i !== -1) { if (i !== -1) {
clearTimeout(toasts.value[i].timer) clearTimeout(toasts.value[i]?.timer)
toasts.value.splice(i, 1) 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 SidebarNav from '@/components/nav/SidebarNav.vue'
import { useDownloadsStore } from '@/stores/downloads' import { useDownloadsStore } from '@/stores/downloads'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { NavItem } from '@/types/nav' import type { NavItem, NavChild } from '@/types/nav'
const mobileOpen = ref(false) const mobileOpen = ref(false)
const dlStore = useDownloadsStore() const dlStore = useDownloadsStore()
@@ -79,7 +79,7 @@ const navItems = computed<NavItem[]>(() => {
items.push({ separator: true }) items.push({ separator: true })
const settingsChildren: NavItem[] = [ const settingsChildren: NavChild[] = [
{ label: 'Mon profil', to: '/settings/profile' }, { label: 'Mon profil', to: '/settings/profile' },
] ]
if (auth.isAdmin) { if (auth.isAdmin) {
+20 -3
View File
@@ -2,10 +2,16 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
export interface TorrentRef { export interface TorrentRef {
torrent_raw: string
torrent_url: string | null torrent_url: string | null
magnet: string | null magnet: string | null
type: string 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 { export interface Episode {
@@ -20,7 +26,10 @@ export interface Episode {
has_thumbnail: boolean has_thumbnail: boolean
season_id: number season_id: number
available: boolean available: boolean
torrent: TorrentRef | null // non-null seulement si type=episode organized: boolean
fankai: boolean | null
torrent: TorrentRef | null
torrents: TorrentRef[]
} }
export interface Season { export interface Season {
@@ -33,6 +42,9 @@ export interface Season {
premiered: string | null premiered: string | null
episodes: Episode[] episodes: Episode[]
torrent: TorrentRef | null torrent: TorrentRef | null
torrents: TorrentRef[]
organized_state: 'none' | 'partial' | 'complete'
organized_count: number
} }
export interface Serie { export interface Serie {
@@ -46,12 +58,17 @@ export interface Serie {
status: string | null status: string | null
torrent_count: number torrent_count: number
has_torrents: boolean has_torrents: boolean
download_state: 'none' | 'downloading' | 'partial' | 'complete'
in_client: boolean
has_files: boolean
rss_synced: boolean
} }
export interface SerieDetail { export interface SerieDetail {
serie: Serie serie: Serie
seasons: Season[] 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', () => { 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" <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"> 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"> <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>
<span class="text-muted">{{ r.username }}</span> <span class="text-muted">{{ r.username }}</span>
<span v-if="r.episodes?.length > 0" class="text-muted/60"> <span v-if="r.episodes?.length > 0" class="text-muted/60">
@@ -179,7 +179,7 @@ const filters = [
{ value: 'approved', label: 'Approuvées' }, { value: 'approved', label: 'Approuvées' },
{ value: 'completed', label: 'Disponibles' }, { value: 'completed', label: 'Disponibles' },
{ value: 'rejected', label: 'Refusées' }, { value: 'rejected', label: 'Refusées' },
] ] as const
const counts = computed(() => ({ const counts = computed(() => ({
all : requests.value.length, all : requests.value.length,
+108 -30
View File
@@ -103,6 +103,34 @@
</div> </div>
</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) --> <!-- 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="!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"> <div v-if="myRequest" class="flex items-center gap-3">
@@ -377,6 +405,12 @@ const downloadMenuOpen = ref(false)
const downloadingAll = ref(false) const downloadingAll = ref(false)
const downloadingSeason = ref<Record<number, boolean>>({}) const downloadingSeason = ref<Record<number, boolean>>({})
const rssSync = ref(false) 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 } export interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[]; save_path?: string; name?: string }
const activeTorrents = ref<ActiveTorrent[]>([]) const activeTorrents = ref<ActiveTorrent[]>([])
@@ -405,6 +439,16 @@ function integraleGroupLabels(): string[] {
} }
// Épisodes couverts par une intégrale donnée (hash commun) // É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> { function integraleEpisodeCoverage(integraleIndex: number): Set<number> {
const covered = new Set<number>() const covered = new Set<number>()
const t = data.value?.torrents_integrale[integraleIndex] 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' }) const res = await fetch(`/api/organized/${route.params.id}`, { credentials: 'include' })
if (res.ok) organizedByEpisode.value = await res.json() if (res.ok) organizedByEpisode.value = await res.json()
} catch {} } 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() { async function fetchSettings() {
@@ -503,6 +568,8 @@ async function fetchActiveDownloads() {
const list: any[] = await res.json() const list: any[] = await res.json()
const isFirstPoll = _prevTorrentStates.size === 0 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) { for (const t of list) {
const prevState = _prevTorrentStates.get(t.hash) const prevState = _prevTorrentStates.get(t.hash)
@@ -515,7 +582,7 @@ async function fetchActiveDownloads() {
_prevFileProgresses.set(key, f.progress) _prevFileProgresses.set(key, f.progress)
if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) { if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) {
_recentlyOrganized.add(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) // 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)) { if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) {
_recentlyOrganized.add(t.hash) _recentlyOrganized.add(t.hash)
triggerOrganize(t) toOrganize.set(t.hash, t)
} }
_prevTorrentStates.set(t.hash, t.state) _prevTorrentStates.set(t.hash, t.state)
} }
for (const t of toOrganize.values()) triggerOrganize(t)
activeTorrents.value = list.map(t => ({ activeTorrents.value = list.map(t => ({
hash : t.hash, hash : t.hash,
progress : t.progress ?? 0, progress : t.progress ?? 0,
@@ -582,6 +651,12 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
const result: DlItem[] = [] const result: DlItem[] = []
const covered = new Set<number>() 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) ─────────────────────────────────────────── // ── 1. Intégrale(s) ───────────────────────────────────────────
const integrales: { t: any; i: number }[] = integraleIndex !== undefined const integrales: { t: any; i: number }[] = integraleIndex !== undefined
? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }] ? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }]
@@ -589,15 +664,23 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
for (const { t, i } of integrales) { for (const { t, i } of integrales) {
if (!t) continue if (!t) continue
if (!isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet }) // Épisodes apportés par CETTE intégrale
// Marquer les épisodes couverts par CETTE intégrale uniquement
const hash = t.infohash?.toLowerCase() ?? extractHash(t) const hash = t.infohash?.toLowerCase() ?? extractHash(t)
const coveredByThis = new Set<number>()
if (hash) if (hash)
for (const season of data.value!.seasons) for (const season of data.value!.seasons)
for (const ep of season.episodes) for (const ep of season.episodes)
if (ep.torrents?.some((et: any) => (et.infohash ?? extractHash(et)) === hash)) 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 ────────────────────────────── // ── 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 // 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)) const hasUncovered = season.episodes.some((ep: any) => ep.available && !ep.organized && !covered.has(ep.id))
if (!hasUncovered) continue if (!hasUncovered) continue
let addedAny = false const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
season.torrents.forEach((t: any, i: number) => { const alreadyHandled = season.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}` const t = season.torrents[0]
if (!isAlreadyQueued(t) && !isDownloaded(key)) { result.push({ key, torrent_url: t.torrent_url, magnet: t.magnet }); addedAny = true } 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)
if (addedAny) for (const ep of season.episodes) covered.add(ep.id)
} }
// ── 3. Épisodes individuels non couverts ───────────────────── // ── 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 // Le bouton "Tout télécharger" s'affiche s'il y a quoi que ce soit à lancer
const hasSomethingToDownload = computed(() => { // Dérivé de collectDownloadables pour que le bouton n'apparaisse jamais alors
if (!data.value) return false // que le garde-fou versions ne laisserait passer aucun torrent.
if (data.value.torrents_integrale.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))) return true const hasSomethingToDownload = computed(() => collectDownloadables().length > 0)
for (const season of data.value.seasons) {
if (season.torrents?.length && season.organized_state !== 'complete' &&
season.torrents.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`))) return true
for (const ep of season.episodes)
if (ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)) return true
}
return false
})
async function downloadAll(integraleIndex?: number) { async function downloadAll(integraleIndex?: number) {
downloadingAll.value = true downloadingAll.value = true
@@ -651,15 +725,19 @@ async function downloadAll(integraleIndex?: number) {
async function downloadSeason(season: Season, packHash?: string) { async function downloadSeason(season: Season, packHash?: string) {
downloadingSeason.value[season.id] = true 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 const seasonAny = season as any
if (seasonAny.torrents && seasonAny.torrents.length > 0 && seasonAny.organized_state !== 'complete') { if (seasonAny.torrents && seasonAny.torrents.length > 0 && seasonAny.organized_state !== 'complete') {
seasonAny.torrents.forEach((t: any, i: number) => { const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}` const alreadyHandled = seasonAny.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
if (!isAlreadyQueued(t) && !isDownloaded(key)) { if (!alreadyHandled) {
torrents.push({ key, torrent_url: t.torrent_url, magnet: t.magnet }) 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 { } else {
for (const ep of seasonAny.episodes ?? []) { for (const ep of seasonAny.episodes ?? []) {
// Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant // 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" :filters-import="filtersImport"
:sort-options="sortOptions" :sort-options="sortOptions"
:legend="legend" :legend="legend"
:selectable="auth.isAdmin"
v-model:selecting="selecting"
/> />
<!-- Grille --> <!-- Grille -->
@@ -33,14 +35,22 @@
</div> </div>
<div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }"> <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" v-for="serie in filtered"
:key="serie.id" :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="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 <img
v-if="serie.poster_image" v-if="serie.poster_image"
:src="serie.poster_image" :src="serie.poster_image"
@@ -52,10 +62,22 @@
<Tv :size="28" /> <Tv :size="28" />
</div> </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> <span class="text-xs text-white border border-white/30 px-3 py-1 rounded-md">Voir</span>
</div> </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 <div
class="absolute bottom-0 left-0 right-0 h-1" class="absolute bottom-0 left-0 right-0 h-1"
:style="{ background: stateColor(serie.download_state) }" :style="{ background: stateColor(serie.download_state) }"
@@ -77,7 +99,20 @@
</span> </span>
</div> </div>
</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>
</div> </div>
@@ -85,10 +120,10 @@
</template> </template>
<script setup lang="ts"> <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 { RouterLink } from 'vue-router'
import { Tv } from 'lucide-vue-next' import { Tv, Check, Rss, X } from 'lucide-vue-next'
import { useSeriesStore } from '@/stores/series' import { useSeriesStore, type Serie } from '@/stores/series'
import { useDownloadsStore } from '@/stores/downloads' import { useDownloadsStore } from '@/stores/downloads'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
@@ -117,6 +152,7 @@ const filtersImport = [
{ label: 'Importés', value: 'complete', color: '#22c55e' }, { label: 'Importés', value: 'complete', color: '#22c55e' },
{ label: 'En cours', value: 'downloading', color: '#3b82f6' }, { label: 'En cours', value: 'downloading', color: '#3b82f6' },
{ label: 'Partiel', value: 'partial', color: '#9b59b6' }, { label: 'Partiel', value: 'partial', color: '#9b59b6' },
{ label: 'Non suivies', value: 'untracked', color: 'var(--border)' },
] ]
const sortOptions = [ const sortOptions = [
@@ -143,6 +179,7 @@ const filtered = computed(() => {
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete') 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 === '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 === '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()) { if (search.value.trim()) {
const q = search.value.toLowerCase() const q = search.value.toLowerCase()
@@ -163,6 +200,62 @@ const filtered = computed(() => {
return list 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 { function stateColor(state: string): string {
return ({ return ({
complete : '#22c55e', 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> <p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
</div> </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> </template>
</div> </div>
@@ -62,13 +85,33 @@ const { add: toast } = useToast()
const updating = ref(false) const updating = ref(false)
const loaded = ref(false) const loaded = ref(false)
const status = ref({ exists: false, count: 0, empty: true }) const status = ref({ exists: false, count: 0, empty: true })
const syncOrphans = ref<{ serieId: number; serieName: string }[]>([])
onMounted(async () => { onMounted(async () => {
fetchSyncOrphans()
const res = await fetch('/api/torrents/status', { credentials: 'include' }) const res = await fetch('/api/torrents/status', { credentials: 'include' })
if (res.ok) status.value = await res.json() if (res.ok) status.value = await res.json()
loaded.value = true 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() { async function update() {
updating.value = true updating.value = true
try { try {
@@ -77,6 +120,7 @@ async function update() {
const { count } = await res.json() const { count } = await res.json()
status.value = { exists: true, count, empty: count === 0 } status.value = { exists: true, count, empty: count === 0 }
toast(`${count} séries chargées ✓`, 'success') toast(`${count} séries chargées ✓`, 'success')
fetchSyncOrphans()
} else { } else {
const { error } = await res.json() const { error } = await res.json()
toast(error ?? 'Erreur lors de la mise à jour', 'error') toast(error ?? 'Erreur lors de la mise à jour', 'error')
+57 -35
View File
@@ -138,16 +138,25 @@
<!-- Champs principaux --> <!-- Champs principaux -->
<template v-if="currentDefinition"> <template v-if="currentDefinition">
<div v-for="field in basicFields" :key="field.key"> <div v-for="field in basicFields" :key="field.key">
<label class="settings-label"> <SettingsToggle
{{ field.label }} v-if="field.type === 'boolean'"
<span v-if="field.required" class="text-red-400 ml-0.5">*</span> :model-value="toBool(modal.config[field.key])"
</label> :label="field.label"
<input :description="fieldTooltip(field.key)"
v-model="modal.config[field.key]" @update:model-value="modal.config[field.key] = $event"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/> />
<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> </div>
<!-- Paramètres avancés --> <!-- 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-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"> <div v-for="field in advancedFields" :key="field.key">
<label class="settings-label flex items-center gap-1.5"> <SettingsToggle
{{ field.label }} v-if="field.type === 'boolean'"
<span :model-value="toBool(modal.config[field.key])"
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" :label="field.label"
tabindex="-1" :description="fieldTooltip(field.key)"
> @update:model-value="modal.config[field.key] = $event"
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"
/> />
<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> </div>
</div> </div>
@@ -222,6 +240,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { ChevronDown } from 'lucide-vue-next' import { ChevronDown } from 'lucide-vue-next'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
const { add: toast } = useToast() const { add: toast } = useToast()
@@ -254,11 +273,7 @@ const clientMatrix = [
{ {
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1', id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true, 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({ const modal = ref({
@@ -266,24 +281,30 @@ const modal = ref({
uuid : null as string | null, uuid : null as string | null,
name : '', name : '',
type : '', type : '',
config : {} as Record<string, string>, config : {} as Record<string, string | number | boolean>,
testing: false, testing: false,
saving : false, saving : false,
tested : false, tested : false,
}) })
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath'] const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath', 'ignoreCertificateErrors']
const FIELD_TOOLTIPS: Record<string, string> = { 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.', 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.', 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.', 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 { function fieldTooltip(key: string): string {
return FIELD_TOOLTIPS[key] ?? '' 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 advancedOpen = ref(false)
const currentDefinition = computed(() => const currentDefinition = computed(() =>
@@ -367,7 +388,8 @@ function onTypeChange() {
const def = currentDefinition.value const def = currentDefinition.value
if (def) { if (def) {
for (const field of def.fields) { 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 // 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 // Pour les passwords, on reset seulement si la valeur n'est plus le masque
for (const [key, val] of Object.entries(newConfig)) { 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) continue
if (field.type === 'password' && val === '••••••••') continue if (field.type === 'password' && val === '••••••••') continue
modal.value.tested = false modal.value.tested = false
@@ -22,6 +22,14 @@
</svg> </svg>
Actualiser Actualiser
</button> </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 <button
@click="purgeNfo" @click="purgeNfo"
:disabled="purgingNfo" :disabled="purgingNfo"
@@ -164,6 +172,35 @@
</div> </div>
</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> </div>
</template> </template>
@@ -177,12 +214,17 @@ const { add: toast } = useToast()
const loading = ref(true) const loading = ref(true)
const renamingAll = ref(false) const renamingAll = ref(false)
const purgingNfo = ref(false) const purgingNfo = ref(false)
const forcingRefresh = ref(false)
const series = ref<any[]>([]) const series = ref<any[]>([])
const nfoSupport = ref(false) const nfoSupport = ref(false)
const collapsed = ref<Set<number>>(new Set()) const collapsed = ref<Set<number>>(new Set())
const renamingEp = ref<Record<number, boolean>>({}) const renamingEp = ref<Record<number, boolean>>({})
const renameResult = ref<{ done: number; errors: any[] } | null>(null) const renameResult = ref<{ done: number; errors: any[] } | null>(null)
const showOnlyNeedsRename = ref(false) 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 ────────────────────────────────────────────────── // ─── Computed ──────────────────────────────────────────────────
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0)) 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() const data = await res.json()
series.value = data.series ?? [] series.value = data.series ?? []
nfoSupport.value = data.nfo_support ?? false nfoSupport.value = data.nfo_support ?? false
orphans.value = data.orphans ?? []
// Replier les séries sans rename par défaut // 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)) collapsed.value = new Set(series.value.filter(s => s.needs_rename === 0).map((s: any) => s.serie_id))
} finally { } 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() { async function purgeNfo() {
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
purgingNfo.value = true 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) onMounted(load)
</script> </script>
-10
View File
@@ -103,7 +103,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { copyToClipboard } from '@/composables/useClipboard'
const jellyfinUrl = ref('') const jellyfinUrl = ref('')
const jellyfinAdminToken = ref('') const jellyfinAdminToken = ref('')
@@ -116,8 +115,6 @@ const syncing = ref(false)
const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null) const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null)
const syncError = ref<string | null>(null) const syncError = ref<string | null>(null)
const origin = window.location.origin
const apiEndpoints = [ const apiEndpoints = [
{ method: 'POST', path: '/api/v1/auth/jellyfin', desc: 'Échanger un token Jellyfin contre un token FanKarr' }, { method: 'POST', path: '/api/v1/auth/jellyfin', desc: 'Échanger un token Jellyfin contre un token FanKarr' },
{ method: 'GET', path: '/api/v1/auth/me', desc: 'Infos de l\'utilisateur authentifié' }, { method: 'GET', path: '/api/v1/auth/me', desc: 'Infos de l\'utilisateur authentifié' },
@@ -179,13 +176,6 @@ async function syncUsers() {
syncing.value = false syncing.value = false
} }
async function copyToken() {
try {
await copyToClipboard(myToken.value)
tokenCopied.value = true
setTimeout(() => { tokenCopied.value = false }, 2000)
} catch {}
}
</script> </script>
+9 -3
View File
@@ -90,7 +90,7 @@
:model-value="form.nfoSupport" :model-value="form.nfoSupport"
@update:model-value="onNfoToggle" @update:model-value="onNfoToggle"
label="NFO / Métadonnées" 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 <SettingsToggle
v-if="form.organizeMode === 'move'" v-if="form.organizeMode === 'move'"
@@ -103,6 +103,11 @@
label="Désimporter si fichier manquant" 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." 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> </div>
<!-- Plex --> <!-- Plex -->
@@ -159,12 +164,12 @@
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<h3 class="text-sm font-semibold text-primary">Activer les NFO / Métadonnées ?</h3> <h3 class="text-sm font-semibold text-primary">Activer les NFO / Métadonnées ?</h3>
<p class="text-xs text-muted leading-relaxed"> <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 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. et peut créer des conflits.
</p> </p>
<p class="text-xs text-muted leading-relaxed"> <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. ou un lecteur qui lit les fichiers NFO locaux.
</p> </p>
</div> </div>
@@ -215,6 +220,7 @@ const form = ref({
autoImport : true, autoImport : true,
deleteTorrentOnMove : false, deleteTorrentOnMove : false,
autoUnimportMissing : false, autoUnimportMissing : false,
englishDirectory : false,
}) })
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({ 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="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'" :class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
> >
{{ u.username[0].toUpperCase() }} {{ u.username.charAt(0).toUpperCase() }}
</span> </span>
<div> <div>
<p class="text-sm text-primary font-medium">{{ u.username }}</p> <p class="text-sm text-primary font-medium">{{ u.username }}</p>
@@ -137,7 +137,7 @@
<span <span
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold shrink-0" 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'" :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-sm text-primary">{{ u.username }}</span>
<span class="text-xs text-muted">{{ u.role === 'admin' ? 'Admin' : 'Utilisateur' }}</span> <span class="text-xs text-muted">{{ u.role === 'admin' ? 'Admin' : 'Utilisateur' }}</span>
</div> </div>
+3
View File
@@ -3,6 +3,9 @@
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"], "types": ["vite/client"],
"paths": {
"@/*": ["./src/*"]
},
/* Linting */ /* Linting */
"strict": true, "strict": true,
+1 -1
View File
@@ -1 +1 @@
3.18.2 3.23.1