Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c47001c580 | ||
|
|
529173a472 | ||
|
|
f64d2e19f7 | ||
|
|
6348cc40cb | ||
|
|
66a4675321 | ||
|
|
2898627673 | ||
|
|
f6d4001e3c | ||
|
|
c6b26d88f0 | ||
|
|
0ff7f21958 | ||
|
|
1ca7eac48c | ||
|
|
67794d92c8 | ||
|
|
ca971f4daf | ||
|
|
a32b1c64fe | ||
|
|
ffc925dbdb | ||
|
|
f4f213508d | ||
|
|
7f06dd654a | ||
|
|
7479ddf8eb | ||
|
|
0768964041 | ||
|
|
9b66c57a6a | ||
|
|
3eb64b175d | ||
|
|
25a8adc91d | ||
|
|
d533751ffb | ||
|
|
6f1e0dc844 | ||
|
|
894a7ed2b1 | ||
|
|
c87b7a4c7f | ||
|
|
a000e349c9 | ||
|
|
f23e3c04ec | ||
|
|
63146fdc27 | ||
|
|
7795598346 | ||
|
|
04252db0c4 | ||
|
|
aeb2e938ef | ||
|
|
3799d33c62 | ||
|
|
98b317d493 | ||
|
|
4292f96645 | ||
|
|
182c7cc807 | ||
|
|
53be057425 | ||
|
|
ac70dba927 | ||
|
|
f79cc244ef | ||
|
|
14137e1b1b | ||
|
|
43ee4597a9 | ||
|
|
f311c8e2f3 | ||
|
|
48933b4078 | ||
|
|
45288a130f | ||
|
|
73256eb15d | ||
|
|
6855c04fab | ||
|
|
f6f735ad88 | ||
|
|
a7250a5f80 | ||
|
|
d326e08834 | ||
|
|
0e2d513cf7 | ||
|
|
5cf530ba78 | ||
|
|
3bc2e08ca5 | ||
|
|
38996b4095 | ||
|
|
4063a1b8ae | ||
|
|
e9e0b487f4 | ||
|
|
099b0889b0 | ||
|
|
33be4ce687 | ||
|
|
5c7b3dc5cd | ||
|
|
f909f554ff | ||
|
|
fc6d88be5e | ||
|
|
ed556acd1d | ||
|
|
c6c52a1ae7 | ||
|
|
98ed3c7199 | ||
|
|
ee1c7d579c | ||
|
|
ba06dce34d | ||
|
|
8991524463 | ||
|
|
3fe1006a1e | ||
|
|
63c8f8a841 | ||
|
|
25877685ea | ||
|
|
5b111272d4 | ||
|
|
67365336bb | ||
|
|
3a991c8fcf | ||
|
|
d85708588c | ||
|
|
63f6c1276b | ||
|
|
8b4d5e20a9 | ||
|
|
9b22b1f377 | ||
|
|
d1357e7a79 | ||
|
|
e15e7beb1b | ||
|
|
8b4b198697 | ||
|
|
13700c554a | ||
|
|
1e6506aee6 | ||
|
|
2235541000 | ||
|
|
6f5b9e2ee4 | ||
|
|
045213d363 | ||
|
|
1f0c9a338a |
No files matched your search
+2
-1
@@ -5,4 +5,5 @@ node_modules
|
||||
config
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.example
|
||||
.test-sandbox
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -27,3 +27,8 @@ binaries
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Instance de test locale
|
||||
.test-sandbox
|
||||
scripts/seed-test-sandbox.mjs
|
||||
docker-compose.test.yml
|
||||
+130
@@ -1,5 +1,135 @@
|
||||
# Changelog
|
||||
|
||||
## [3.23.1](https://github.com/Masutayunikon/FanKarr/compare/v3.23.0...v3.23.1) (2026-09-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **scrap:** Correction fallback si scraper KO ([f64d2e1](https://github.com/Masutayunikon/FanKarr/commit/f64d2e19f7f9ab838cee6d33f5523fabb22b5cf7))
|
||||
* **scrap:** Correction fallback si scraper KO ([6348cc4](https://github.com/Masutayunikon/FanKarr/commit/6348cc40cb42dd23e3b3e77aa1e96f813b9d2687))
|
||||
|
||||
## [3.23.0](https://github.com/Masutayunikon/FanKarr/compare/v3.22.0...v3.23.0) (2026-09-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **import:** catalogue unifié, renommage de dossier série et nettoya……ge des orphelins corection dédoublonnage ([c6b26d8](https://github.com/Masutayunikon/FanKarr/commit/c6b26d88f0a9c5b1092e162f43ff052efdbffb4d))
|
||||
* **import:** catalogue unifié, renommage de dossier série et nettoyage des orphelins corection dédoublonnage ([0ff7f21](https://github.com/Masutayunikon/FanKarr/commit/0ff7f21958ad33432123f76a2fa7830655329cbb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **import:** repli sur l'API Fankai quand une série est absente du scraper et retrait de Kodi des NFO ([1ca7eac](https://github.com/Masutayunikon/FanKarr/commit/1ca7eac48c9337c39b1d40a43a90c993e90d11ea))
|
||||
* **import:** repli sur l'API Fankai quand une série est absente du scraper et retrait de Kodi des NFO ([67794d9](https://github.com/Masutayunikon/FanKarr/commit/67794d92c8ab48a402bdcb5a2a855df39c86bba8))
|
||||
|
||||
## [3.22.0](https://github.com/Masutayunikon/FanKarr/compare/v3.21.0...v3.22.0) (2026-09-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* actions de masse sur les séries et fiabilisation des imports ([f4f2135](https://github.com/Masutayunikon/FanKarr/commit/f4f213508de8500aa5e32e8782f358a22e47ec36))
|
||||
* actions de masse sur les séries et fiabilisation des imports et correction protection admin ([7f06dd6](https://github.com/Masutayunikon/FanKarr/commit/7f06dd654a72b4960195202ef5597411a54f8d75))
|
||||
* **torrent:** ajout d'une option pour ignorer les erreurs de certificat ssl ([7479ddf](https://github.com/Masutayunikon/FanKarr/commit/7479ddf8eb63579727cbb0f497ead98c90145da5))
|
||||
* **torrent:** ajout d'une option pour ignorer les erreurs de certificat SSL ([#138](https://github.com/Masutayunikon/FanKarr/issues/138)) ([0768964](https://github.com/Masutayunikon/FanKarr/commit/07689640413cf9ea57ea69e1b50273c80dffd640))
|
||||
|
||||
## [3.21.0](https://github.com/Masutayunikon/FanKarr/compare/v3.20.3...v3.21.0) (2026-06-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **settings:** ajout de englishDirectory qui permet d'avoir Season XX au lieu de Saison X ([6f1e0dc](https://github.com/Masutayunikon/FanKarr/commit/6f1e0dc844e8f97b0966b223d558c09ab498ddc9))
|
||||
|
||||
## [3.20.3](https://github.com/Masutayunikon/FanKarr/compare/v3.20.2...v3.20.3) (2026-06-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **serie:** ajout du label de saisons avec les episodes ([63146fd](https://github.com/Masutayunikon/FanKarr/commit/63146fdc27d98acb70cf86c7890ea1236388d717))
|
||||
|
||||
## [3.20.2](https://github.com/Masutayunikon/FanKarr/compare/v3.20.1...v3.20.2) (2026-06-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** add placeholder from config ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||
* **client:** remove alldebrid ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||
* **label:** add season of episodes ([98b317d](https://github.com/Masutayunikon/FanKarr/commit/98b317d493b7742c330d49ca816c0fecd983e82a))
|
||||
* **scraper:** fallback on empty env for GITHUB_BASE ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||
|
||||
## [3.20.1](https://github.com/Masutayunikon/FanKarr/compare/v3.20.0...v3.20.1) (2026-06-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **organize:** import manuel ne traite que les fichiers 100% complets ([f311c8e](https://github.com/Masutayunikon/FanKarr/commit/f311c8e2f3b9f53b6d03a327cf1b5bfd69cc6424))
|
||||
|
||||
## [3.20.0](https://github.com/Masutayunikon/FanKarr/compare/v3.19.1...v3.20.0) (2026-05-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **torrent:** système générique de suivi des tâches gérées ([a7250a5](https://github.com/Masutayunikon/FanKarr/commit/a7250a5f804189a7ddecef44193436cf6b324c31))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **organize:** fallback par torrent_name quand le hash n'est pas résolu ([f6f735a](https://github.com/Masutayunikon/FanKarr/commit/f6f735ad8838d0755472f739571684d75f9dc1b5))
|
||||
* **synology:** filtrage par savePath et retour au GET pour add() ([d326e08](https://github.com/Masutayunikon/FanKarr/commit/d326e0883477a7dbf453ab28a3fc619f759a8604))
|
||||
|
||||
## [3.19.1](https://github.com/Masutayunikon/FanKarr/compare/v3.19.0...v3.19.1) (2026-05-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** rerun deployment ([099b088](https://github.com/Masutayunikon/FanKarr/commit/099b0889b069f0226b7cb2ec4fa3b1ca535a82a6))
|
||||
|
||||
## [3.19.0](https://github.com/Masutayunikon/FanKarr/compare/v3.18.3...v3.19.0) (2026-05-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **synology:** files par fichier + getFiles() comme qbittorrent ([c6c52a1](https://github.com/Masutayunikon/FanKarr/commit/c6c52a1ae742dcb02ee86f3de04831ac1f041940))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **organize:** markOrganized manquant en mode copy pour les packs ([98ed3c7](https://github.com/Masutayunikon/FanKarr/commit/98ed3c7199915d2aafbb7be87b41fb725bb0c1ee))
|
||||
* **synology:** resolution hash via cache URI + magnet + detail.hash ([ee1c7d5](https://github.com/Masutayunikon/FanKarr/commit/ee1c7d579c3795701158e39159511be3cc1789d7))
|
||||
* **torrent:** correction suppression torrent après déplacement ([ed556ac](https://github.com/Masutayunikon/FanKarr/commit/ed556acd1d2990a6375d4a60f745048a79854249))
|
||||
|
||||
## [3.18.3](https://github.com/Masutayunikon/FanKarr/compare/v3.18.2...v3.18.3) (2026-05-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **synology:** double prefix dbid_ et fallback hash ([2587768](https://github.com/Masutayunikon/FanKarr/commit/25877685eaf02b3b1c02ff06db618a37aebf0e6b))
|
||||
|
||||
## [3.18.2](https://github.com/Masutayunikon/FanKarr/compare/v3.18.1...v3.18.2) (2026-05-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** synology use default savepath when 403 ([63f6c12](https://github.com/Masutayunikon/FanKarr/commit/63f6c1276bd48a14e5806ff997b6be63c458d94c))
|
||||
|
||||
## [3.18.1](https://github.com/Masutayunikon/FanKarr/compare/v3.18.0...v3.18.1) (2026-05-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** suppresion de alldebrid ([8b4b198](https://github.com/Masutayunikon/FanKarr/commit/8b4b198697ba3296444759c522bb0139be69e364))
|
||||
|
||||
## [3.18.0](https://github.com/Masutayunikon/FanKarr/compare/v3.17.1...v3.18.0) (2026-05-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **client:** ajout deluge ([045213d](https://github.com/Masutayunikon/FanKarr/commit/045213d363a75bf56bdb5a05e3965a040ead2893))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** alldebrid ([045213d](https://github.com/Masutayunikon/FanKarr/commit/045213d363a75bf56bdb5a05e3965a040ead2893))
|
||||
|
||||
## [3.17.1](https://github.com/Masutayunikon/FanKarr/compare/v3.17.0...v3.17.1) (2026-05-24)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Serif+Display&display=swap" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>fankarr</title>
|
||||
<title>Fankarr</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+5
-2
@@ -7,7 +7,9 @@
|
||||
"dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"dev:client": "vite",
|
||||
"build": "vite build && tsc -p tsconfig.server.json",
|
||||
"build": "vue-tsc -p tsconfig.app.json --noEmit && vite build && tsc -p tsconfig.server.json",
|
||||
"typecheck": "vue-tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.server.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"build:bun:linux": "bun build server/index.ts --compile --target bun-linux-x64 --outfile binaries/fankarr-linux-x64 && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:windows": "bun build server/index.ts --compile --target bun-windows-x64 --outfile binaries/fankarr-windows-x64.exe && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:macos": "bun build server/index.ts --compile --target bun-darwin-arm64 --outfile binaries/fankarr-macos && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
@@ -44,6 +46,7 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^5.0.0",
|
||||
"vue-tsc": "^3.1.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+228
@@ -87,6 +87,9 @@ importers:
|
||||
vite:
|
||||
specifier: ^7.3.1
|
||||
version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vue-tsc:
|
||||
specifier: ^3.1.5
|
||||
version: 3.2.5(typescript@5.9.3)
|
||||
@@ -283,6 +286,9 @@ packages:
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0':
|
||||
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
@@ -323,66 +329,79 @@ packages:
|
||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||
@@ -452,24 +471,28 @@ packages:
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
||||
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
||||
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
||||
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
||||
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
|
||||
@@ -510,6 +533,9 @@ packages:
|
||||
'@types/body-parser@1.19.6':
|
||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
@@ -521,6 +547,9 @@ packages:
|
||||
'@types/cors@2.8.19':
|
||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -564,6 +593,20 @@ packages:
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vitest/mocker@5.0.0':
|
||||
resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/spy@5.0.0':
|
||||
resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==}
|
||||
|
||||
'@volar/language-core@2.4.28':
|
||||
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
|
||||
|
||||
@@ -676,6 +719,10 @@ packages:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-kit@2.2.0:
|
||||
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -710,6 +757,10 @@ packages:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -828,6 +879,9 @@ packages:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@2.3.2:
|
||||
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -847,10 +901,17 @@ packages:
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
etag@1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
expect-type@1.4.0:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -1017,24 +1078,28 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.31.1:
|
||||
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.31.1:
|
||||
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||
@@ -1089,6 +1154,9 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magic-string@1.3.1:
|
||||
resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1147,6 +1215,10 @@ packages:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
obug@2.2.1:
|
||||
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1180,6 +1252,10 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
picomatch@4.0.7:
|
||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pinia@3.0.4:
|
||||
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
|
||||
peerDependencies:
|
||||
@@ -1289,6 +1365,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1297,10 +1376,16 @@ packages:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@4.2.0:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1328,10 +1413,22 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tinybench@6.1.4:
|
||||
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
tinyexec@1.3.0:
|
||||
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -1423,6 +1520,47 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@5.0.0:
|
||||
resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==}
|
||||
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 5.0.0
|
||||
'@vitest/browser-preview': 5.0.0
|
||||
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
|
||||
'@vitest/coverage-istanbul': 5.0.0
|
||||
'@vitest/coverage-v8': 5.0.0
|
||||
'@vitest/ui': 5.0.0
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
@@ -1462,6 +1600,11 @@ packages:
|
||||
webpack-virtual-modules@0.6.2:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1601,6 +1744,8 @@ snapshots:
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
@@ -1760,6 +1905,11 @@ snapshots:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 24.12.0
|
||||
@@ -1772,6 +1922,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
@@ -1821,6 +1973,17 @@ snapshots:
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@vitest/mocker@5.0.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.0
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.3.1
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/spy@5.0.0': {}
|
||||
|
||||
'@volar/language-core@2.4.28':
|
||||
dependencies:
|
||||
'@volar/source-map': 2.4.28
|
||||
@@ -1971,6 +2134,8 @@ snapshots:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-kit@2.2.0:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.0
|
||||
@@ -2013,6 +2178,8 @@ snapshots:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -2110,6 +2277,8 @@ snapshots:
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-module-lexer@2.3.2: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -2149,8 +2318,14 @@ snapshots:
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
@@ -2190,6 +2365,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.7):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.7
|
||||
|
||||
fetch-blob@3.2.0:
|
||||
dependencies:
|
||||
node-domexception: 1.0.0
|
||||
@@ -2390,6 +2569,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magic-string@1.3.1:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
@@ -2431,6 +2614,8 @@ snapshots:
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
obug@2.2.1: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
@@ -2455,6 +2640,8 @@ snapshots:
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
picomatch@4.0.7: {}
|
||||
|
||||
pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.9
|
||||
@@ -2618,12 +2805,18 @@ snapshots:
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@4.2.0: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
@@ -2650,11 +2843,20 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tinybench@6.1.4: {}
|
||||
|
||||
tinyexec@1.3.0: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
picomatch: 4.0.7
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
@@ -2713,6 +2915,27 @@ snapshots:
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
vitest@5.0.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
chai: 6.2.2
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
magic-string: 1.3.1
|
||||
obug: 2.2.1
|
||||
picomatch: 4.0.7
|
||||
std-env: 4.2.0
|
||||
tinybench: 6.1.4
|
||||
tinyexec: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.12.0
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-router@5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)):
|
||||
@@ -2759,6 +2982,11 @@ snapshots:
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
||||
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 |
+41
-35
@@ -13,11 +13,13 @@ import synologyDsDriver from './torrent-clients/synology-ds.js'
|
||||
import utorrentDriver from './torrent-clients/utorrent.js'
|
||||
import rtorrentDriver from './torrent-clients/rtorrent.js'
|
||||
import realDebridDriver from './torrent-clients/real-debrid.js'
|
||||
import allDebridDriver from './torrent-clients/alldebrid.js'
|
||||
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js'
|
||||
import delugeDriver from './torrent-clients/deluge.js'
|
||||
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from './organize.js'
|
||||
import { logger } from './logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||
import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js'
|
||||
import { readAvailable, readInfohashMap } from './lib/github-cache.js'
|
||||
import { loadCatalog } from './lib/serie-helpers.js'
|
||||
import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js'
|
||||
import { pushNotif } from './lib/notifs.js'
|
||||
import { readRequests, completeRequest } from './requests.js'
|
||||
import { checkNfoUpdates } from './lib/nfo.js'
|
||||
@@ -47,7 +49,7 @@ registerDriver(synologyDsDriver)
|
||||
registerDriver(utorrentDriver)
|
||||
registerDriver(rtorrentDriver)
|
||||
registerDriver(realDebridDriver)
|
||||
registerDriver(allDebridDriver)
|
||||
registerDriver(delugeDriver)
|
||||
|
||||
const app = express()
|
||||
const PORT = Number(process.env.PORT) || 9898
|
||||
@@ -91,6 +93,9 @@ const ADMIN_PREFIXES = [
|
||||
'/users', '/jellyfin', '/settings', '/torrent-clients',
|
||||
'/downloads', '/download', '/organize', '/import',
|
||||
'/system', '/nfo-updates', '/plex', '/rss-sync',
|
||||
'/torrent', '/manual-import', '/organized', '/organized-summary',
|
||||
'/rename-episode', '/rename-all', '/purge-nfo',
|
||||
'/logs', '/browse', '/browse-files', '/update', '/scan', '/debug',
|
||||
]
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) {
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
const { mediaPath } = readSettings()
|
||||
|
||||
// Migration organized.json : si ancien format (valeurs string) → reset + rescan
|
||||
try {
|
||||
if (fs.existsSync(ORGANIZED_PATH)) {
|
||||
const raw = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
const isOldFormat = Object.values(raw).some((entries: any) =>
|
||||
Object.values(entries).some(v => typeof v === 'string')
|
||||
)
|
||||
if (isOldFormat) {
|
||||
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
|
||||
fs.writeFileSync(ORGANIZED_PATH, '{}', 'utf-8')
|
||||
}
|
||||
const raw = readOrganized()
|
||||
const isOldFormat = Object.values(raw).some((entries: any) =>
|
||||
Object.values(entries).some(v => typeof v === 'string')
|
||||
)
|
||||
if (isOldFormat) {
|
||||
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
|
||||
writeOrganized({})
|
||||
}
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
logger.error('api', `Migration organized.json ignorée : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
// Migration organized.json : backfill dest_dir manquant sur les anciennes entrées
|
||||
try {
|
||||
if (fs.existsSync(ORGANIZED_PATH)) {
|
||||
const raw: Record<string, Record<string, any>> = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
let patched = 0
|
||||
for (const episodes of Object.values(raw)) {
|
||||
for (const entry of Object.values(episodes)) {
|
||||
if (!entry.dest_dir && entry.dest_path) {
|
||||
entry.dest_dir = path.dirname(entry.dest_path)
|
||||
patched++
|
||||
}
|
||||
const raw = readOrganized()
|
||||
let patched = 0
|
||||
for (const episodes of Object.values(raw)) {
|
||||
for (const entry of Object.values(episodes)) {
|
||||
if (!entry.dest_dir && entry.dest_path) {
|
||||
entry.dest_dir = path.dirname(entry.dest_path)
|
||||
patched++
|
||||
}
|
||||
}
|
||||
if (patched > 0) {
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(raw, null, 2))
|
||||
logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`)
|
||||
}
|
||||
}
|
||||
if (patched > 0) {
|
||||
writeOrganized(raw)
|
||||
logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
loadEnrichedSeriesData()
|
||||
loadCatalog()
|
||||
.then(async seriesData => {
|
||||
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
|
||||
dedupeOrganizedEpisodes(ORGANIZED_PATH)
|
||||
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
|
||||
})
|
||||
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
|
||||
@@ -185,7 +188,7 @@ server.listen(PORT, async () => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const infohashMap = await readInfohashMap()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const seriesData = await loadCatalog()
|
||||
await autoOrganizeAll(
|
||||
() => dispatchList(category ?? 'fankai', infohashMap),
|
||||
seriesData,
|
||||
@@ -210,11 +213,11 @@ server.listen(PORT, async () => {
|
||||
}
|
||||
const { organizeMode, deleteTorrentOnMove } = readSettings()
|
||||
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
|
||||
try {
|
||||
await dispatchRemove(result.hash, false)
|
||||
const removeResult = await dispatchRemove(result.hash, false)
|
||||
if (removeResult.ok) {
|
||||
logger.info('api', `Torrent "${result.name}" supprimé après move`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Impossible de supprimer le torrent "${result.name}" : ${err instanceof Error ? err.message : err}`)
|
||||
} else {
|
||||
logger.warn('api', `Torrent "${result.name}" — suppression échouée après move : ${removeResult.error ?? 'aucun client n\'a pu supprimer'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const seriesData = await loadCatalog()
|
||||
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
||||
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
if (updated > 0)
|
||||
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`)
|
||||
const { removed } = dedupeOrganizedEpisodes(organizedPath)
|
||||
if (removed > 0)
|
||||
logger.info('api', `Dédoublonnage auto — ${removed} entrée(s) redondante(s) supprimée(s)`)
|
||||
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
|
||||
if (renamed > 0)
|
||||
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
|
||||
|
||||
+24
-15
@@ -2,8 +2,9 @@ import { DATA_DIR } from '../config.js'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
const GITHUB_BASE = process.env.GITHUB_BASE
|
||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
||||
const GITHUB_BASE =
|
||||
process.env.GITHUB_BASE?.trim() ||
|
||||
'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
||||
|
||||
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
|
||||
|
||||
@@ -25,34 +26,42 @@ export function cacheClear(): void { _cache.clear() }
|
||||
|
||||
export function cacheSize(): number { return _cache.size }
|
||||
|
||||
export async function githubGet(urlPath: string): Promise<any> {
|
||||
const cached = cacheGet(urlPath)
|
||||
if (cached) return cached
|
||||
const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
|
||||
/**
|
||||
* @param force Ignore le cache mémoire ET le cache CDN de raw.githubusercontent
|
||||
* (~5 min).
|
||||
*/
|
||||
export async function githubGet(urlPath: string, force = false): Promise<any> {
|
||||
if (!force) {
|
||||
const cached = cacheGet(urlPath)
|
||||
if (cached) return cached
|
||||
}
|
||||
const url = force ? `${GITHUB_BASE}/${urlPath}?_=${Date.now()}` : `${GITHUB_BASE}/${urlPath}`
|
||||
const init = force ? { cache: 'no-store' as const, headers: { 'Cache-Control': 'no-cache' } } : undefined
|
||||
const res = await fetch(url, init)
|
||||
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
|
||||
const data = await res.json()
|
||||
cacheSet(urlPath, data)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function readAvailable(): Promise<number[]> {
|
||||
try { return await githubGet('available.json') as number[] }
|
||||
export async function readAvailable(force = false): Promise<number[]> {
|
||||
try { return await githubGet('available.json', force) as number[] }
|
||||
catch { return [] }
|
||||
}
|
||||
|
||||
export async function readInfohashMap(): Promise<Record<string, string>> {
|
||||
try { return await githubGet('infohash_map.json') as Record<string, string> }
|
||||
export async function readInfohashMap(force = false): Promise<Record<string, string>> {
|
||||
try { return await githubGet('infohash_map.json', force) as Record<string, string> }
|
||||
catch { return {} }
|
||||
}
|
||||
|
||||
export async function readSerieData(serieId: number): Promise<any | null> {
|
||||
try { return await githubGet(`series/${serieId}.json`) }
|
||||
export async function readSerieData(serieId: number, force = false): Promise<any | null> {
|
||||
try { return await githubGet(`series/${serieId}.json`, force) }
|
||||
catch { return null }
|
||||
}
|
||||
|
||||
export async function loadEnrichedSeriesData(): Promise<any[]> {
|
||||
const ids = await readAvailable()
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
|
||||
export async function loadEnrichedSeriesData(force = false): Promise<any[]> {
|
||||
const ids = await readAvailable(force)
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id, force)))
|
||||
return allSd.filter(Boolean)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -11,7 +11,8 @@ import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { loadEnrichedSeriesData, readInfohashMap } from './github-cache.js'
|
||||
import { readInfohashMap } from './github-cache.js'
|
||||
import { loadCatalogStatus } from './serie-helpers.js'
|
||||
import { dispatchDownload, dispatchList } from '../torrent-clients/index.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
|
||||
@@ -53,10 +54,48 @@ export function setSync(serieId: number, serieName: string, enabled: boolean): S
|
||||
return map
|
||||
}
|
||||
|
||||
// Une série déjà surveillée garde sa date d'activation
|
||||
export function setSyncBulk(series: { id: number; name: string }[], enabled: boolean): { changed: number; map: SyncedMap } {
|
||||
const map = loadSynced()
|
||||
let changed = 0
|
||||
for (const { id, name } of series) {
|
||||
if (enabled && !map[id]) { map[id] = { serieId: id, serieName: name, addedAt: new Date().toISOString() }; changed++ }
|
||||
if (!enabled && map[id]) { delete map[id]; changed++ }
|
||||
}
|
||||
if (changed > 0) saveSynced(map)
|
||||
return { changed, map }
|
||||
}
|
||||
|
||||
export function isSynced(serieId: number): boolean {
|
||||
return !!loadSynced()[serieId]
|
||||
}
|
||||
|
||||
const normalizeName = (name: string) =>
|
||||
name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
|
||||
|
||||
// Série recréée sous un nouvel ID avec le même nom : la surveillance suit.
|
||||
// Renvoie les surveillances dont la série n'existe plus dans le catalogue.
|
||||
export function reconcileSynced(catalog: any[]): SyncedSerie[] {
|
||||
const map = loadSynced()
|
||||
const ids = new Set(catalog.map(sd => sd.id))
|
||||
const orphans: SyncedSerie[] = []
|
||||
let changed = false
|
||||
for (const entry of Object.values(map)) {
|
||||
if (ids.has(entry.serieId)) continue
|
||||
const matches = catalog.filter(sd => normalizeName(sd.title ?? '') === normalizeName(entry.serieName))
|
||||
if (matches.length === 1 && !map[matches[0].id]) {
|
||||
delete map[entry.serieId]
|
||||
map[matches[0].id] = { ...entry, serieId: matches[0].id, serieName: matches[0].title }
|
||||
logger.info('rss-sync', `Surveillance de "${entry.serieName}" déplacée de la série ${entry.serieId} vers ${matches[0].id}`)
|
||||
changed = true
|
||||
} else {
|
||||
orphans.push(entry)
|
||||
}
|
||||
}
|
||||
if (changed) saveSynced(map)
|
||||
return orphans
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
function readOrganized(): Record<string, Record<string, any>> {
|
||||
@@ -70,10 +109,11 @@ function readOrganized(): Record<string, Record<string, any>> {
|
||||
function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string, any>>): Set<number> {
|
||||
const ids = new Set<number>()
|
||||
|
||||
const manual = organized['manual'] ?? {}
|
||||
// Sous n'importe quel hash : évite de retélécharger un épisode importé via un torrent disparu du scraper
|
||||
const tracked = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (manual[String(ep.id)]) ids.add(ep.id)
|
||||
if (tracked.has(String(ep.id))) ids.add(ep.id)
|
||||
|
||||
for (const t of sd.torrents ?? []) {
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
@@ -107,14 +147,16 @@ function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string
|
||||
// ── Logique de sync ───────────────────────────────────────────
|
||||
|
||||
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
|
||||
if (Object.keys(loadSynced()).length === 0) return { sent: 0, skipped: 0, errors: 0 }
|
||||
|
||||
const { series: seriesData, complete } = await loadCatalogStatus()
|
||||
if (complete) reconcileSynced(seriesData)
|
||||
const synced = loadSynced()
|
||||
const ids = Object.keys(synced).map(Number)
|
||||
if (ids.length === 0) return { sent: 0, skipped: 0, errors: 0 }
|
||||
|
||||
logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`)
|
||||
|
||||
const organized = readOrganized()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
|
||||
const activeHashes = new Set<string>()
|
||||
try {
|
||||
@@ -134,7 +176,7 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
|
||||
const syncedEntry = synced[serieId]
|
||||
const sd = seriesData.find((s: any) => s.id === serieId)
|
||||
if (!sd) {
|
||||
logger.warn('rss-sync', `Série ${serieId} introuvable dans le cache`)
|
||||
logger.warn('rss-sync', `Série ${serieId} ("${syncedEntry.serieName}") introuvable dans le catalogue — surveillance à retirer depuis Paramètres › Catalogue`)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import path from 'path'
|
||||
import { logger } from '../logger.js'
|
||||
import { githubGet, loadEnrichedSeriesData, readAvailable } from './github-cache.js'
|
||||
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
|
||||
@@ -8,6 +10,80 @@ export async function fankaiGet(endpoint: string): Promise<any> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchSerieFromApi(serieId: number): Promise<any> {
|
||||
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${serieId}`), fankaiGet(`/series/${serieId}/seasons`)])
|
||||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
||||
return {
|
||||
...serie,
|
||||
seasons: await Promise.all(seasons.map(async (season: any) => {
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function serieHasEpisodes(sd: any): boolean {
|
||||
return (sd?.seasons ?? []).some((s: any) => (s.episodes ?? []).length > 0)
|
||||
}
|
||||
|
||||
export function serieFolderName(title: string): string {
|
||||
return title.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// Même règle que seasonFolder() du worker
|
||||
export function seasonFolderName(seasonNumber: number, englishDirectory: boolean): string {
|
||||
return seasonNumber === 0 ? 'Specials' : (englishDirectory ? `Season ${String(seasonNumber).padStart(2, '0')}` : `Saison ${seasonNumber}`)
|
||||
}
|
||||
|
||||
const errorMessage = (err: unknown) => err instanceof Error ? err.message : String(err)
|
||||
|
||||
// Données scraper avec le titre à jour de l'API, ou repli complet sur l'API quand le scraper n'a pas (encore) la série
|
||||
export async function resolveSerieData(serieId: number, action?: string): Promise<any | null> {
|
||||
const [scraper, api] = await Promise.allSettled([githubGet(`series/${serieId}.json`), fankaiGet(`/series/${serieId}`)])
|
||||
if (scraper.status === 'fulfilled' && serieHasEpisodes(scraper.value))
|
||||
return api.status === 'fulfilled' && api.value?.title ? { ...scraper.value, title: api.value.title } : scraper.value
|
||||
const scraperError = scraper.status === 'rejected' ? errorMessage(scraper.reason) : 'aucun épisode'
|
||||
try {
|
||||
const sd = await fetchSerieFromApi(serieId)
|
||||
if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises de l'API Fankai`)
|
||||
return sd
|
||||
} catch (err) {
|
||||
logger.error('api', `${action ?? 'Lecture série'} : série ${serieId} introuvable — scraper (${scraperError}), API Fankai (${errorMessage(err)})`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
let lastCatalogWarn = 0
|
||||
|
||||
// Catalogue du scraper complété par l'API Fankai : titres à jour et séries pas encore scrapées.
|
||||
// complete = false si une source a échoué : ne rien supprimer sur la base d'une absence dans ce cas.
|
||||
export async function loadCatalogStatus(force = false): Promise<{ series: any[]; complete: boolean }> {
|
||||
const [seriesData, availableIds] = await Promise.all([loadEnrichedSeriesData(force), readAvailable(force)])
|
||||
const scraperComplete = availableIds.length > 0 && seriesData.length === availableIds.length
|
||||
let apiSeries: any[]
|
||||
try {
|
||||
const data = await fankaiGet('/series')
|
||||
apiSeries = Array.isArray(data) ? data : (data.series ?? [])
|
||||
} catch (err) {
|
||||
if (Date.now() - lastCatalogWarn > 60 * 60_000) {
|
||||
lastCatalogWarn = Date.now()
|
||||
logger.warn('api', `Catalogue : API Fankai injoignable, titres du scraper conservés (${errorMessage(err)})`)
|
||||
}
|
||||
return { series: seriesData, complete: false }
|
||||
}
|
||||
const titles = new Map<number, string>(apiSeries.filter(s => s.title).map(s => [s.id, s.title]))
|
||||
const catalog = seriesData.map(sd => titles.has(sd.id) ? { ...sd, title: titles.get(sd.id) } : sd)
|
||||
// Scraper vide = injoignable : ne pas interroger l'API série par série
|
||||
if (seriesData.length === 0) return { series: catalog, complete: false }
|
||||
const known = new Set(seriesData.map(sd => sd.id))
|
||||
const missing = await Promise.all(apiSeries.filter(s => !known.has(s.id)).map(s => fetchSerieFromApi(s.id).catch(() => null)))
|
||||
return { series: [...catalog, ...missing.filter(Boolean)], complete: scraperComplete && missing.every(Boolean) }
|
||||
}
|
||||
|
||||
export async function loadCatalog(force = false): Promise<any[]> {
|
||||
return (await loadCatalogStatus(force)).series
|
||||
}
|
||||
|
||||
export function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
|
||||
if (!url) return null
|
||||
const stripped = url.replace(/^https?:\/\//, '')
|
||||
@@ -170,15 +246,9 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
|
||||
}
|
||||
}
|
||||
|
||||
const manualOrg = organized['manual'] ?? {}
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (manualOrg[String(ep.id)]) {
|
||||
const key = epIdToKey.get(ep.id)
|
||||
if (key) organizedEpKeys.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
const trackedIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
|
||||
for (const [id, key] of epIdToKey)
|
||||
if (trackedIds.has(String(id))) organizedEpKeys.add(key)
|
||||
|
||||
if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none'
|
||||
if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete'
|
||||
|
||||
+14
-6
@@ -22,18 +22,26 @@ export interface LogEntry {
|
||||
|
||||
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
|
||||
const MAX_LINES = 2000 // rotation auto au-delà
|
||||
const ROTATE_SLACK = 200 // marge avant rotation, pour ne pas réécrire le fichier à chaque ligne
|
||||
const IS_PROD = process.env.NODE_ENV === 'production'
|
||||
|
||||
// ── Écriture ──────────────────────────────────────────────────
|
||||
|
||||
let lineCount: number | null = null
|
||||
|
||||
function countLines(): number {
|
||||
if (!fs.existsSync(LOGS_PATH)) return 0
|
||||
return fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).length
|
||||
}
|
||||
|
||||
function writeLine(entry: LogEntry) {
|
||||
try {
|
||||
lineCount ??= countLines()
|
||||
fs.appendFileSync(LOGS_PATH, JSON.stringify(entry) + '\n', 'utf-8')
|
||||
// Rotation si trop grand
|
||||
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
|
||||
const lines = content.split('\n').filter(Boolean)
|
||||
if (lines.length > MAX_LINES) {
|
||||
fs.writeFileSync(LOGS_PATH, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8')
|
||||
if (++lineCount > MAX_LINES + ROTATE_SLACK) {
|
||||
const lines = fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).slice(-MAX_LINES)
|
||||
fs.writeFileSync(LOGS_PATH, lines.join('\n') + '\n', 'utf-8')
|
||||
lineCount = lines.length
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -98,7 +106,7 @@ export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
|
||||
}
|
||||
|
||||
export function clearLogs(): void {
|
||||
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8') } catch {}
|
||||
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8'); lineCount = 0 } catch {}
|
||||
}
|
||||
|
||||
export function logsFileSize(): number {
|
||||
|
||||
+93
-25
@@ -10,8 +10,7 @@ import path from 'path'
|
||||
import { parentPort } from 'worker_threads'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { getGitlabTitle } from './gitlab-map.js'
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
import { readOrganized } from './lib/organized-store.js'
|
||||
|
||||
// ─── Utils log ────────────────────────────────────────────────
|
||||
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
|
||||
@@ -35,34 +34,28 @@ export interface OrgEntry {
|
||||
type Organized = Record<string, Record<string, OrgEntry>>
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean, englishDirectory: boolean } {
|
||||
try {
|
||||
const p = path.join(DATA_DIR, 'settings.json')
|
||||
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
|
||||
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, englishDirectory: false }
|
||||
return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
|
||||
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false , englishDirectory: false } }
|
||||
}
|
||||
|
||||
// ─── Organized log ────────────────────────────────────────────
|
||||
function loadOrganized(): Organized {
|
||||
try {
|
||||
if (!fs.existsSync(ORGANIZED_PATH)) return {}
|
||||
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
} catch { return {} }
|
||||
}
|
||||
|
||||
function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
|
||||
const data = loadOrganized()
|
||||
if (!data[hash]) data[hash] = {}
|
||||
data[hash][String(episodeId)] = entry
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
parentPort?.postMessage({ type: 'mark', hash, episodeId, entry })
|
||||
}
|
||||
|
||||
function isOrganized(hash: string, episodeId: number): boolean {
|
||||
return !!loadOrganized()[hash]?.[String(episodeId)]
|
||||
return !!readOrganized()[hash]?.[String(episodeId)]
|
||||
}
|
||||
|
||||
// ─── Lookup torrent dans les seriesData ───────────────────────
|
||||
|
||||
/**
|
||||
* Recherche par infohash (méthode principale).
|
||||
*/
|
||||
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
|
||||
const h = hash.toLowerCase()
|
||||
for (const sd of seriesData) {
|
||||
@@ -101,6 +94,53 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback quand le hash n'est pas résolu (ex: dbid_X Synology).
|
||||
* Cherche par torrent_name (nom réel du dossier torrent) ou title dans le catalogue.
|
||||
* Retourne aussi le vrai infohash BT pour que l'import soit correctement indexé.
|
||||
*/
|
||||
function findTorrentByName(
|
||||
name : string,
|
||||
seriesData: any[],
|
||||
): { torrent: any; serieData: any; infohash: string } | null {
|
||||
const n = name.toLowerCase().trim()
|
||||
if (!n) return null
|
||||
|
||||
for (const sd of seriesData) {
|
||||
// Pack intégral (torrents au niveau série)
|
||||
for (const t of sd.torrents ?? []) {
|
||||
if (!t.infohash) continue
|
||||
if (
|
||||
t.torrent_name?.toLowerCase().trim() === n ||
|
||||
t.title?.toLowerCase().trim() === n
|
||||
) return { torrent: t, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||
}
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
// Pack saison
|
||||
for (const t of season.torrents ?? []) {
|
||||
if (!t.infohash) continue
|
||||
if (
|
||||
t.torrent_name?.toLowerCase().trim() === n ||
|
||||
t.title?.toLowerCase().trim() === n
|
||||
) return { torrent: { ...t, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||
}
|
||||
|
||||
// Torrent épisode unique
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
if (!t.infohash) continue
|
||||
if (
|
||||
t.torrent_name?.toLowerCase().trim() === n ||
|
||||
t.title?.toLowerCase().trim() === n
|
||||
) return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildFileMap(
|
||||
serieData : any,
|
||||
hash : string,
|
||||
@@ -251,7 +291,8 @@ function swapExtension(filename: string, ext: string): string {
|
||||
}
|
||||
|
||||
function seasonFolder(n: number): string {
|
||||
return n === 0 ? 'Specials' : `Saison ${n}`
|
||||
const { englishDirectory } = readSettings()
|
||||
return n === 0 ? 'Specials' : (englishDirectory ? `Season ${String(n).padStart(2, '0')}` : `Saison ${n}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -458,7 +499,12 @@ async function organizeTorrent(
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (organizeMode === 'copy') {
|
||||
await fsp.copyFile(src, dest)
|
||||
await fsp.copyFile(src, dest)
|
||||
markOrganized(hash, episode_id, {
|
||||
at: new Date().toISOString(), season: season_number,
|
||||
episode: episode_number, episode_id,
|
||||
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
|
||||
})
|
||||
} else if (organizeMode === 'hardlink') {
|
||||
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||
markOrganized(hash, episode_id, {
|
||||
@@ -493,13 +539,35 @@ parentPort?.on('message', async (msg: any) => {
|
||||
|
||||
const torrents: any[] = msg.torrents
|
||||
const seriesData: any[] = msg.seriesData ?? []
|
||||
const organized = loadOrganized()
|
||||
const { nfoSupport } = readSettings()
|
||||
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() as Organized }
|
||||
catch (err) {
|
||||
error(`Import annulé : ${err instanceof Error ? err.message : err}`)
|
||||
parentPort?.postMessage({ type: 'done' })
|
||||
return
|
||||
}
|
||||
|
||||
for (const t of torrents) {
|
||||
if (t.state !== 'seeding') continue
|
||||
|
||||
const found = findTorrentByHash(t.hash, seriesData)
|
||||
// Résolution principale : par infohash
|
||||
let found = findTorrentByHash(t.hash, seriesData)
|
||||
let effectiveHash = t.hash
|
||||
|
||||
// Fallback : si le hash n'est pas résolu (ex: dbid_X Synology), on cherche
|
||||
// par torrent_name — le nom du dossier racine tel que rapporté par le client,
|
||||
// qui correspond au champ torrent_name dans les données série.
|
||||
if (!found && t.name) {
|
||||
const byName = findTorrentByName(t.name, seriesData)
|
||||
if (byName) {
|
||||
found = byName
|
||||
effectiveHash = byName.infohash
|
||||
debug(`Hash résolu par torrent_name : "${t.name}" → ${effectiveHash.slice(0, 8)}…`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
|
||||
continue
|
||||
@@ -507,8 +575,8 @@ parentPort?.on('message', async (msg: any) => {
|
||||
|
||||
const { torrent, serieData } = found
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
|
||||
const orgHash = organized[t.hash] ?? {}
|
||||
const fileMap = buildFileMap(serieData, effectiveHash, nfoSupport, seasonFilter)
|
||||
const orgHash = organized[effectiveHash] ?? {}
|
||||
const allDone = fileMap.size > 0
|
||||
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
|
||||
: Object.keys(orgHash).length > 0
|
||||
@@ -541,8 +609,8 @@ parentPort?.on('message', async (msg: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
|
||||
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, serieId: serieData.id ?? null, ...result })
|
||||
const result = await organizeTorrent(effectiveHash, t.name, t.save_path, seriesData, completedFileNames)
|
||||
parentPort?.postMessage({ type: 'result', hash: effectiveHash, name: t.name, serieId: serieData.id ?? null, ...result })
|
||||
} catch (err) {
|
||||
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
+188
-96
@@ -8,10 +8,8 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { logger } from './logger.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { readSettings } from './settings.js'
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from './lib/organized-store.js'
|
||||
|
||||
export let workerRunning = false
|
||||
const _prevStates = new Map<string, string>()
|
||||
@@ -30,18 +28,54 @@ export interface OrganizeResult {
|
||||
errors : { file: string; error: string }[]
|
||||
}
|
||||
|
||||
interface OrgEntry {
|
||||
at : string
|
||||
season : number
|
||||
episode : number
|
||||
episode_id : number
|
||||
src_filename : string
|
||||
dest_filename: string
|
||||
dest_path : string
|
||||
dest_dir? : string // absent dans les anciennes entrées — backfillé au démarrage
|
||||
type WorkerResult = { hash: string; name: string; serieId?: number | null; total: number; done: number; skipped: number; errors: { file: string; error: string }[] }
|
||||
|
||||
// ── File d'attente des workers ────────────────────────────────
|
||||
let _queue: Promise<unknown> = Promise.resolve()
|
||||
let _queued = 0
|
||||
const _pendingManual = new Map<string, Promise<OrganizeResult>>()
|
||||
|
||||
function enqueue<T>(job: () => Promise<T>): Promise<T> {
|
||||
_queued++
|
||||
const run = _queue.then(job).finally(() => { _queued-- })
|
||||
_queue = run.catch(() => {})
|
||||
return run
|
||||
}
|
||||
|
||||
type Organized = Record<string, Record<string, OrgEntry>>
|
||||
function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResult) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
workerRunning = true
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
let settled = false
|
||||
const finish = (err?: Error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
workerRunning = false
|
||||
worker.terminate()
|
||||
err ? reject(err) : resolve()
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
|
||||
} else if (msg.type === 'mark') {
|
||||
try {
|
||||
updateOrganized(data => { (data[msg.hash] ??= {})[String(msg.episodeId)] = msg.entry })
|
||||
} catch (err) {
|
||||
logger.error('organize', `Enregistrement de l'import impossible (ep ${msg.episodeId}) : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
} else if (msg.type === 'result') {
|
||||
onResult(msg)
|
||||
} else if (msg.type === 'done') {
|
||||
finish()
|
||||
}
|
||||
})
|
||||
worker.on('error', err => finish(err))
|
||||
worker.on('exit', code => finish(code > 1 ? new Error(`Worker exit inattendu : code ${code}`) : undefined))
|
||||
|
||||
worker.postMessage({ type: 'run', torrents, seriesData })
|
||||
})
|
||||
}
|
||||
|
||||
// ── Scan initial ──────────────────────────────────────────────
|
||||
|
||||
@@ -65,10 +99,8 @@ export async function scanMediaPath(
|
||||
|
||||
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
|
||||
|
||||
// Map : nom_sans_extension → { hash, srcFilename, episodeId, season, episode }
|
||||
// On indexe par nom sans extension pour matcher quelle que soit l'extension sur le disque
|
||||
const filenameIndex = new Map<string, {
|
||||
hash : string
|
||||
hashes : string[]
|
||||
srcFilename : string
|
||||
episodeId : number
|
||||
season : number
|
||||
@@ -84,8 +116,13 @@ export async function scanMediaPath(
|
||||
const idx = (filename: string, hash: string, destFilename?: string) => {
|
||||
const base = filename.replace(/\.[^.]+$/, '')
|
||||
if (!base) return
|
||||
const existing = filenameIndex.get(base)
|
||||
if (existing && existing.episodeId === ep.id) {
|
||||
if (!existing.hashes.includes(hash)) existing.hashes.push(hash)
|
||||
return
|
||||
}
|
||||
filenameIndex.set(base, {
|
||||
hash,
|
||||
hashes : [hash],
|
||||
srcFilename : filename,
|
||||
episodeId : ep.id,
|
||||
season : season.season_number,
|
||||
@@ -123,16 +160,27 @@ export async function scanMediaPath(
|
||||
}
|
||||
}
|
||||
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch {}
|
||||
let organized: Organized
|
||||
try { organized = readOrganized(organizedPath) }
|
||||
catch (err) {
|
||||
logger.error('organize', `Scan annulé : ${err instanceof Error ? err.message : err}`)
|
||||
return result
|
||||
}
|
||||
|
||||
// Track les paires hash:episodeId présentes
|
||||
const presentFiles = new Set<string>()
|
||||
let noMatch = 0
|
||||
|
||||
const trackedByPath = new Map<string, string[]>()
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const [episodeId, entry] of Object.entries(episodes)) {
|
||||
if (!entry?.dest_path) continue
|
||||
const keys = trackedByPath.get(entry.dest_path) ?? []
|
||||
keys.push(`${hash}:${episodeId}`)
|
||||
trackedByPath.set(entry.dest_path, keys)
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
|
||||
@@ -143,6 +191,14 @@ export async function scanMediaPath(
|
||||
walk(full)
|
||||
} else if (entry.isFile() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) {
|
||||
result.found++
|
||||
|
||||
// Fichier déjà suivi (sous n'importe quel hash) : ne pas créer d'entrée en double
|
||||
const tracked = trackedByPath.get(full)
|
||||
if (tracked) {
|
||||
for (const key of tracked) presentFiles.add(key)
|
||||
continue
|
||||
}
|
||||
|
||||
const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '')
|
||||
let match = filenameIndex.get(nameWithoutExt)
|
||||
|
||||
@@ -167,7 +223,8 @@ export async function scanMediaPath(
|
||||
continue
|
||||
}
|
||||
|
||||
const { hash, srcFilename, episodeId, season, episode } = match
|
||||
const { hashes, srcFilename, episodeId, season, episode } = match
|
||||
const hash = hashes.find(h => organized[h]?.[String(episodeId)]) ?? hashes[0]
|
||||
presentFiles.add(`${hash}:${episodeId}`)
|
||||
|
||||
if (organized[hash]?.[String(episodeId)]) continue
|
||||
@@ -190,11 +247,17 @@ export async function scanMediaPath(
|
||||
|
||||
walk(mediaPath)
|
||||
|
||||
const mediaEmpty = result.found === 0 && Object.keys(organized).length > 0
|
||||
if (mediaEmpty) logger.warn('organize', `Scan : aucun fichier dans ${mediaPath} — suppression des entrées ignorée`)
|
||||
|
||||
// Supprimer uniquement les entrées connues dans l'index et absentes du disque
|
||||
const allEpisodeIds = new Set([...filenameIndex.values()].map(v => `${v.hash}:${v.episodeId}`))
|
||||
const allEpisodeIds = new Set(
|
||||
[...filenameIndex.values()].flatMap(v => v.hashes.map(h => `${h}:${v.episodeId}`))
|
||||
)
|
||||
|
||||
let removed = 0
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
if (mediaEmpty) break
|
||||
for (const episodeId of Object.keys(episodes)) {
|
||||
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
|
||||
delete organized[hash][episodeId]
|
||||
@@ -207,7 +270,7 @@ export async function scanMediaPath(
|
||||
// Désimport automatique des fichiers manquants sur le disque
|
||||
const { autoUnimportMissing } = readSettings() as any
|
||||
let autoRemoved = 0
|
||||
if (autoUnimportMissing) {
|
||||
if (autoUnimportMissing && !mediaEmpty) {
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
|
||||
const destPath = entry?.dest_path
|
||||
@@ -222,7 +285,7 @@ export async function scanMediaPath(
|
||||
}
|
||||
|
||||
if (result.added > 0 || removed > 0 || autoRemoved > 0) {
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
writeOrganized(organized, organizedPath)
|
||||
}
|
||||
|
||||
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
|
||||
@@ -243,8 +306,8 @@ export async function autoOrganizeAll(
|
||||
return
|
||||
}
|
||||
|
||||
if (workerRunning) {
|
||||
logger.debug('organize', 'Worker déjà en cours — skip')
|
||||
if (_queued > 0) {
|
||||
logger.debug('organize', 'Import déjà en cours — skip')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -268,11 +331,12 @@ export async function autoOrganizeAll(
|
||||
|
||||
for (const t of torrents) _prevStates.set(t.hash, t.state)
|
||||
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(ORGANIZED_PATH))
|
||||
organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
} catch {}
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) {
|
||||
logger.error('organize', `Import automatique annulé : ${err instanceof Error ? err.message : err}`)
|
||||
return
|
||||
}
|
||||
|
||||
const hasUnorganized = torrents.some(t =>
|
||||
t.state === 'seeding' && !organized[t.hash?.toLowerCase()]
|
||||
@@ -290,13 +354,8 @@ export async function autoOrganizeAll(
|
||||
logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
|
||||
}
|
||||
|
||||
workerRunning = true
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
try {
|
||||
await enqueue(() => runWorker(torrents, seriesData, msg => {
|
||||
if (msg.errors.length > 0) {
|
||||
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
|
||||
for (const e of msg.errors) {
|
||||
@@ -316,24 +375,11 @@ export async function autoOrganizeAll(
|
||||
errors : msg.errors.length,
|
||||
errorFiles: msg.errors,
|
||||
})
|
||||
} else if (msg.type === 'done') {
|
||||
logger.debug('organize', 'Worker terminé')
|
||||
workerRunning = false
|
||||
worker.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => {
|
||||
logger.error('organize', `Worker erreur : ${err.message}`)
|
||||
workerRunning = false
|
||||
})
|
||||
worker.on('exit', (code) => {
|
||||
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
|
||||
else logger.debug('organize', `Worker terminé (code ${code})`)
|
||||
workerRunning = false
|
||||
})
|
||||
|
||||
worker.postMessage({ type: 'run', torrents, seriesData })
|
||||
}))
|
||||
logger.debug('organize', 'Worker terminé')
|
||||
} catch (err) {
|
||||
logger.error('organize', `Worker erreur : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration des IDs épisodes ────────────────────────────────
|
||||
@@ -368,11 +414,12 @@ export async function migrateOrganizedEpisodeIds(
|
||||
}
|
||||
|
||||
// 2. Charger organized.json
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch { return { updated: 0, orphaned: 0 } }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized(organizedPath) }
|
||||
catch (err) {
|
||||
logger.error('organize', `Migration IDs annulée : ${err instanceof Error ? err.message : err}`)
|
||||
return { updated: 0, orphaned: 0 }
|
||||
}
|
||||
|
||||
let updated = 0
|
||||
let orphaned = 0
|
||||
@@ -422,7 +469,7 @@ export async function migrateOrganizedEpisodeIds(
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
writeOrganized(organized, organizedPath)
|
||||
logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`)
|
||||
} else {
|
||||
logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`)
|
||||
@@ -431,6 +478,49 @@ export async function migrateOrganizedEpisodeIds(
|
||||
return { updated, orphaned }
|
||||
}
|
||||
|
||||
// ── Déduplication ─────────────────────────────────────────────
|
||||
export function dedupeOrganizedEpisodes(
|
||||
organizedPath: string,
|
||||
): { removed: number } {
|
||||
let organized: Organized
|
||||
try { organized = readOrganized(organizedPath) }
|
||||
catch (err) {
|
||||
logger.error('organize', `Dédoublonnage annulé : ${err instanceof Error ? err.message : err}`)
|
||||
return { removed: 0 }
|
||||
}
|
||||
|
||||
// episode_id → [{ hash, entry }]
|
||||
const byEpisode = new Map<string, { hash: string; entry: any }[]>()
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
|
||||
if (!byEpisode.has(episodeId)) byEpisode.set(episodeId, [])
|
||||
byEpisode.get(episodeId)!.push({ hash, entry })
|
||||
}
|
||||
}
|
||||
|
||||
let removed = 0
|
||||
for (const [episodeId, candidates] of byEpisode) {
|
||||
if (candidates.length < 2) continue
|
||||
|
||||
const present = candidates.filter(c => c.entry?.dest_path && fs.existsSync(c.entry.dest_path))
|
||||
if (present.length === 0) continue
|
||||
|
||||
for (const c of candidates) {
|
||||
if (present.includes(c)) continue
|
||||
delete (organized[c.hash] as any)[episodeId]
|
||||
removed++
|
||||
logger.info('organize', `Dédoublonnage : ep ${episodeId} retiré de ${c.hash.slice(0, 8)}… (fichier absent, conservé sous ${present[0].hash.slice(0, 8)}…)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
writeOrganized(organized, organizedPath)
|
||||
logger.info('organize', `Dédoublonnage terminé — ${removed} entrée(s) redondante(s) supprimée(s)`)
|
||||
}
|
||||
|
||||
return { removed }
|
||||
}
|
||||
|
||||
// ── Sync des noms de fichiers après mise à jour du scraper ────
|
||||
|
||||
export async function syncFilenameChanges(
|
||||
@@ -438,11 +528,12 @@ export async function syncFilenameChanges(
|
||||
organizedPath: string,
|
||||
): Promise<{ renamed: number; errors: number }> {
|
||||
const { nfoSupport } = readSettings()
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch { return { renamed: 0, errors: 0 } }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized(organizedPath) }
|
||||
catch (err) {
|
||||
logger.error('organize', `Sync noms annulée : ${err instanceof Error ? err.message : err}`)
|
||||
return { renamed: 0, errors: 0 }
|
||||
}
|
||||
|
||||
let renamed = 0
|
||||
let errors = 0
|
||||
@@ -494,6 +585,7 @@ export async function syncFilenameChanges(
|
||||
dest_path : newPath,
|
||||
at : new Date().toISOString(),
|
||||
}
|
||||
retargetEntries(organized, new Map([[oldPath, newPath]]))
|
||||
renamed++
|
||||
changed = true
|
||||
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
|
||||
@@ -506,7 +598,7 @@ export async function syncFilenameChanges(
|
||||
}
|
||||
|
||||
if (changed)
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
writeOrganized(organized, organizedPath)
|
||||
|
||||
if (renamed > 0 || errors > 0)
|
||||
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
|
||||
@@ -520,19 +612,27 @@ export async function organizeTorrent(
|
||||
hash : string,
|
||||
name : string,
|
||||
savePath : string,
|
||||
seriesData: any[]
|
||||
seriesData: any[],
|
||||
files? : { name?: string; progress: number; priority?: number }[],
|
||||
): Promise<OrganizeResult> {
|
||||
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
|
||||
const key = hash.toLowerCase()
|
||||
const pending = _pendingManual.get(key)
|
||||
if (pending) {
|
||||
logger.info('organize', `Import manuel de "${name}" déjà en attente — demande fusionnée`)
|
||||
return pending
|
||||
}
|
||||
logger.info('organize', `Import manuel ${_queued > 0 ? 'mis en file' : 'lancé'} pour "${name}" (${hash})`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
|
||||
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
||||
// files : progression par fichier récupérée depuis le client torrent.
|
||||
// Permet au worker de ne traiter que les fichiers à 100% et d'éviter
|
||||
// les EBUSY sur Windows quand qBit est encore en train d'écrire les autres.
|
||||
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] }
|
||||
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
const job = enqueue(async () => {
|
||||
_pendingManual.delete(key)
|
||||
try {
|
||||
await runWorker([fakeTorrent], seriesData, msg => {
|
||||
result.total = msg.total
|
||||
result.skipped = msg.skipped
|
||||
result.done = msg.done
|
||||
@@ -545,21 +645,13 @@ export async function organizeTorrent(
|
||||
} else {
|
||||
logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
|
||||
}
|
||||
} else if (msg.type === 'done') {
|
||||
worker.terminate()
|
||||
resolve(result)
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => {
|
||||
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err.message}`)
|
||||
worker.terminate()
|
||||
reject(err)
|
||||
})
|
||||
worker.on('exit', (code) => {
|
||||
if (code > 1) reject(new Error(`Worker exit inattendu: code ${code}`))
|
||||
})
|
||||
|
||||
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err instanceof Error ? err.message : err}`)
|
||||
throw err
|
||||
}
|
||||
return result
|
||||
})
|
||||
_pendingManual.set(key, job)
|
||||
return job
|
||||
}
|
||||
+261
-80
@@ -1,28 +1,77 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { requireAuth, requireAdmin } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { getGitlabTitle } from '../gitlab-map.js'
|
||||
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js'
|
||||
import { resolveEpNaming, computeExpectedName } from '../lib/serie-helpers.js'
|
||||
import {
|
||||
resolveEpNaming, computeExpectedName, resolveSerieData, loadCatalog, loadCatalogStatus,
|
||||
serieFolderName, seasonFolderName,
|
||||
} from '../lib/serie-helpers.js'
|
||||
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
|
||||
import { dispatchRemove } from '../torrent-clients/index.js'
|
||||
import { readRequests, deleteRequest } from '../requests.js'
|
||||
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from '../lib/organized-store.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
const serieNotFound = (id: unknown) => `Série ${id} introuvable dans le scraper et sur l'API Fankai (voir les logs)`
|
||||
|
||||
function loadOrganizedJson(): Record<string, Record<string, any>> {
|
||||
try { if (fs.existsSync(ORGANIZED_PATH)) return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
|
||||
return {}
|
||||
// Entrée d'un épisode sous n'importe quel hash (torrent absent des paths du scraper)
|
||||
function findEntry(organized: Organized, episodeId: number): { hash: string; entry: any } | null {
|
||||
for (const [hash, eps] of Object.entries(organized))
|
||||
if (eps[String(episodeId)]) return { hash, entry: eps[String(episodeId)] }
|
||||
return null
|
||||
}
|
||||
|
||||
function saveOrganizedJson(data: Record<string, Record<string, any>>) {
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
// Entrées dont l'épisode n'existe plus dans le catalogue
|
||||
function findOrphans(organized: Organized, catalog: any[]) {
|
||||
const known = new Set<string>()
|
||||
for (const sd of catalog)
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? []) known.add(String(ep.id))
|
||||
const orphans: { hash: string; episode_id: number; season: number | null; episode: number | null; dest_path: string | null }[] = []
|
||||
for (const [hash, eps] of Object.entries(organized))
|
||||
for (const [episodeId, entry] of Object.entries(eps))
|
||||
if (!known.has(episodeId))
|
||||
orphans.push({ hash, episode_id: Number(episodeId), season: entry?.season ?? null, episode: entry?.episode ?? null, dest_path: entry?.dest_path ?? null })
|
||||
return orphans
|
||||
}
|
||||
|
||||
// Dossiers série (enfants directs de la médiathèque) contenant les fichiers suivis d'une série
|
||||
function serieFolders(sd: any, organized: Organized, mediaPath: string): Map<string, number> {
|
||||
const ids = new Set<string>()
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? []) ids.add(String(ep.id))
|
||||
const folders = new Map<string, number>()
|
||||
if (!mediaPath) return folders
|
||||
for (const eps of Object.values(organized)) {
|
||||
for (const [episodeId, entry] of Object.entries(eps)) {
|
||||
if (!ids.has(episodeId) || !entry?.dest_path) continue
|
||||
const parts = path.relative(mediaPath, entry.dest_path).split(path.sep)
|
||||
if (parts.length < 2 || parts[0] === '..' || path.isAbsolute(parts[0])) continue
|
||||
const folder = path.join(mediaPath, parts[0])
|
||||
folders.set(folder, (folders.get(folder) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return folders
|
||||
}
|
||||
|
||||
function listFiles(dir: string): string[] {
|
||||
const files: string[] = []
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) files.push(...listFiles(full))
|
||||
else files.push(full)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function removeEmptyDirs(dir: string): void {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true }))
|
||||
if (entry.isDirectory()) removeEmptyDirs(path.join(dir, entry.name))
|
||||
if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir)
|
||||
}
|
||||
|
||||
// ── Import manuel ──────────────────────────────────────────────
|
||||
@@ -31,18 +80,16 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
if (!serie_id || !Array.isArray(items) || items.length === 0) {
|
||||
res.status(400).json({ error: 'serie_id et items requis' }); return
|
||||
}
|
||||
const { mediaPath, organizeMode, nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
let sd = await readSerieData(Number(serie_id))
|
||||
if (!sd) {
|
||||
// readSerieData ne couvre que le cache local — fallback sur le catalogue complet
|
||||
// (utile pour les séries sans torrent officiel dont les données sont peut-être absentes du cache individuel)
|
||||
const all = await loadEnrichedSeriesData()
|
||||
sd = all.find((s: any) => s.id === Number(serie_id)) ?? null
|
||||
}
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return }
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
const { mediaPath, organizeMode, nfoSupport, englishDirectory } = readSettings()
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
// Les copies de fichiers sont asynchrones : les modifications sont rejouées sur une relecture fraîche à la fin
|
||||
const ops: ((data: Organized) => void)[] = []
|
||||
const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) }
|
||||
const sd = await resolveSerieData(Number(serie_id), 'Import manuel')
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
|
||||
const serieTitle = serieFolderName(sd.title ?? sd.show_title ?? '')
|
||||
const episodeIndex = new Map<number, { ep: any; season: any }>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
@@ -68,8 +115,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
const destFilename = resolvedExt && resolvedExt !== srcExt
|
||||
? resolvedName.slice(0, -resolvedExt.length) + srcExt
|
||||
: resolvedName
|
||||
const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}`
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolderName(season.season_number, englishDirectory))
|
||||
const destPath = path.join(destDir, destFilename)
|
||||
try {
|
||||
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
|
||||
@@ -78,13 +124,15 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
// sous un épisode différent (réassignation → l'ancien enregistrement devient invalide)
|
||||
for (const [h, eps] of Object.entries(organized)) {
|
||||
for (const [epId, entry] of Object.entries(eps)) {
|
||||
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) {
|
||||
delete (organized[h] as any)[epId]
|
||||
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id))
|
||||
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (fichier réassigné)`)
|
||||
}
|
||||
}
|
||||
if (Object.keys(organized[h]).length === 0) delete organized[h]
|
||||
}
|
||||
apply(data => {
|
||||
for (const eps of Object.values(data))
|
||||
for (const [epId, entry] of Object.entries(eps))
|
||||
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) delete eps[epId]
|
||||
})
|
||||
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (file_path !== destPath) {
|
||||
@@ -96,18 +144,21 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
fs.unlinkSync(destPath)
|
||||
logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`)
|
||||
// Nettoyer toute entrée organized qui pointait sur ce fichier supprimé
|
||||
for (const [h, eps] of Object.entries(organized)) {
|
||||
for (const eps of Object.values(organized)) {
|
||||
for (const [epId, entry] of Object.entries(eps)) {
|
||||
if (entry?.dest_path === destPath) {
|
||||
delete (organized[h] as any)[epId]
|
||||
if (entry?.dest_path === destPath)
|
||||
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (ancien fichier écrasé)`)
|
||||
}
|
||||
}
|
||||
if (Object.keys(organized[h]).length === 0) delete organized[h]
|
||||
}
|
||||
apply(data => {
|
||||
for (const eps of Object.values(data))
|
||||
for (const [epId, entry] of Object.entries(eps))
|
||||
if (entry?.dest_path === destPath) delete eps[epId]
|
||||
})
|
||||
}
|
||||
if (isInSeriePath && !fs.existsSync(destPath)) {
|
||||
fs.renameSync(file_path, destPath)
|
||||
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
|
||||
logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
|
||||
} else if (!isInSeriePath && !fs.existsSync(destPath)) {
|
||||
if (organizeMode === 'hardlink') {
|
||||
@@ -116,6 +167,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
} else if (organizeMode === 'move') {
|
||||
try { fs.renameSync(file_path, destPath) }
|
||||
catch { await fs.promises.copyFile(file_path, destPath); await fs.promises.unlink(file_path) }
|
||||
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
|
||||
} else {
|
||||
await fs.promises.copyFile(file_path, destPath)
|
||||
}
|
||||
@@ -125,12 +177,12 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`)
|
||||
}
|
||||
const torrentHash = String(hash || '').toLowerCase() || 'manual'
|
||||
if (!organized[torrentHash]) organized[torrentHash] = {}
|
||||
organized[torrentHash][String(episode_id)] = {
|
||||
const entry = {
|
||||
at: new Date().toISOString(), season: season.season_number,
|
||||
episode: ep.episode_number, episode_id: ep.id,
|
||||
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
|
||||
}
|
||||
apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry })
|
||||
done.push(episode_id)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
@@ -138,7 +190,13 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
|
||||
}
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
if (ops.length > 0) {
|
||||
try { updateOrganized(data => { for (const op of ops) op(data) }) }
|
||||
catch (err) {
|
||||
logger.error('api', `Import manuel : enregistrement impossible : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return
|
||||
}
|
||||
}
|
||||
|
||||
if (nfoSupport && done.length > 0) {
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
@@ -173,9 +231,9 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
router.get('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
const serieId = Number(req.params.serieId)
|
||||
try {
|
||||
const sd = await readSerieData(serieId)
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await resolveSerieData(serieId)
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
|
||||
const organized = readOrganized()
|
||||
const { nfoSupport } = readSettings()
|
||||
const result: Record<string, any> = {}
|
||||
for (const season of sd.seasons ?? []) {
|
||||
@@ -191,6 +249,10 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
if (!entry && organized['manual']?.[String(ep.id)]) {
|
||||
entry = organized['manual'][String(ep.id)]
|
||||
}
|
||||
if (!entry) {
|
||||
const found = findEntry(organized, ep.id)
|
||||
if (found) { entry = found.entry; entryHash = found.hash }
|
||||
}
|
||||
if (!entry) continue
|
||||
|
||||
const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport)
|
||||
@@ -208,9 +270,11 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
|
||||
const { serie_id, episode_id, torrent_hash } = req.body
|
||||
if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return }
|
||||
const { nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serie_id))
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
const sd = await resolveSerieData(Number(serie_id), 'Renommage')
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
let foundEp: any = null
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
@@ -224,9 +288,8 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
|
||||
let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
|
||||
let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
|
||||
if (!orgEntry) {
|
||||
for (const [h, eps] of Object.entries(organized)) {
|
||||
if (eps[String(episode_id)]) { orgEntry = eps[String(episode_id)]; entryHash = h; break }
|
||||
}
|
||||
const found = findEntry(organized, Number(episode_id))
|
||||
if (found) { orgEntry = found.entry; entryHash = found.hash }
|
||||
}
|
||||
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
|
||||
const { currentName, expectedName: newName, needsRename } = computeExpectedName(ep, orgEntry, entryHash, nfoSupport)
|
||||
@@ -238,7 +301,8 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
|
||||
if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
|
||||
fs.renameSync(oldPath, newPath)
|
||||
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
|
||||
saveOrganizedJson(organized)
|
||||
retargetEntries(organized, new Map([[oldPath, newPath]]))
|
||||
writeOrganized(organized)
|
||||
logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
|
||||
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
|
||||
} catch (err) {
|
||||
@@ -252,9 +316,11 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
|
||||
router.delete('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
const serieId = String(req.params.serieId)
|
||||
const deleteFile = req.query.deleteFile === 'true'
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serieId))
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
const sd = await resolveSerieData(Number(serieId), 'Désimport série')
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
const episodeIds = new Set<string>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id))
|
||||
@@ -279,7 +345,7 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
writeOrganized(organized)
|
||||
if (deleteFile) {
|
||||
for (const hash of emptyHashes) {
|
||||
if (hash === 'manual') continue
|
||||
@@ -312,9 +378,11 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
|
||||
const serieId = String(req.params.serieId)
|
||||
const seasonId = Number(req.params.seasonId)
|
||||
const deleteFile = req.query.deleteFile === 'true'
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serieId))
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
const sd = await resolveSerieData(Number(serieId), 'Désimport saison')
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
const season = sd.seasons?.find((s: any) => s.id === seasonId)
|
||||
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
|
||||
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? [])
|
||||
@@ -333,7 +401,7 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
writeOrganized(organized)
|
||||
if (deleteFile) {
|
||||
for (const hash of emptyHashes) {
|
||||
if (hash === 'manual') continue
|
||||
@@ -348,27 +416,40 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
|
||||
router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
|
||||
const episodeId = String(req.params.episodeId)
|
||||
const deleteFile = req.query.deleteFile === 'true'
|
||||
const organized = loadOrganizedJson()
|
||||
let foundHash: string | null = null
|
||||
let foundEntry: any = null
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
const found: { hash: string; entry: any }[] = []
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
if (episodes[episodeId]) { foundHash = hash; foundEntry = episodes[episodeId]; break }
|
||||
if (episodes[episodeId]) found.push({ hash, entry: episodes[episodeId] })
|
||||
}
|
||||
if (!foundHash || !foundEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
|
||||
if (found.length === 0) { res.status(404).json({ error: 'Épisode non importé' }); return }
|
||||
try {
|
||||
if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) {
|
||||
fs.unlinkSync(foundEntry.dest_path)
|
||||
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`)
|
||||
const deletedPaths = new Set<string>()
|
||||
if (deleteFile) {
|
||||
for (const { entry } of found) {
|
||||
const p = entry?.dest_path
|
||||
if (!p || deletedPaths.has(p) || !fs.existsSync(p)) continue
|
||||
fs.unlinkSync(p)
|
||||
deletedPaths.add(p)
|
||||
logger.info('api', `Désimport + suppression fichier : "${p}"`)
|
||||
}
|
||||
}
|
||||
delete organized[foundHash][episodeId]
|
||||
const torrentEmpty = Object.keys(organized[foundHash]).length === 0
|
||||
if (torrentEmpty) delete organized[foundHash]
|
||||
saveOrganizedJson(organized)
|
||||
// Si plus aucun épisode de ce torrent n'est importé → retirer le torrent du client
|
||||
if (deleteFile && torrentEmpty && foundHash !== 'manual') {
|
||||
dispatchRemove(foundHash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${foundHash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
|
||||
const emptyHashes: string[] = []
|
||||
for (const { hash } of found) {
|
||||
delete organized[hash][episodeId]
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
logger.info('api', `Désimport ep ${episodeId}${deleteFile && torrentEmpty ? ` + torrent ${foundHash.slice(0, 8)}… retiré du client` : ''}`)
|
||||
writeOrganized(organized)
|
||||
// Si plus aucun épisode d'un torrent n'est importé → retirer ce torrent du client
|
||||
if (deleteFile) {
|
||||
for (const hash of emptyHashes) {
|
||||
if (hash === 'manual') continue
|
||||
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
|
||||
}
|
||||
}
|
||||
const removedTorrents = deleteFile ? emptyHashes.filter(h => h !== 'manual') : []
|
||||
logger.info('api', `Désimport ep ${episodeId}${removedTorrents.length ? ` + ${removedTorrents.length} torrent(s) retiré(s) du client` : ''}`)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
@@ -381,12 +462,12 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) =>
|
||||
router.get('/organized-summary', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const { series: seriesData, complete } = await loadCatalogStatus()
|
||||
const organized = readOrganized()
|
||||
const result: any[] = []
|
||||
for (const sd of seriesData) {
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
const serieTitle = serieFolderName(rawTitle)
|
||||
const episodes: any[] = []
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
@@ -403,6 +484,10 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
|
||||
if (!orgEntry && organized['manual']?.[String(ep.id)]) {
|
||||
orgEntry = organized['manual'][String(ep.id)]; orgHash = 'manual'
|
||||
}
|
||||
if (!orgEntry) {
|
||||
const found = findEntry(organized, ep.id)
|
||||
if (found) { orgEntry = found.entry; orgHash = found.hash }
|
||||
}
|
||||
if (!orgEntry) continue
|
||||
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
|
||||
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
|
||||
@@ -413,23 +498,118 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
|
||||
result.push({ serie_id: sd.id, serie_title: rawTitle, serie_title_clean: serieTitle, total: episodes.length, needs_rename: episodes.filter(e => e.needs_rename).length, episodes })
|
||||
}
|
||||
}
|
||||
res.json({ series: result, nfo_support: nfoSupport })
|
||||
const orphans = complete
|
||||
? findOrphans(organized, seriesData).map(o => ({ ...o, file_exists: !!o.dest_path && fs.existsSync(o.dest_path) }))
|
||||
: []
|
||||
res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete })
|
||||
} catch (err) {
|
||||
logger.error('api', `organized-summary échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Retrait des entrées orphelines ─────────────────────────────
|
||||
router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => {
|
||||
const only = Array.isArray(req.body?.episode_ids) ? new Set(req.body.episode_ids.map(String)) : null
|
||||
const { series, complete } = await loadCatalogStatus()
|
||||
if (!complete) { res.status(503).json({ error: 'Catalogue incomplet (scraper ou API Fankai injoignable), réessayez plus tard' }); return }
|
||||
let removed = 0
|
||||
try {
|
||||
updateOrganized(data => {
|
||||
for (const o of findOrphans(data, series)) {
|
||||
if (only && !only.has(String(o.episode_id))) continue
|
||||
delete data[o.hash][String(o.episode_id)]
|
||||
removed++
|
||||
}
|
||||
return removed > 0
|
||||
})
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return
|
||||
}
|
||||
logger.info('api', `Entrées orphelines retirées du suivi : ${removed}`)
|
||||
res.json({ ok: true, removed })
|
||||
})
|
||||
|
||||
// ── Dossier série ──────────────────────────────────────────────
|
||||
router.get('/organized/:serieId/folder', requireAuth, async (req, res) => {
|
||||
const serieId = Number(req.params.serieId)
|
||||
const sd = await resolveSerieData(serieId)
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
|
||||
const { mediaPath } = readSettings()
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
|
||||
const current = [...serieFolders(sd, organized, mediaPath)].map(([folder, entries]) => ({ path: folder, entries, exists: fs.existsSync(folder) }))
|
||||
res.json({ expected, current, needs_rename: current.some(f => f.exists && f.path !== expected) })
|
||||
})
|
||||
|
||||
router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
|
||||
const serieId = Number(req.params.serieId)
|
||||
const sd = await resolveSerieData(serieId, 'Renommage dossier')
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
|
||||
const { mediaPath } = readSettings()
|
||||
if (!mediaPath) { res.status(400).json({ error: 'Chemin médiathèque non configuré' }); return }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
|
||||
const sources = [...serieFolders(sd, organized, mediaPath).keys()].filter(f => f !== expected && fs.existsSync(f))
|
||||
if (sources.length === 0) { res.json({ ok: true, moved: 0, updated: 0 }); return }
|
||||
|
||||
// Tout le contenu (vidéos, NFO, images…) est fusionné dans le dossier attendu, sans jamais écraser
|
||||
const moves = new Map<string, string>()
|
||||
for (const src of sources)
|
||||
for (const file of listFiles(src)) moves.set(file, path.join(expected, path.relative(src, file)))
|
||||
const targets = [...moves.values()]
|
||||
const conflicts = targets.filter((to, i) => fs.existsSync(to) || targets.indexOf(to) !== i)
|
||||
if (conflicts.length > 0) {
|
||||
res.status(409).json({ error: `${conflicts.length} fichier(s) existent déjà dans « ${path.basename(expected)} »`, conflicts: conflicts.slice(0, 10) }); return
|
||||
}
|
||||
|
||||
const done = new Map<string, string>()
|
||||
let failure: string | null = null
|
||||
for (const [from, to] of moves) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true })
|
||||
fs.renameSync(from, to)
|
||||
done.set(from, to)
|
||||
} catch (err) {
|
||||
failure = `${path.basename(from)} : ${err instanceof Error ? err.message : err}`
|
||||
break
|
||||
}
|
||||
}
|
||||
for (const src of sources) {
|
||||
try { removeEmptyDirs(src) } catch {}
|
||||
}
|
||||
let updated = 0
|
||||
try { updateOrganized(data => { updated = retargetEntries(data, done); return updated > 0 }) }
|
||||
catch (err) {
|
||||
logger.error('api', `Renommage dossier série ${serieId} : fichiers déplacés mais suivi non mis à jour : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: 'Fichiers déplacés mais suivi non mis à jour, lancez un scan de la médiathèque' }); return
|
||||
}
|
||||
const label = `"${sources.map(s => path.basename(s)).join('", "')}" → "${path.basename(expected)}"`
|
||||
if (failure) {
|
||||
logger.error('api', `Renommage dossier ${label} interrompu après ${done.size} fichier(s) : ${failure}`)
|
||||
res.status(500).json({ error: `Interrompu après ${done.size} fichier(s) : ${failure}`, moved: done.size, updated }); return
|
||||
}
|
||||
logger.info('api', `Dossier série renommé : ${label} (${done.size} fichier(s), ${updated} entrée(s))`)
|
||||
res.json({ ok: true, moved: done.size, updated })
|
||||
})
|
||||
|
||||
// ── Rename en masse ────────────────────────────────────────────
|
||||
router.post('/rename-all', requireAuth, async (req, res) => {
|
||||
const { serie_id } = req.body
|
||||
router.post('/rename-all', requireAdmin, async (req, res) => {
|
||||
const { serie_id, serie_ids } = req.body
|
||||
const onlyIds = Array.isArray(serie_ids) ? new Set(serie_ids.map(Number)) : serie_id ? new Set([Number(serie_id)]) : null
|
||||
const { nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const seriesData = await loadCatalog()
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
const done: number[] = []
|
||||
const errors: { episode_id: number; error: string }[] = []
|
||||
for (const sd of seriesData) {
|
||||
if (serie_id && sd.id !== Number(serie_id)) continue
|
||||
if (onlyIds && !onlyIds.has(sd.id)) continue
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
let orgEntry: any = null
|
||||
@@ -455,6 +635,7 @@ router.post('/rename-all', requireAuth, async (req, res) => {
|
||||
}
|
||||
fs.renameSync(oldPath, newPath)
|
||||
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
|
||||
retargetEntries(organized, new Map([[oldPath, newPath]]))
|
||||
done.push(ep.id)
|
||||
logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`)
|
||||
} catch (err) {
|
||||
@@ -465,7 +646,7 @@ router.post('/rename-all', requireAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
writeOrganized(organized)
|
||||
res.json({ ok: true, done: done.length, errors })
|
||||
})
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
|
||||
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
|
||||
import { loadCatalog } from '../lib/serie-helpers.js'
|
||||
import { recentOrganized, pushNotif } from '../lib/notifs.js'
|
||||
import { dispatchGetFiles } from '../torrent-clients/index.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import path from 'path'
|
||||
|
||||
@@ -11,7 +12,7 @@ const router = Router()
|
||||
|
||||
router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const seriesData = await loadCatalog()
|
||||
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
||||
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
res.json({ ok: true, ...result })
|
||||
@@ -35,8 +36,14 @@ router.post('/organize', requireAuth, async (req, res) => {
|
||||
const { hash, name, save_path } = req.body
|
||||
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await organizeTorrent(hash, name, save_path, seriesData)
|
||||
const seriesData = await loadCatalog()
|
||||
|
||||
// Récupère la progression par fichier depuis le client torrent.
|
||||
// Permet au worker de sauter les fichiers encore en téléchargement (EBUSY sur Windows).
|
||||
// En cas d'échec (client injoignable, hash non trouvé), on continue sans filtre.
|
||||
const files = await dispatchGetFiles(hash).catch(() => [])
|
||||
|
||||
const result = await organizeTorrent(hash, name, save_path, seriesData, files)
|
||||
if (result.done > 0 || result.errors.length > 0) {
|
||||
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { requireAuth, requireAdmin } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { loadSynced, setSync, isSynced, runRssSync } from '../lib/rss-sync.js'
|
||||
import { loadSynced, setSync, setSyncBulk, isSynced, runRssSync, reconcileSynced } from '../lib/rss-sync.js'
|
||||
import { loadCatalogStatus } from '../lib/serie-helpers.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -21,6 +22,24 @@ router.post('/rss-sync/run', requireAuth, async (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** GET /api/rss-sync/orphans → surveillances dont la série n'existe plus (avant /:id) */
|
||||
router.get('/rss-sync/orphans', requireAdmin, async (_req, res) => {
|
||||
const { series, complete } = await loadCatalogStatus()
|
||||
res.json({ checked: complete, orphans: complete ? reconcileSynced(series) : [] })
|
||||
})
|
||||
|
||||
/** POST /api/rss-sync/bulk → activer/désactiver la surveillance de plusieurs séries (avant /:id) */
|
||||
router.post('/rss-sync/bulk', requireAdmin, (req, res) => {
|
||||
const enabled = req.body?.enabled === true
|
||||
const series = (Array.isArray(req.body?.series) ? req.body.series : [])
|
||||
.map((s: any) => ({ id: Number(s?.id), name: String(s?.name ?? '') }))
|
||||
.filter((s: { id: number; name: string }) => s.id && s.name)
|
||||
if (series.length === 0) { res.status(400).json({ error: 'series requis' }); return }
|
||||
const { changed, map } = setSyncBulk(series, enabled)
|
||||
logger.info('rss-sync', `Surveillance ${enabled ? 'activée' : 'désactivée'} en masse — ${changed} série(s) modifiée(s) sur ${series.length}`)
|
||||
res.json({ ok: true, changed, total: Object.keys(map).length })
|
||||
})
|
||||
|
||||
/** GET /api/rss-sync/:id → statut sync pour une série */
|
||||
router.get('/rss-sync/:id', requireAuth, (req, res) => {
|
||||
const serieId = Number(req.params.id)
|
||||
|
||||
+20
-20
@@ -7,10 +7,11 @@ import { DATA_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { dispatchList } from '../torrent-clients/index.js'
|
||||
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
|
||||
import { loadSynced } from '../lib/rss-sync.js'
|
||||
import {
|
||||
fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode,
|
||||
fankaiGet, fetchSerieFromApi, normalizeSerie, normalizeSeason, normalizeEpisode,
|
||||
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
|
||||
deduplicateEpisodes,
|
||||
deduplicateEpisodes, serieHasEpisodes,
|
||||
} from '../lib/serie-helpers.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -22,14 +23,19 @@ router.get('/series', requireAuth, async (_req, res) => {
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const activeTorrents = new Set<string>()
|
||||
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
|
||||
const clientHashes = new Set<string>()
|
||||
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (!t.hash) continue; clientHashes.add(t.hash.toLowerCase()); if (t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
|
||||
const organizedEpisodeIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
|
||||
const synced = loadSynced()
|
||||
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
|
||||
const serieDataMap = new Map<number, any>()
|
||||
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
|
||||
res.json({ series: seriesRaw.map((serie: any) => {
|
||||
const serieData = serieDataMap.get(serie.id) ?? null
|
||||
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false
|
||||
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) }
|
||||
const serieData = serieDataMap.get(serie.id) ?? null
|
||||
const torrents = serieData ? extractTorrentsFromSerieData(serieData) : []
|
||||
const inClient = torrents.some(t => t.infohash && clientHashes.has(t.infohash.toLowerCase()))
|
||||
const hasFiles = (serieData?.seasons ?? []).some((s: any) => (s.episodes ?? []).some((ep: any) => organizedEpisodeIds.has(String(ep.id))))
|
||||
return { ...serie, torrent_count: torrents.length, has_torrents: torrents.length > 0, download_state: computeSerieDownloadState(serieData, organized, activeTorrents), in_client: inClient, has_files: hasFiles, rss_synced: !!synced[serie.id] }
|
||||
})})
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
|
||||
@@ -40,14 +46,9 @@ router.get('/series', requireAuth, async (_req, res) => {
|
||||
router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const [serieRaw, seasonsData, serieData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`), readSerieData(id)])
|
||||
const [{ seasons, ...serieRaw }, serieData] = await Promise.all([fetchSerieFromApi(id), readSerieData(id)])
|
||||
const serie = { ...normalizeSerie(serieRaw), wiki: serieData?.wiki ?? null }
|
||||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
||||
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
|
||||
return { ...normalizeSeason(season), episodes }
|
||||
}))
|
||||
const seasonsWithEpisodes = seasons.map((season: any) => ({ ...normalizeSeason(season), episodes: season.episodes.map(normalizeEpisode) }))
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
|
||||
@@ -57,12 +58,11 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
const integraleTorrents : any[] = []
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
|
||||
const manualOrg = organized['manual'] ?? {}
|
||||
if (serieData) {
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
// Importé = une entrée pour l'épisode sous n'importe quel hash : les torrents du scraper peuvent disparaître
|
||||
const trackedIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
|
||||
for (const season of seasonsWithEpisodes) {
|
||||
for (const ep of season.episodes) {
|
||||
if (trackedIds.has(String(ep.id))) organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
if (serieData) {
|
||||
@@ -185,7 +185,7 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) })
|
||||
res.json({ serie, seasons: enrichedSeasons, scraper_synced: serieHasEpisodes(serieData), torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) })
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
|
||||
@@ -10,7 +10,7 @@ router.get('/settings', requireAuth, (_req, res) => {
|
||||
})
|
||||
|
||||
router.post('/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing, requestAutoDownloadUsers } = req.body
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing, requestAutoDownloadUsers, englishDirectory } = req.body
|
||||
const patch: Record<string, any> = {}
|
||||
if (mediaPath !== undefined) patch.mediaPath = mediaPath
|
||||
if (completePath !== undefined) patch.completePath = completePath
|
||||
@@ -22,6 +22,7 @@ router.post('/settings', requireAuth, (req, res) => {
|
||||
if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
|
||||
if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing
|
||||
if (requestAutoDownloadUsers !== undefined) patch.requestAutoDownloadUsers = requestAutoDownloadUsers
|
||||
if (englishDirectory !== undefined) patch.englishDirectory = englishDirectory
|
||||
const updated = writeSettings(patch)
|
||||
logger.info('api', 'Paramètres mis à jour')
|
||||
res.json(updated)
|
||||
|
||||
+28
-13
@@ -6,8 +6,9 @@ import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { systemInfo } from '../system.js'
|
||||
import { scanMediaPath, syncFilenameChanges } from '../organize.js'
|
||||
import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js'
|
||||
import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js'
|
||||
import { readAvailable, cacheClear } from '../lib/github-cache.js'
|
||||
import { loadCatalog } from '../lib/serie-helpers.js'
|
||||
import { cacheSize } from '../lib/github-cache.js'
|
||||
import { recentOrganized } from '../lib/notifs.js'
|
||||
import { workerRunning } from '../organize.js'
|
||||
@@ -87,10 +88,8 @@ router.get('/browse-files', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
|
||||
try {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
logger.info('api', `Dossier créé : ${dirPath}`)
|
||||
}
|
||||
// Lecture seule : le dossier est créé au moment de l'import
|
||||
if (!fs.existsSync(dirPath)) { res.json({ path: dirPath, files: [], exists: false }); return }
|
||||
const files: { name: string; path: string; size: number }[] = []
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
@@ -111,7 +110,7 @@ router.get('/browse-files', requireAuth, (req, res) => {
|
||||
}
|
||||
walk(dirPath)
|
||||
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||
res.json({ path: dirPath, files })
|
||||
res.json({ path: dirPath, files, exists: true })
|
||||
} catch (err) {
|
||||
logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
|
||||
@@ -126,18 +125,34 @@ router.get('/torrents/status', requireAuth, async (_req, res) => {
|
||||
} catch { res.json({ exists: false, count: 0, empty: true }) }
|
||||
})
|
||||
|
||||
router.post('/update', requireAuth, async (_req, res) => {
|
||||
router.post('/update', requireAuth, async (req, res) => {
|
||||
const force = req.body?.force === true
|
||||
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
||||
try {
|
||||
cacheClear()
|
||||
logger.info('api', 'Cache GitHub vidé — rechargement forcé')
|
||||
const availableIds = await readAvailable()
|
||||
logger.info('api', force
|
||||
? 'Forçage des correspondances de renommage — cache mémoire et CDN contournés'
|
||||
: 'Cache GitHub vidé — rechargement forcé')
|
||||
|
||||
const availableIds = await readAvailable(force)
|
||||
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
|
||||
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
|
||||
|
||||
if (force) {
|
||||
const seriesData = await loadCatalog(true)
|
||||
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
const { removed } = dedupeOrganizedEpisodes(organizedPath)
|
||||
const { renamed, errors } = await syncFilenameChanges(seriesData, organizedPath)
|
||||
logger.info('api', `Forçage terminé — ${updated} ID(s) migré(s), ${removed} doublon(s) retiré(s), ${renamed} fichier(s) renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
|
||||
res.json({ ok: true, count: availableIds.length, migrated: updated, deduped: removed, renamed, errors })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({ ok: true, count: availableIds.length })
|
||||
;(async () => {
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
|
||||
const seriesData = await loadCatalog()
|
||||
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
|
||||
if (renamed > 0)
|
||||
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
|
||||
} catch (err) {
|
||||
@@ -154,7 +169,7 @@ router.post('/scan', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { mediaPath } = readSettings()
|
||||
logger.info('api', 'Scan médiathèque manuel lancé')
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const seriesData = await loadCatalog()
|
||||
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface Settings {
|
||||
requestAutoDownloadUsers: 'all' | string[] // IDs users autorisés au dl auto sur approbation ; [] = désactivé
|
||||
jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096)
|
||||
jellyfinAdminToken : string // Token admin Jellyfin pour la sync
|
||||
englishDirectory : boolean // Si true, les dossiers de séries sont en anglais (ex: "Season 1" au lieu de "Saison 1")
|
||||
}
|
||||
|
||||
const defaults: Settings = {
|
||||
@@ -30,6 +31,7 @@ const defaults: Settings = {
|
||||
requestAutoDownloadUsers: [],
|
||||
jellyfinUrl : '',
|
||||
jellyfinAdminToken : '',
|
||||
englishDirectory : false,
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const AD_BASE = 'https://api.alldebrid.com/v4.1'
|
||||
const AD_BASE_V4 = 'https://api.alldebrid.com/v4'
|
||||
const AD_AGENT = 'FanKarr'
|
||||
|
||||
function adUrl(config: Record<string, string | number>, path: string, base = AD_BASE): string {
|
||||
const sep = path.includes('?') ? '&' : '?'
|
||||
return `${base}${path}${sep}agent=${AD_AGENT}&apikey=${config.apiKey}`
|
||||
}
|
||||
|
||||
async function adFetch(
|
||||
config: Record<string, string | number>,
|
||||
method: 'GET' | 'POST' | 'DELETE',
|
||||
path : string,
|
||||
body ?: URLSearchParams | FormData,
|
||||
base = AD_BASE,
|
||||
): Promise<any> {
|
||||
const res = await fetch(adUrl(config, path, base), { method, body })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`AllDebrid HTTP ${res.status} : ${text}`)
|
||||
}
|
||||
const json = await res.json()
|
||||
if (json.status === 'error') {
|
||||
throw new Error(`AllDebrid : ${json.error?.message ?? json.error?.code ?? 'Erreur inconnue'}`)
|
||||
}
|
||||
return json.data
|
||||
}
|
||||
|
||||
// statusCode : 0=Processing, 1=Downloading, 2=Compressing, 3=Uploading, 4=Ready,
|
||||
// 5=Upload Fail, 6=Internal Error, 7=Not Downloaded, 8=File Not Available,
|
||||
// 9=Banned BitTorrent, 10=Awaiting Slot, 11=Free Trial Limit Reached
|
||||
function mapStatus(statusCode: number): TorrentInfo['state'] {
|
||||
switch (statusCode) {
|
||||
case 4: return 'seeding'
|
||||
case 1:
|
||||
case 0:
|
||||
case 2:
|
||||
case 3:
|
||||
case 10: return 'downloading'
|
||||
case 5:
|
||||
case 6:
|
||||
case 7:
|
||||
case 8:
|
||||
case 9:
|
||||
case 11: return 'error'
|
||||
default: return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function flattenFiles(nodes: any[], prefix = '', startIdx = 0): TorrentFileProgress[] {
|
||||
const result: TorrentFileProgress[] = []
|
||||
let idx = startIdx
|
||||
for (const node of nodes) {
|
||||
if (node.e) {
|
||||
const children = flattenFiles(node.e, prefix ? `${prefix}/${node.n}` : node.n, idx)
|
||||
result.push(...children)
|
||||
idx += children.length
|
||||
} else if (node.n) {
|
||||
result.push({ index: idx++, name: prefix ? `${prefix}/${node.n}` : node.n, progress: 0, priority: 1 })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const adDriver: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'alldebrid',
|
||||
label : 'AllDebrid',
|
||||
fields: [
|
||||
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
|
||||
{ key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/alldebrid', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
const data = await adFetch(config, 'GET', '/user')
|
||||
const username = data.user?.username ?? data.user?.login ?? '?'
|
||||
logger.info('alldebrid', `Connexion réussie — utilisateur : ${username}`)
|
||||
return { ok: true, message: `Connecté en tant que ${username}` }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.warn('alldebrid', `Test de connexion échoué : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const data = await adFetch(config, 'GET', '/user')
|
||||
const username = data.user?.username ?? data.user?.login ?? '?'
|
||||
return { online: true, version: `AD — ${username}` }
|
||||
} catch {
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
if (options?.file_index != null)
|
||||
logger.warn('alldebrid', `Sélection de fichier (index ${options.file_index}) non supportée par AllDebrid — pack complet téléchargé`)
|
||||
|
||||
const isMagnet = url.startsWith('magnet:')
|
||||
|
||||
if (isMagnet) {
|
||||
const body = new URLSearchParams()
|
||||
body.append('magnets[]', url)
|
||||
const data = await adFetch(config, 'POST', '/magnet/upload', body)
|
||||
const id = data.magnets?.[0]?.id
|
||||
logger.info('alldebrid', `Magnet ajouté → ID ${id}`)
|
||||
} else {
|
||||
const torrentRes = await fetch(url)
|
||||
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`)
|
||||
const torrentBuf = await torrentRes.arrayBuffer()
|
||||
const formData = new FormData()
|
||||
formData.append('files[]', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
|
||||
const data = await adFetch(config, 'POST', '/magnet/upload/file', formData)
|
||||
const id = data.magnets?.[0]?.id
|
||||
logger.info('alldebrid', `.torrent uploadé → ID ${id}`)
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, _category?) {
|
||||
const data = await adFetch(config, 'GET', '/magnet/status')
|
||||
const magnets: any[] = data.magnets ?? []
|
||||
const localBase = String(config.localPath ?? '').replace(/\/+$/, '')
|
||||
|
||||
return magnets.map((m: any): TorrentInfo => {
|
||||
const statusCode = typeof m.statusCode === 'number' ? m.statusCode : 0
|
||||
const size = typeof m.size === 'number' ? m.size : 0
|
||||
const downloaded = typeof m.downloaded === 'number' ? m.downloaded : 0
|
||||
const progress = size > 0
|
||||
? Math.round((downloaded / size) * 100)
|
||||
: (statusCode === 4 ? 100 : 0)
|
||||
const save_path = localBase ? `${localBase}/${m.filename ?? m.id}` : ''
|
||||
|
||||
const eta = (typeof m.completionDate === 'number' && m.completionDate > 0)
|
||||
? Math.max(0, m.completionDate - Math.floor(Date.now() / 1000))
|
||||
: -1
|
||||
|
||||
return {
|
||||
hash : String(m.hash ?? m.id ?? '').toLowerCase(),
|
||||
name : String(m.filename ?? m.id ?? ''),
|
||||
state : mapStatus(statusCode),
|
||||
progress,
|
||||
size,
|
||||
downloaded,
|
||||
uploaded : typeof m.uploaded === 'number' ? m.uploaded : 0,
|
||||
ratio : 0,
|
||||
speed : typeof m.downloadSpeed === 'number' ? m.downloadSpeed : 0,
|
||||
upspeed : typeof m.uploadSpeed === 'number' ? m.uploadSpeed : 0,
|
||||
eta,
|
||||
save_path,
|
||||
category : '',
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async remove(config, hash, _deleteFiles?) {
|
||||
const data = await adFetch(config, 'GET', '/magnet/status')
|
||||
const magnets: any[] = data.magnets ?? []
|
||||
const target = magnets.find((m: any) => String(m.hash ?? m.id ?? '').toLowerCase() === hash.toLowerCase())
|
||||
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur AllDebrid`)
|
||||
await adFetch(config, 'DELETE', `/magnet/delete?id=${target.id}`)
|
||||
logger.info('alldebrid', `Torrent ${hash.slice(0, 8)}… supprimé de AllDebrid`)
|
||||
},
|
||||
|
||||
async getFiles(config, hash) {
|
||||
const data = await adFetch(config, 'GET', '/magnet/status')
|
||||
const magnets: any[] = data.magnets ?? []
|
||||
const target = magnets.find((m: any) => String(m.hash ?? m.id ?? '').toLowerCase() === hash.toLowerCase())
|
||||
if (!target) return []
|
||||
|
||||
const statusCode = typeof target.statusCode === 'number' ? target.statusCode : 0
|
||||
const size = typeof target.size === 'number' ? target.size : 0
|
||||
const downloaded = typeof target.downloaded === 'number' ? target.downloaded : 0
|
||||
const overallProgress = size > 0 ? downloaded / size : (statusCode === 4 ? 1 : 0)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.append('id[]', String(target.id))
|
||||
const filesData = await adFetch(config, 'POST', '/magnet/files', body, AD_BASE_V4)
|
||||
const magnetEntry = (filesData.magnets ?? [])[0]
|
||||
|
||||
if (!Array.isArray(magnetEntry?.files)) return []
|
||||
|
||||
return flattenFiles(magnetEntry.files).map(f => ({ ...f, progress: overallProgress }))
|
||||
},
|
||||
}
|
||||
|
||||
export default adDriver
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Deluge Web JSON-RPC driver
|
||||
*
|
||||
* Auth : POST {url}/json → auth.login(password) → cookie _session_id
|
||||
* RPC : POST {url}/json avec { method, params, id } + Cookie header
|
||||
* States : "Downloading" | "Seeding" | "Paused" | "Checking"
|
||||
* "Error" | "Queued" | "Allocating" | "Moving"
|
||||
* Progress : float 0–100 (déjà en pourcentage, pas 0–1)
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── Session cache ─────────────────────────────────────────────────────────────
|
||||
// Chaque entrée est indexée par "url::password" pour supporter plusieurs instances.
|
||||
// TTL 30 min — Deluge invalide les sessions inactives, on relogine automatiquement.
|
||||
|
||||
interface SessionEntry { cookie: string; expires: number }
|
||||
const _sessions = new Map<string, SessionEntry>()
|
||||
|
||||
function sessionKey(config: ClientConfig): string {
|
||||
return `${config.url}::${config.password}`
|
||||
}
|
||||
|
||||
// ─── Helpers HTTP ──────────────────────────────────────────────────────────────
|
||||
|
||||
function delugeUrl(config: ClientConfig): string {
|
||||
return String(config.url ?? '').replace(/\/+$/, '') + '/json'
|
||||
}
|
||||
|
||||
function parseCookie(raw: string): string {
|
||||
// "set-cookie: _session_id=abc123; Path=/; HttpOnly" → "_session_id=abc123"
|
||||
return raw.split(';')[0].trim()
|
||||
}
|
||||
|
||||
async function delugeRPC(
|
||||
config : ClientConfig,
|
||||
url : string,
|
||||
method : string,
|
||||
params : unknown[],
|
||||
cookie : string,
|
||||
): Promise<{ result: any; newCookie: string | null }> {
|
||||
const res = await clientFetch(config, url, {
|
||||
method : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept' : 'application/json',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
body: JSON.stringify({ method, params, id: 1 }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`Deluge HTTP ${res.status} : ${text}`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
if (json.error) {
|
||||
throw new Error(`Deluge RPC : ${json.error.message ?? JSON.stringify(json.error)}`)
|
||||
}
|
||||
|
||||
const setCookie = res.headers.get('set-cookie')
|
||||
return {
|
||||
result : json.result,
|
||||
newCookie: setCookie ? parseCookie(setCookie) : null,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Login ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function delugeLogin(config: ClientConfig): Promise<string> {
|
||||
const url = delugeUrl(config)
|
||||
const res = await clientFetch(config, url, {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body : JSON.stringify({ method: 'auth.login', params: [String(config.password ?? '')], id: 1 }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`Deluge HTTP ${res.status} : ${text}`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
if (json.error) throw new Error(`Deluge login : ${json.error.message ?? JSON.stringify(json.error)}`)
|
||||
if (!json.result) throw new Error('Deluge : authentification échouée (mot de passe incorrect ?)')
|
||||
|
||||
const setCookie = res.headers.get('set-cookie')
|
||||
if (!setCookie) throw new Error('Deluge : pas de cookie de session reçu après login')
|
||||
|
||||
return parseCookie(setCookie)
|
||||
}
|
||||
|
||||
// ─── Call avec gestion de session ─────────────────────────────────────────────
|
||||
|
||||
async function delugeCall(
|
||||
config : ClientConfig,
|
||||
method : string,
|
||||
params : unknown[] = [],
|
||||
): Promise<any> {
|
||||
const url = delugeUrl(config)
|
||||
const key = sessionKey(config)
|
||||
|
||||
// Récupère ou rafraîchit la session
|
||||
let entry = _sessions.get(key)
|
||||
if (!entry || Date.now() >= entry.expires) {
|
||||
const cookie = await delugeLogin(config)
|
||||
entry = { cookie, expires: Date.now() + 30 * 60_000 }
|
||||
_sessions.set(key, entry)
|
||||
}
|
||||
|
||||
try {
|
||||
const { result, newCookie } = await delugeRPC(config, url, method, params, entry.cookie)
|
||||
// Mise à jour du cookie si Deluge en renvoie un nouveau
|
||||
if (newCookie) {
|
||||
entry.cookie = newCookie
|
||||
entry.expires = Date.now() + 30 * 60_000
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
// Session expirée → relogin unique
|
||||
if (msg.includes('Not authorized') || msg.includes('auth') || msg.includes('401')) {
|
||||
_sessions.delete(key)
|
||||
const cookie = await delugeLogin(config)
|
||||
const fresh = { cookie, expires: Date.now() + 30 * 60_000 }
|
||||
_sessions.set(key, fresh)
|
||||
const { result } = await delugeRPC(config, url, method, params, cookie)
|
||||
return result
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mapping état ──────────────────────────────────────────────────────────────
|
||||
|
||||
function mapState(state: string): TorrentInfo['state'] {
|
||||
switch (state) {
|
||||
case 'Downloading':
|
||||
case 'Queued' :
|
||||
case 'Allocating' :
|
||||
case 'Moving' : return 'downloading'
|
||||
case 'Seeding' : return 'seeding'
|
||||
case 'Paused' : return 'paused'
|
||||
case 'Checking' : return 'checking'
|
||||
case 'Error' : return 'error'
|
||||
default : return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Aplatissement de l'arborescence de fichiers Deluge ────────────────────────
|
||||
// web.get_torrent_files retourne :
|
||||
// { contents: { "nom": { type: "file"|"dir", index, progress, priority, contents? } } }
|
||||
|
||||
function flattenDelugeFiles(
|
||||
contents: Record<string, any>,
|
||||
prefix = '',
|
||||
): TorrentFileProgress[] {
|
||||
const result: TorrentFileProgress[] = []
|
||||
for (const [name, node] of Object.entries(contents ?? {})) {
|
||||
const fullPath = prefix ? `${prefix}/${name}` : name
|
||||
if (node.type === 'dir') {
|
||||
result.push(...flattenDelugeFiles(node.contents ?? {}, fullPath))
|
||||
} else {
|
||||
result.push({
|
||||
index : typeof node.index === 'number' ? node.index : 0,
|
||||
name : fullPath,
|
||||
progress: typeof node.progress === 'number' ? node.progress : 0,
|
||||
priority: typeof node.priority === 'number' ? node.priority : 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Sélection de fichier (asynchrone, best-effort) ──────────────────────────
|
||||
// Attend que les métadonnées soient disponibles (~60 s max) puis applique
|
||||
// les priorités : fichier voulu = 1 (normal), tous les autres = 0 (skip).
|
||||
|
||||
async function applyFilePriority(
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
try {
|
||||
const status = await delugeCall(config, 'core.get_torrent_status', [hash, ['num_files', 'has_metadata']])
|
||||
const numFiles = typeof status?.num_files === 'number' ? status.num_files : 0
|
||||
if (numFiles === 0 || !status?.has_metadata) continue
|
||||
|
||||
const priorities = Array.from({ length: numFiles }, (_, i) => i === fileIndex ? 1 : 0)
|
||||
await delugeCall(config, 'core.set_torrent_options', [[hash], { file_priorities: priorities }])
|
||||
logger.info('deluge', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
logger.warn('deluge', `Timeout : priorité fichier non appliquée pour ${hash.slice(0, 8)}…`)
|
||||
}
|
||||
|
||||
// ─── Driver ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const delugeDriver: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'deluge',
|
||||
label : 'Deluge',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8112', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie (Label plugin)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
// ── Test ───────────────────────────────────────────────────────────────────
|
||||
async test(config) {
|
||||
try {
|
||||
_sessions.delete(sessionKey(config))
|
||||
const cookie = await delugeLogin(config)
|
||||
_sessions.set(sessionKey(config), { cookie, expires: Date.now() + 30 * 60_000 })
|
||||
logger.info('deluge', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.warn('deluge', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
// ── Healthcheck ────────────────────────────────────────────────────────────
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
// daemon.get_version() retourne la version du daemon Deluge
|
||||
const version = await delugeCall(config, 'daemon.get_version')
|
||||
return { online: true, version: String(version ?? 'inconnue') }
|
||||
} catch {
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
// ── List ───────────────────────────────────────────────────────────────────
|
||||
async list(config, category?) {
|
||||
const keys = [
|
||||
'name', 'state', 'progress', 'total_size', 'total_done',
|
||||
'total_uploaded', 'ratio', 'download_payload_rate', 'upload_payload_rate',
|
||||
'eta', 'save_path', 'label',
|
||||
]
|
||||
// filter_dict vide → tous les torrents ; le filtrage par label se fait en JS
|
||||
// pour ne pas dépendre du Label plugin côté serveur.
|
||||
const raw: Record<string, any> = await delugeCall(config, 'core.get_torrents_status', [{}, keys]) ?? {}
|
||||
|
||||
return Object.entries(raw)
|
||||
.filter(([_, t]) => !category || (t.label ?? '') === category)
|
||||
.map(([hash, t]): TorrentInfo => ({
|
||||
hash : hash.toLowerCase(),
|
||||
name : String(t.name ?? ''),
|
||||
state : mapState(String(t.state ?? '')),
|
||||
progress : Math.round(t.progress ?? 0), // déjà en %
|
||||
size : typeof t.total_size === 'number' ? t.total_size : 0,
|
||||
downloaded: typeof t.total_done === 'number' ? t.total_done : 0,
|
||||
uploaded : typeof t.total_uploaded === 'number' ? t.total_uploaded : 0,
|
||||
ratio : Math.round((t.ratio ?? 0) * 100) / 100,
|
||||
speed : typeof t.download_payload_rate === 'number' ? t.download_payload_rate : 0,
|
||||
upspeed : typeof t.upload_payload_rate === 'number' ? t.upload_payload_rate : 0,
|
||||
eta : typeof t.eta === 'number' && t.eta >= 0 ? t.eta : -1,
|
||||
save_path : String(t.save_path ?? ''),
|
||||
category : String(t.label ?? ''),
|
||||
}))
|
||||
},
|
||||
|
||||
// ── Add ────────────────────────────────────────────────────────────────────
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const addOpts: Record<string, unknown> = {}
|
||||
if (config.savePath) addOpts['download_location'] = String(config.savePath)
|
||||
|
||||
let hash: string | null = null
|
||||
|
||||
if (url.startsWith('magnet:')) {
|
||||
hash = await delugeCall(config, 'core.add_torrent_magnet', [url, addOpts])
|
||||
logger.info('deluge', `Magnet ajouté → ${hash?.slice(0, 8) ?? '?'}…`)
|
||||
} else {
|
||||
// Télécharge le .torrent puis l'envoie en base64 via core.add_torrent_file
|
||||
const torrentRes = await fetch(url)
|
||||
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent : ${torrentRes.status}`)
|
||||
const buf = await torrentRes.arrayBuffer()
|
||||
const b64 = Buffer.from(buf).toString('base64')
|
||||
const filename = url.split('/').pop()?.replace(/\?.*$/, '') ?? 'torrent.torrent'
|
||||
hash = await delugeCall(config, 'core.add_torrent_file', [filename, b64, addOpts])
|
||||
logger.info('deluge', `.torrent uploadé → ${hash?.slice(0, 8) ?? '?'}…`)
|
||||
}
|
||||
|
||||
// Label plugin — best-effort (silencieux si plugin absent)
|
||||
if (config.category && hash) {
|
||||
delugeCall(config, 'label.set_torrent', [hash, String(config.category)]).catch(() => {})
|
||||
}
|
||||
|
||||
// Sélection de fichier — asynchrone, best-effort
|
||||
if (options?.file_index != null && hash) {
|
||||
applyFilePriority(config, hash, options.file_index).catch(() => {})
|
||||
}
|
||||
},
|
||||
|
||||
// ── Remove ─────────────────────────────────────────────────────────────────
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
await delugeCall(config, 'core.remove_torrent', [hash, deleteFiles])
|
||||
logger.info('deluge', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
},
|
||||
|
||||
// ── Get files ──────────────────────────────────────────────────────────────
|
||||
async getFiles(config, hash) {
|
||||
const data = await delugeCall(config, 'web.get_torrent_files', [hash])
|
||||
if (!data?.contents) return []
|
||||
return flattenDelugeFiles(data.contents)
|
||||
},
|
||||
}
|
||||
|
||||
export default delugeDriver
|
||||
+131
-15
@@ -7,31 +7,38 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Agent, fetch as undiciFetch } from 'undici'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Config d'un client torrent (les champs `boolean` sont stockés en booléens). */
|
||||
export type ClientConfig = Record<string, string | number | boolean>
|
||||
|
||||
export interface FieldDef {
|
||||
key : string
|
||||
label : string
|
||||
type : 'text' | 'password' | 'number' | 'url'
|
||||
type : 'text' | 'password' | 'number' | 'url' | 'boolean'
|
||||
placeholder?: string
|
||||
required : boolean
|
||||
default? : string | number
|
||||
default? : string | number | boolean
|
||||
}
|
||||
|
||||
export interface TorrentClientDefinition {
|
||||
id : string
|
||||
label : string
|
||||
fields : FieldDef[]
|
||||
id : string
|
||||
label : string
|
||||
fields : FieldDef[]
|
||||
/** Si true, dispatchList filtre les résultats pour n'inclure que les
|
||||
* torrents ajoutés via FanKarr (clients sans catégories natives). */
|
||||
filterByManagedHashes?: boolean
|
||||
}
|
||||
|
||||
export interface SavedClient {
|
||||
uuid : string
|
||||
name : string
|
||||
type : string
|
||||
config: Record<string, string | number>
|
||||
config: ClientConfig
|
||||
}
|
||||
|
||||
export interface TorrentFileProgress {
|
||||
@@ -67,12 +74,50 @@ export interface DownloadOptions {
|
||||
|
||||
export interface TorrentClientDriver {
|
||||
definition : TorrentClientDefinition
|
||||
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
|
||||
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
|
||||
add : (config: Record<string, string | number>, url: string, options?: DownloadOptions) => Promise<void>
|
||||
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
|
||||
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
|
||||
getFiles ?: (config: Record<string, string | number>, hash: string) => Promise<TorrentFileProgress[]>
|
||||
test : (config: ClientConfig) => Promise<{ ok: boolean; message: string }>
|
||||
healthcheck : (config: ClientConfig) => Promise<{ online: boolean; version?: string }>
|
||||
add : (config: ClientConfig, url: string, options?: DownloadOptions) => Promise<void>
|
||||
list : (config: ClientConfig, category?: string) => Promise<TorrentInfo[]>
|
||||
remove : (config: ClientConfig, hash: string, deleteFiles?: boolean) => Promise<void>
|
||||
getFiles ?: (config: ClientConfig, hash: string) => Promise<TorrentFileProgress[]>
|
||||
}
|
||||
|
||||
// ─── Certificats TLS ──────────────────────────────────────────────────────────
|
||||
// Option "ignoreCertificateErrors" (par client) : accepte les certificats
|
||||
// auto-signés/invalides. Node → Agent undici (cf. plex.ts) ; Bun → option
|
||||
// `tls` de son fetch natif (l'Agent undici n'y est pas honoré).
|
||||
|
||||
let _insecureAgent: Agent | null = null
|
||||
|
||||
function insecureAgent(): Agent {
|
||||
if (!_insecureAgent) _insecureAgent = new Agent({ connect: { rejectUnauthorized: false } })
|
||||
return _insecureAgent
|
||||
}
|
||||
|
||||
export function ignoreCertificateErrors(config: ClientConfig): boolean {
|
||||
const v = config.ignoreCertificateErrors
|
||||
return v === true || v === 'true' || v === 1
|
||||
}
|
||||
|
||||
// Option `tls` propre au fetch de Bun, absente des types standards.
|
||||
interface BunRequestInit extends RequestInit {
|
||||
tls?: { rejectUnauthorized?: boolean }
|
||||
}
|
||||
|
||||
const isBun = 'bun' in process.versions
|
||||
|
||||
/** fetch du client : accepte les certificats invalides si l'option est active. */
|
||||
export function clientFetch(
|
||||
config : ClientConfig,
|
||||
url : string,
|
||||
init : RequestInit = {},
|
||||
): Promise<Response> {
|
||||
if (!ignoreCertificateErrors(config)) return fetch(url, init)
|
||||
if (isBun) {
|
||||
const bunInit: BunRequestInit = { ...init, tls: { rejectUnauthorized: false } }
|
||||
return fetch(url, bunInit)
|
||||
}
|
||||
return undiciFetch(url, { ...init, dispatcher: insecureAgent() } as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>
|
||||
}
|
||||
|
||||
// ─── Registry ─────────────────────────────────────────────────────────────────
|
||||
@@ -94,7 +139,64 @@ export function getAvailableClients(): TorrentClientDefinition[] {
|
||||
|
||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
||||
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
||||
|
||||
// ─── Managed tasks ─────────────────────────────────────────────────────────────
|
||||
// Stocke les infohashes des torrents ajoutés via FanKarr, par UUID client.
|
||||
// Utilisé par dispatchList pour filtrer les résultats des clients sans
|
||||
// catégories natives (ex: Synology Download Station).
|
||||
// Structure : { clientUuid: [infohash, ...] }
|
||||
|
||||
const MANAGED_PATH = path.join(DATA_DIR, 'managed_tasks.json')
|
||||
|
||||
function loadManagedMap(): Record<string, string[]> {
|
||||
try {
|
||||
if (!fs.existsSync(MANAGED_PATH)) return {}
|
||||
return JSON.parse(fs.readFileSync(MANAGED_PATH, 'utf-8'))
|
||||
} catch { return {} }
|
||||
}
|
||||
|
||||
function saveManagedMap(data: Record<string, string[]>) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(MANAGED_PATH), { recursive: true })
|
||||
fs.writeFileSync(MANAGED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Retourne le Set des infohashes gérés pour un client, ou null si vide. */
|
||||
function getManagedSet(clientUuid: string): Set<string> | null {
|
||||
const list = loadManagedMap()[clientUuid]
|
||||
if (!list || list.length === 0) return null
|
||||
return new Set(list)
|
||||
}
|
||||
|
||||
/** Enregistre un infohash comme géré pour un client (idempotent). */
|
||||
export function trackManagedHash(clientUuid: string, infohash: string) {
|
||||
const data = loadManagedMap()
|
||||
if (!data[clientUuid]) data[clientUuid] = []
|
||||
const h = infohash.toLowerCase()
|
||||
if (!data[clientUuid].includes(h)) {
|
||||
data[clientUuid].push(h)
|
||||
saveManagedMap(data)
|
||||
}
|
||||
}
|
||||
|
||||
/** Retire un infohash de la liste gérée pour un client. */
|
||||
export function untrackManagedHash(clientUuid: string, infohash: string) {
|
||||
const data = loadManagedMap()
|
||||
if (!data[clientUuid]) return
|
||||
data[clientUuid] = data[clientUuid].filter(h => h !== infohash.toLowerCase())
|
||||
if (data[clientUuid].length === 0) delete data[clientUuid]
|
||||
saveManagedMap(data)
|
||||
}
|
||||
|
||||
/** Supprime toutes les tâches gérées d'un client (appelé lors de la suppression du client). */
|
||||
function clearManagedHashes(clientUuid: string) {
|
||||
const data = loadManagedMap()
|
||||
if (!data[clientUuid]) return
|
||||
delete data[clientUuid]
|
||||
saveManagedMap(data)
|
||||
}
|
||||
|
||||
function loadClients(): SavedClient[] {
|
||||
try {
|
||||
@@ -114,7 +216,7 @@ export function listClients(): SavedClient[] {
|
||||
return loadClients()
|
||||
}
|
||||
|
||||
export function addClient(name: string, type: string, config: Record<string, string | number>): SavedClient {
|
||||
export function addClient(name: string, type: string, config: ClientConfig): SavedClient {
|
||||
const clients = loadClients()
|
||||
const client: SavedClient = { uuid: randomUUID(), name, type, config }
|
||||
clients.push(client)
|
||||
@@ -129,6 +231,7 @@ export function removeClient(uuid: string): boolean {
|
||||
const filtered = clients.filter(c => c.uuid !== uuid)
|
||||
if (filtered.length === clients.length) return false
|
||||
saveClients(filtered)
|
||||
clearManagedHashes(uuid)
|
||||
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
|
||||
return true
|
||||
}
|
||||
@@ -137,7 +240,7 @@ export function getClient(uuid: string): SavedClient | undefined {
|
||||
return loadClients().find(c => c.uuid === uuid)
|
||||
}
|
||||
|
||||
export function updateClient(uuid: string, name: string, type: string, config: Record<string, string | number>): SavedClient | null {
|
||||
export function updateClient(uuid: string, name: string, type: string, config: ClientConfig): SavedClient | null {
|
||||
const clients = loadClients()
|
||||
const index = clients.findIndex(c => c.uuid === uuid)
|
||||
if (index === -1) return null
|
||||
@@ -243,6 +346,10 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
|
||||
}
|
||||
try {
|
||||
await driver.add(client.config, url, options)
|
||||
if (options?.infohash) {
|
||||
trackManagedHash(client.uuid, options.infohash)
|
||||
logger.debug('torrent-clients', `Tâche ${options.infohash.slice(0, 8)}… suivie pour "${client.name}"`)
|
||||
}
|
||||
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: true })
|
||||
} catch (err) {
|
||||
@@ -266,6 +373,7 @@ export async function dispatchRemove(hash: string, deleteFiles = false): Promise
|
||||
try {
|
||||
await driver.remove(client.config, hash, deleteFiles)
|
||||
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
untrackManagedHash(client.uuid, hash)
|
||||
found = true
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
@@ -328,6 +436,14 @@ export async function dispatchList(
|
||||
}
|
||||
}
|
||||
|
||||
// Filtre par hashes gérés pour les clients sans catégories natives.
|
||||
// Si le Set de tâches suivies est non-vide et que ce hash n'y figure pas,
|
||||
// on ignore ce torrent (il n'a pas été ajouté via FanKarr).
|
||||
if (driver.definition.filterByManagedHashes) {
|
||||
const managed = getManagedSet(client.uuid)
|
||||
if (managed && !managed.has(hash)) continue
|
||||
}
|
||||
|
||||
results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication)
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// qBittorrent répond normalement "Ok." mais certains setups (reverse proxy, etc.)
|
||||
@@ -27,7 +28,7 @@ function mapState(state: string): TorrentInfo['state'] {
|
||||
}
|
||||
|
||||
// Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID
|
||||
async function qbAuth(config: Record<string, string | number>): Promise<Record<string, string>> {
|
||||
async function qbAuth(config: ClientConfig): Promise<Record<string, string>> {
|
||||
if (config.apiKey) {
|
||||
return { Authorization: `Bearer ${config.apiKey}` }
|
||||
}
|
||||
@@ -35,12 +36,12 @@ async function qbAuth(config: Record<string, string | number>): Promise<Record<s
|
||||
return { Cookie: cookie }
|
||||
}
|
||||
|
||||
async function qbLogin(config: Record<string, string | number>): Promise<string> {
|
||||
async function qbLogin(config: ClientConfig): Promise<string> {
|
||||
const form = new URLSearchParams()
|
||||
form.append('username', String(config.username ?? ''))
|
||||
form.append('password', String(config.password ?? ''))
|
||||
|
||||
const res = await fetch(`${config.url}/api/v2/auth/login`, {
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/auth/login`, {
|
||||
method : 'POST',
|
||||
body : form,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
@@ -55,9 +56,9 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
|
||||
return cookie
|
||||
}
|
||||
|
||||
async function qbTorrentExists(config: Record<string, string | number>, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
|
||||
async function qbTorrentExists(config: ClientConfig, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, {
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?hashes=${hash}`, {
|
||||
headers: authHeaders,
|
||||
})
|
||||
if (!res.ok) return false
|
||||
@@ -73,7 +74,7 @@ async function qbTorrentExists(config: Record<string, string | number>, authHead
|
||||
* puis applique les priorités (désactive tout sauf la cible).
|
||||
*/
|
||||
async function qbApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
@@ -82,7 +83,7 @@ async function qbApplyFilePriority(
|
||||
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
|
||||
try {
|
||||
const authH = await qbAuth(config)
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) continue
|
||||
@@ -105,13 +106,13 @@ async function qbApplyFilePriority(
|
||||
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
|
||||
if (unwanted.length > 0) {
|
||||
const body = new URLSearchParams({ hash, id: unwanted.join('|'), priority: '0' })
|
||||
const r = await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers })
|
||||
const r = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers })
|
||||
if (!r.ok) { logger.warn('qbittorrent', `filePrio désactivation échouée (${r.status}) — retry`); continue }
|
||||
}
|
||||
}
|
||||
|
||||
// Activer le fichier cible (dans tous les cas)
|
||||
const r2 = await fetch(`${config.url}/api/v2/torrents/filePrio`, {
|
||||
const r2 = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, {
|
||||
method: 'POST',
|
||||
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
|
||||
headers,
|
||||
@@ -119,7 +120,7 @@ async function qbApplyFilePriority(
|
||||
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
|
||||
|
||||
// Vérification : le fichier cible est bien à priorité > 0
|
||||
const verif = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
|
||||
const verif = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
|
||||
if (verif.ok) {
|
||||
const verifiedFiles: any[] = await verif.json()
|
||||
const target = verifiedFiles[fileIndex]
|
||||
@@ -152,13 +153,14 @@ const QB: TorrentClientDriver = {
|
||||
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
const authH = await qbAuth(config)
|
||||
const res = await fetch(`${config.url}/api/v2/app/version`, { headers: authH })
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/app/version`, { headers: authH })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
@@ -172,7 +174,7 @@ const QB: TorrentClientDriver = {
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const authH = await qbAuth(config)
|
||||
const res = await fetch(`${config.url}/api/v2/app/version`, {
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/app/version`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) return { online: false }
|
||||
@@ -190,7 +192,7 @@ const QB: TorrentClientDriver = {
|
||||
const authH = await qbAuth(config)
|
||||
const params = new URLSearchParams()
|
||||
if (category) params.set('category', category)
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?${params}`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
|
||||
@@ -200,7 +202,7 @@ const QB: TorrentClientDriver = {
|
||||
const fileMap = new Map<string, any[]>()
|
||||
await Promise.all(data.map(async (t: any) => {
|
||||
try {
|
||||
const r = await fetch(`${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
|
||||
const r = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (r.ok) fileMap.set(t.hash, await r.json())
|
||||
@@ -237,7 +239,7 @@ const QB: TorrentClientDriver = {
|
||||
|
||||
async getFiles(config, hash) {
|
||||
const authH = await qbAuth(config)
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) return []
|
||||
@@ -275,7 +277,7 @@ const QB: TorrentClientDriver = {
|
||||
let knownHashes: Set<string> = new Set()
|
||||
if (!hash) {
|
||||
try {
|
||||
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: authH })
|
||||
const lr = await clientFetch(config, `${config.url}/api/v2/torrents/info`, { headers: authH })
|
||||
if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
|
||||
} catch {}
|
||||
}
|
||||
@@ -292,7 +294,7 @@ const QB: TorrentClientDriver = {
|
||||
params.set('urls', url)
|
||||
if (config.category) params.set('category', String(config.category))
|
||||
if (config.savePath) params.set('savepath', String(config.savePath))
|
||||
res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
@@ -306,7 +308,7 @@ const QB: TorrentClientDriver = {
|
||||
if (config.category) form.append('category', String(config.category))
|
||||
if (config.savePath) form.append('savepath', String(config.savePath))
|
||||
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent')
|
||||
res = await fetch(`${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
|
||||
res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
|
||||
}
|
||||
const text = await res.text()
|
||||
if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`)
|
||||
@@ -325,7 +327,7 @@ const QB: TorrentClientDriver = {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const ah = await qbAuth(config)
|
||||
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: ah })
|
||||
const lr = await clientFetch(config, `${config.url}/api/v2/torrents/info`, { headers: ah })
|
||||
if (!lr.ok) continue
|
||||
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
|
||||
if (!newT) continue
|
||||
@@ -347,7 +349,7 @@ const QB: TorrentClientDriver = {
|
||||
if (config.category) params.set('category', String(config.category))
|
||||
if (config.savePath) params.set('savepath', String(config.savePath))
|
||||
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
@@ -359,7 +361,7 @@ const QB: TorrentClientDriver = {
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const authH = await qbAuth(config)
|
||||
const params = new URLSearchParams({ hashes: hash, deleteFiles: deleteFiles ? 'true' : 'false' })
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/torrents/delete`, {
|
||||
method : 'POST',
|
||||
body : params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
* (dossier où l'utilisateur télécharge ses fichiers RD via FUSE, WebDAV ou autre).
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
|
||||
|
||||
function rdHeaders(config: Record<string, string | number>) {
|
||||
function rdHeaders(config: ClientConfig) {
|
||||
return {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -26,7 +26,7 @@ function rdHeaders(config: Record<string, string | number>) {
|
||||
}
|
||||
|
||||
async function rdFetch(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
method : 'GET' | 'POST' | 'DELETE',
|
||||
path : string,
|
||||
body ?: URLSearchParams,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── XML-RPC helpers ──────────────────────────────────────────
|
||||
@@ -55,7 +56,7 @@ function parseXmlValue(node: Element): any {
|
||||
}
|
||||
|
||||
async function rpcCall(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
method : string,
|
||||
params : any[] = [],
|
||||
): Promise<any> {
|
||||
@@ -69,7 +70,7 @@ async function rpcCall(
|
||||
const rpcPath = String(config.rpcPath || '/RPC2')
|
||||
const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}`
|
||||
|
||||
const res = await fetch(url, { method: 'POST', headers, body })
|
||||
const res = await clientFetch(config, url, { method: 'POST', headers, body })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
const text = await res.text()
|
||||
@@ -109,7 +110,7 @@ function parseInnerValue(inner: string): any {
|
||||
|
||||
// multicall pour récupérer plusieurs champs en une requête
|
||||
async function d_multicall(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
view : string,
|
||||
methods: string[],
|
||||
): Promise<any[][]> {
|
||||
@@ -118,7 +119,7 @@ async function d_multicall(
|
||||
return Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
async function rtTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
|
||||
async function rtTorrentExists(config: ClientConfig, hash: string): Promise<boolean> {
|
||||
try {
|
||||
const rows = await d_multicall(config, 'main', ['d.hash'])
|
||||
return rows.some(r => String(r[0]).toLowerCase() === hash.toLowerCase())
|
||||
@@ -134,7 +135,7 @@ async function rtTorrentExists(config: Record<string, string | number>, hash: st
|
||||
* On set les priorités dès que les fichiers sont disponibles, avant que le download réel commence.
|
||||
*/
|
||||
async function rtApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
@@ -192,6 +193,7 @@ const RT: TorrentClientDriver = {
|
||||
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -2,22 +2,89 @@
|
||||
* Synology Download Station Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
|
||||
// ─── Cache URI → infohash ──────────────────────────────────────────────────────
|
||||
// Synology n'expose pas toujours le hash BT via l'API (ancien DSM).
|
||||
// On persiste l'association URI → infohash au moment du add() pour la réutiliser
|
||||
// dans list() via le champ additional.detail.uri de chaque tâche.
|
||||
|
||||
const URI_HASH_PATH = path.join(DATA_DIR, 'synology_uri_hash.json')
|
||||
|
||||
// ─── Cache hash BT → ID Synology (en mémoire) ─────────────────────────────────
|
||||
// Synology utilise des IDs internes (dbid_X), pas des infohash BT.
|
||||
// Quand list() réussit à résoudre le vrai hash BT, on mémorise l'association
|
||||
// infohash → dbid_X pour que remove() / getFiles() puissent retrouver la tâche
|
||||
// même quand detail.hash et detail.uri sont vides.
|
||||
const _hashToSynoId = new Map<string, string>()
|
||||
|
||||
function loadUriHashMap(): Record<string, string> {
|
||||
try {
|
||||
if (fs.existsSync(URI_HASH_PATH)) return JSON.parse(fs.readFileSync(URI_HASH_PATH, 'utf-8'))
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
|
||||
const _uriToHash: Map<string, string> = (() => {
|
||||
const m = new Map<string, string>()
|
||||
for (const [k, v] of Object.entries(loadUriHashMap())) m.set(k, v)
|
||||
return m
|
||||
})()
|
||||
|
||||
function storeUriHash(uri: string, hash: string) {
|
||||
const key = uri.toLowerCase()
|
||||
if (_uriToHash.get(key) === hash) return // déjà connu, pas besoin d'écrire
|
||||
_uriToHash.set(key, hash)
|
||||
try {
|
||||
const map = loadUriHashMap()
|
||||
map[key] = hash
|
||||
fs.mkdirSync(path.dirname(URI_HASH_PATH), { recursive: true })
|
||||
fs.writeFileSync(URI_HASH_PATH, JSON.stringify(map, null, 2), 'utf-8')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function resolveHash(detailHash: string, detailUri: string, taskId: string): string {
|
||||
// 1. Hash direct depuis l'API Synology (DSM récent)
|
||||
if (detailHash.length > 0) return detailHash.toLowerCase()
|
||||
|
||||
// 2. Magnet URI dans detail.uri → extraction directe du hash (hex 40 chars)
|
||||
if (detailUri.startsWith('magnet:')) {
|
||||
const m = detailUri.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
if (m) return m[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 3. Notre cache URI → infohash (stocké au moment du add())
|
||||
if (detailUri) {
|
||||
const cached = _uriToHash.get(detailUri.toLowerCase())
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
// 4. Fallback : ID Synology natif (déjà au format "dbid_X")
|
||||
logger.debug('synology-ds', `Hash BT non résolu — fallback ID Synology : ${taskId}`)
|
||||
return String(taskId ?? '').toLowerCase()
|
||||
}
|
||||
|
||||
// ─── State mapping ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Synology Download Station task status codes
|
||||
function mapState(status: string): TorrentInfo['state'] {
|
||||
if (status === 'downloading') return 'downloading'
|
||||
if (status === 'seeding' || status === 'finished') return 'seeding'
|
||||
if (status === 'paused') return 'paused'
|
||||
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
|
||||
if (status === 'hash_checking') return 'checking'
|
||||
if (status === 'error') return 'error'
|
||||
if (status === 'downloading') return 'downloading'
|
||||
if (status === 'seeding' || status === 'finished') return 'seeding'
|
||||
if (status === 'finishing' || status === 'extracting') return 'seeding'
|
||||
if (status === 'paused') return 'paused'
|
||||
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
|
||||
if (status === 'hash_checking') return 'checking'
|
||||
if (status === 'error') return 'error'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
// Authentification DSM — retourne un SID
|
||||
async function dsLogin(config: Record<string, string | number>): Promise<string> {
|
||||
// ─── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function dsLogin(config: ClientConfig): Promise<string> {
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.API.Auth',
|
||||
version: '3',
|
||||
@@ -27,8 +94,7 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
|
||||
session: 'DownloadStation',
|
||||
format : 'sid',
|
||||
})
|
||||
|
||||
const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`)
|
||||
const res = await clientFetch(config, `${config.url}/webapi/auth.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
if (!data.success) throw new Error(`Auth échouée : ${JSON.stringify(data.error)}`)
|
||||
@@ -36,7 +102,7 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
|
||||
}
|
||||
|
||||
async function dsRequest(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
api : string,
|
||||
method : string,
|
||||
version: string,
|
||||
@@ -44,25 +110,29 @@ async function dsRequest(
|
||||
sid : string,
|
||||
): Promise<any> {
|
||||
const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra })
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
// ─── Driver ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DS: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'synology-ds',
|
||||
label : 'Synology Download Station',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
id : 'synology-ds',
|
||||
label : 'Synology Download Station',
|
||||
filterByManagedHashes: true,
|
||||
fields : [
|
||||
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -87,15 +157,13 @@ const DS: TorrentClientDriver = {
|
||||
method : 'getinfo',
|
||||
_sid : sid,
|
||||
})
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/info.cgi?${params}`)
|
||||
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/info.cgi?${params}`)
|
||||
if (!res.ok) return { online: false }
|
||||
const data = await res.json()
|
||||
if (!data.success) return { online: false }
|
||||
const version = data.data?.version_string ?? 'inconnue'
|
||||
logger.debug('synology-ds', `Healthcheck OK — version ${version}`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
logger.debug('synology-ds', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
||||
} catch {
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
@@ -103,76 +171,196 @@ const DS: TorrentClientDriver = {
|
||||
async list(config, category) {
|
||||
const sid = await dsLogin(config)
|
||||
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
|
||||
{ additional: 'transfer,detail' }, sid)
|
||||
{ additional: 'transfer,detail,file' }, sid)
|
||||
|
||||
const tasks: any[] = data?.tasks ?? []
|
||||
|
||||
return tasks
|
||||
.map(t => {
|
||||
const transfer = t.additional?.transfer ?? {}
|
||||
const size = t.size ?? 0
|
||||
const dl = transfer.size_downloaded ?? 0
|
||||
return {
|
||||
hash : t.id,
|
||||
name : t.title,
|
||||
state : mapState(t.status),
|
||||
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
|
||||
size,
|
||||
downloaded: dl,
|
||||
uploaded : transfer.size_uploaded ?? 0,
|
||||
ratio : size > 0 ? Math.round(((transfer.size_uploaded ?? 0) / size) * 100) / 100 : 0,
|
||||
speed : transfer.speed_download ?? 0,
|
||||
upspeed : transfer.speed_upload ?? 0,
|
||||
eta : -1,
|
||||
save_path : t.additional?.detail?.destination ?? '',
|
||||
category : category ?? '',
|
||||
} satisfies TorrentInfo
|
||||
// Filtrage par dossier cible — Synology n'a pas de système de labels/catégories.
|
||||
// Si savePath est configuré, on ne garde que les tâches dont la destination correspond.
|
||||
const normalizedSavePath = String(config.savePath ?? '').replace(/\/+$/, '').toLowerCase()
|
||||
const filtered = normalizedSavePath
|
||||
? tasks.filter(t => {
|
||||
const dest = String(t.additional?.detail?.destination ?? '').replace(/\/+$/, '').toLowerCase()
|
||||
return dest === normalizedSavePath || dest.startsWith(normalizedSavePath + '/')
|
||||
})
|
||||
: tasks
|
||||
|
||||
return filtered.map(t => {
|
||||
const transfer = t.additional?.transfer ?? {}
|
||||
const detail = t.additional?.detail ?? {}
|
||||
const fileList: any[] = t.additional?.file ?? []
|
||||
const size = t.size ?? 0
|
||||
const dl = transfer.size_downloaded ?? 0
|
||||
|
||||
const hash = resolveHash(
|
||||
String(detail.hash ?? '').trim(),
|
||||
String(detail.uri ?? '').trim(),
|
||||
String(t.id ?? ''),
|
||||
)
|
||||
|
||||
// Mémoriser l'association hash BT → ID Synology (pour remove/getFiles)
|
||||
const synoIdStr = String(t.id ?? '').toLowerCase()
|
||||
if (!hash.startsWith('dbid_') && synoIdStr) {
|
||||
_hashToSynoId.set(hash, synoIdStr)
|
||||
}
|
||||
|
||||
const files = fileList.length > 0
|
||||
? fileList.map((f: any, i: number) => {
|
||||
const fSize = typeof f.size === 'number' ? f.size : 0
|
||||
const fDl = typeof f.size_downloaded === 'number' ? f.size_downloaded : 0
|
||||
return {
|
||||
index : i,
|
||||
name : String(f.filename ?? ''),
|
||||
progress: fSize > 0 ? fDl / fSize : 0,
|
||||
priority: String(f.priority ?? 'normal') === 'skip' ? 0 : 1,
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
|
||||
return {
|
||||
hash,
|
||||
name : t.title,
|
||||
state : mapState(t.status),
|
||||
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
|
||||
size,
|
||||
downloaded: dl,
|
||||
uploaded : transfer.size_uploaded ?? 0,
|
||||
ratio : size > 0 ? Math.round(((transfer.size_uploaded ?? 0) / size) * 100) / 100 : 0,
|
||||
speed : transfer.speed_download ?? 0,
|
||||
upspeed : transfer.speed_upload ?? 0,
|
||||
eta : -1,
|
||||
save_path : detail.destination ?? '',
|
||||
category : category ?? '',
|
||||
files,
|
||||
} satisfies TorrentInfo
|
||||
})
|
||||
},
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
if (options?.file_index != null) logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
|
||||
if (options?.file_index != null)
|
||||
logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
|
||||
|
||||
// Mémoriser l'association URI → infohash AVANT l'envoi (le hash est connu côté FanKarr)
|
||||
if (options?.infohash) {
|
||||
storeUriHash(url, options.infohash)
|
||||
logger.debug('synology-ds', `URI→hash mémorisé : ${options.infohash.slice(0, 8)}… (${url.slice(-50)})`)
|
||||
}
|
||||
|
||||
const sid = await dsLogin(config)
|
||||
|
||||
const extra: Record<string, string> = { uri: url }
|
||||
if (config.savePath) extra.destination = String(config.savePath)
|
||||
// Snapshot des IDs existants avant l'ajout — pour identifier la nouvelle tâche Synology
|
||||
const beforeIds = new Set<string>()
|
||||
try {
|
||||
const before = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
|
||||
for (const t of before?.tasks ?? []) beforeIds.add(String(t.id))
|
||||
} catch {}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version: '1',
|
||||
method : 'create',
|
||||
_sid : sid,
|
||||
...extra,
|
||||
})
|
||||
const doAdd = async (withDestination: boolean) => {
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version: '1',
|
||||
method : 'create',
|
||||
_sid : sid,
|
||||
uri : url,
|
||||
})
|
||||
if (withDestination && config.savePath) params.append('destination', String(config.savePath))
|
||||
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
let data = await doAdd(true)
|
||||
|
||||
// Code 403 = dossier cible introuvable sur le NAS → réessai sans destination
|
||||
if (!data.success && data.error?.code === 403 && config.savePath) {
|
||||
logger.warn('synology-ds', `Dossier cible "${config.savePath}" introuvable (code 403) — ajout sans destination`)
|
||||
data = await doAdd(false)
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
|
||||
|
||||
// Snapshot après ajout — identifier l'ID natif Synology (dbid_X) de la tâche créée
|
||||
// et le stocker dans _hashToSynoId pour remove/getFiles sans attendre le prochain list()
|
||||
if (options?.infohash) {
|
||||
try {
|
||||
const after = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
|
||||
const newTask = (after?.tasks ?? []).find((t: any) => !beforeIds.has(String(t.id)))
|
||||
if (newTask) {
|
||||
const synoId = String(newTask.id).toLowerCase()
|
||||
_hashToSynoId.set(options.infohash.toLowerCase(), synoId)
|
||||
logger.debug('synology-ds', `ID Synology capturé : ${synoId} → ${options.infohash.slice(0, 8)}…`)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const sid = await dsLogin(config)
|
||||
// Synology utilise son ID interne — on cherche via la liste
|
||||
async getFiles(config, hash) {
|
||||
const sid = await dsLogin(config)
|
||||
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
|
||||
{ additional: 'detail,file' }, sid)
|
||||
const tasks: any[] = data?.tasks ?? []
|
||||
|
||||
const h = hash.toLowerCase()
|
||||
const synoIdFromHash = _hashToSynoId.get(h) ?? ''
|
||||
const task = tasks.find((t: any) => {
|
||||
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
|
||||
const detailUri = String(t.additional?.detail?.uri ?? '').toLowerCase()
|
||||
const synoId = String(t.id ?? '').toLowerCase()
|
||||
const cached = _uriToHash.get(detailUri) ?? ''
|
||||
return detailHash === h
|
||||
|| synoId === h
|
||||
|| cached === h
|
||||
|| (synoIdFromHash && synoId === synoIdFromHash)
|
||||
})
|
||||
|
||||
if (!task) return []
|
||||
const fileList: any[] = task.additional?.file ?? []
|
||||
return fileList.map((f: any, i: number) => {
|
||||
const fSize = typeof f.size === 'number' ? f.size : 0
|
||||
const fDl = typeof f.size_downloaded === 'number' ? f.size_downloaded : 0
|
||||
return {
|
||||
index : i,
|
||||
name : String(f.filename ?? ''),
|
||||
progress: fSize > 0 ? fDl / fSize : 0,
|
||||
priority: String(f.priority ?? 'normal') === 'skip' ? 0 : 1,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async remove(config, hash, _deleteFiles?) {
|
||||
const sid = await dsLogin(config)
|
||||
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid)
|
||||
const tasks: any[] = data?.tasks ?? []
|
||||
// Matcher par hash si résolu, sinon par id
|
||||
const found = tasks.find(t => t.id?.toLowerCase() === hash.toLowerCase())
|
||||
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version: '1',
|
||||
method : 'delete',
|
||||
id : found.id,
|
||||
force_complete: deleteFiles ? 'true' : 'false',
|
||||
_sid : sid,
|
||||
|
||||
const h = hash.toLowerCase()
|
||||
const synoIdFromHash = _hashToSynoId.get(h) ?? ''
|
||||
const found = tasks.find((t: any) => {
|
||||
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
|
||||
const detailUri = String(t.additional?.detail?.uri ?? '').toLowerCase()
|
||||
const synoId = String(t.id ?? '').toLowerCase()
|
||||
const cachedHash = _uriToHash.get(detailUri) ?? ''
|
||||
return detailHash === h
|
||||
|| synoId === h
|
||||
|| cachedHash === h
|
||||
|| (synoIdFromHash && synoId === synoIdFromHash)
|
||||
})
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
|
||||
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version : '1',
|
||||
method : 'delete',
|
||||
id : found.id,
|
||||
force_complete: 'false',
|
||||
_sid : sid,
|
||||
})
|
||||
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
logger.info('synology-ds', `Torrent ${found.id} supprimé`)
|
||||
},
|
||||
}
|
||||
|
||||
export default DS
|
||||
export default DS
|
||||
@@ -2,7 +2,8 @@
|
||||
* Transmission Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
function mapState(status: number): TorrentInfo['state'] {
|
||||
@@ -17,7 +18,7 @@ function mapState(status: number): TorrentInfo['state'] {
|
||||
}
|
||||
|
||||
async function trRequest(
|
||||
config: Record<string, string | number>,
|
||||
config: ClientConfig,
|
||||
method: string,
|
||||
args: Record<string, unknown> = {},
|
||||
sessionId = ''
|
||||
@@ -31,7 +32,7 @@ async function trRequest(
|
||||
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.url}/transmission/rpc`, {
|
||||
const res = await clientFetch(config, `${config.url}/transmission/rpc`, {
|
||||
method : 'POST',
|
||||
headers,
|
||||
body : JSON.stringify({ method, arguments: args }),
|
||||
@@ -52,7 +53,7 @@ async function trRequest(
|
||||
return { ...data, sessionId }
|
||||
}
|
||||
|
||||
async function trGetIdByHash(config: Record<string, string | number>, hash: string): Promise<number | null> {
|
||||
async function trGetIdByHash(config: ClientConfig, hash: string): Promise<number | null> {
|
||||
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
|
||||
const torrents = data.arguments?.torrents ?? []
|
||||
return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null
|
||||
@@ -63,7 +64,7 @@ async function trGetIdByHash(config: Record<string, string | number>, hash: stri
|
||||
* puis applique les priorités : fichier cible voulu, tous les autres ignorés.
|
||||
*/
|
||||
async function trApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
@@ -102,6 +103,7 @@ const TR: TorrentClientDriver = {
|
||||
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* uTorrent Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// uTorrent status flags (bitmask) :
|
||||
@@ -21,7 +22,7 @@ function mapState(status: number): TorrentInfo['state'] {
|
||||
// uTorrent nécessite un token CSRF + cookie à chaque session
|
||||
interface UTSession { token: string; cookie: string }
|
||||
|
||||
async function utTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
|
||||
async function utTorrentExists(config: ClientConfig, hash: string): Promise<boolean> {
|
||||
try {
|
||||
const session = await utGetSession(config)
|
||||
const data = await utRequest(config, { action: 'getfiles', hash: hash.toUpperCase() }, session)
|
||||
@@ -35,7 +36,7 @@ async function utTorrentExists(config: Record<string, string | number>, hash: st
|
||||
* tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal).
|
||||
*/
|
||||
async function utApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
@@ -54,13 +55,13 @@ async function utApplyFilePriority(
|
||||
// Passer tous les fichiers à priorité 0 (skip)
|
||||
const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' })
|
||||
for (let i = 0; i < files.length; i++) skipQs.append('f', String(i))
|
||||
await fetch(`${config.url}/gui/?${skipQs}`, {
|
||||
await clientFetch(config, `${config.url}/gui/?${skipQs}`, {
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
|
||||
// Activer le fichier cible (priorité 2 = normal)
|
||||
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
|
||||
await fetch(`${config.url}/gui/?${selectQs}`, {
|
||||
await clientFetch(config, `${config.url}/gui/?${selectQs}`, {
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
|
||||
@@ -71,13 +72,13 @@ async function utApplyFilePriority(
|
||||
throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`)
|
||||
}
|
||||
|
||||
async function utGetSession(config: Record<string, string | number>): Promise<UTSession> {
|
||||
async function utGetSession(config: ClientConfig): Promise<UTSession> {
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
const headers : Record<string, string> = {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.url}/gui/token.html`, { headers })
|
||||
const res = await clientFetch(config, `${config.url}/gui/token.html`, { headers })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`)
|
||||
|
||||
const text = await res.text()
|
||||
@@ -90,14 +91,14 @@ async function utGetSession(config: Record<string, string | number>): Promise<UT
|
||||
}
|
||||
|
||||
async function utRequest(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
params : Record<string, string>,
|
||||
session: UTSession,
|
||||
): Promise<any> {
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
const qs = new URLSearchParams({ token: session.token, ...params })
|
||||
|
||||
const res = await fetch(`${config.url}/gui/?${qs}`, {
|
||||
const res = await clientFetch(config, `${config.url}/gui/?${qs}`, {
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Cookie' : session.cookie,
|
||||
@@ -142,6 +143,7 @@ const UT: TorrentClientDriver = {
|
||||
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -213,7 +215,7 @@ const UT: TorrentClientDriver = {
|
||||
const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url })
|
||||
if (config.savePath) qs.set('path', String(config.savePath))
|
||||
|
||||
const res = await fetch(`${config.url}/gui/?${qs}`, {
|
||||
const res = await clientFetch(config, `${config.url}/gui/?${qs}`, {
|
||||
method : 'GET',
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="w-24 flex-shrink-0">
|
||||
<img
|
||||
v-if="ep.cover"
|
||||
:src="imageUrl(ep.cover)"
|
||||
:src="imageUrl(ep.cover) ?? undefined"
|
||||
:alt="ep.name"
|
||||
class="w-full aspect-video object-cover"
|
||||
/>
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
|
||||
<!-- Dossier vide / introuvable -->
|
||||
<div v-else-if="!scanning && items.length === 0 && !scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
|
||||
<p class="text-sm text-muted">Aucun fichier vidéo trouvé dans ce dossier.</p>
|
||||
<p class="text-xs text-muted">Placez vos fichiers dans <span class="font-mono text-primary">{{ serieFolder }}</span> puis réessayez.</p>
|
||||
<p class="text-sm text-muted">{{ folderMissing ? 'Ce dossier n\'existe pas encore.' : 'Aucun fichier vidéo trouvé dans ce dossier.' }}</p>
|
||||
<p class="text-xs text-muted">
|
||||
{{ folderMissing ? 'Créez' : 'Placez vos fichiers dans' }} <span class="font-mono text-primary">{{ serieFolder }}</span>{{ folderMissing ? ', placez-y vos fichiers' : '' }} puis réessayez.
|
||||
</p>
|
||||
<button @click="scan" class="btn-secondary mt-2">Rescanner</button>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +79,7 @@
|
||||
|
||||
<!-- Sélecteur épisode -->
|
||||
<select
|
||||
v-model="items[i].episode_id"
|
||||
v-model="item.episode_id"
|
||||
class="settings-input text-xs py-1 max-w-[260px] shrink-0"
|
||||
@change="onEpisodeChange(i)"
|
||||
>
|
||||
@@ -154,6 +156,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { matchEpisodeFile } from '@/utils/episode-match'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
@@ -176,6 +179,7 @@ const scanning = ref(false)
|
||||
const importing = ref(false)
|
||||
const importError = ref('')
|
||||
const scanError = ref('')
|
||||
const folderMissing = ref(false)
|
||||
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
|
||||
|
||||
// Dossier de la série dans la médiathèque
|
||||
@@ -230,33 +234,8 @@ function buildOrganizedByPath(): Map<string, number> {
|
||||
}
|
||||
|
||||
function autoMatch(filename: string): { episode_id: number | null; hash: string | null } {
|
||||
const nameNoExt = filename.replace(/\.[^.]+$/, '').toLowerCase()
|
||||
|
||||
for (const season of props.seasons) {
|
||||
for (const ep of season.episodes) {
|
||||
const candidates = [
|
||||
ep.original_filename,
|
||||
ep.nfo_filename?.replace(/\.nfo$/, ''),
|
||||
ep.formatted_name?.replace(/[<>:"/\\|?*]/g, '').trim(),
|
||||
].filter(Boolean).map((s: string) => s.toLowerCase().replace(/\.[^.]+$/, ''))
|
||||
|
||||
if (candidates.some(c => c === nameNoExt || nameNoExt.startsWith(c + '.'))) {
|
||||
return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback numéro d'épisode
|
||||
const numMatch = filename.match(/[.\s_-]0*(\d{1,3})[.\s_-]/i)
|
||||
if (numMatch) {
|
||||
const num = parseInt(numMatch[1], 10)
|
||||
for (const season of props.seasons) {
|
||||
const ep = season.episodes.find((e: any) => e.episode_number === num)
|
||||
if (ep) return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
|
||||
}
|
||||
}
|
||||
|
||||
return { episode_id: null, hash: null }
|
||||
const episodeId = matchEpisodeFile(filename, props.seasons)
|
||||
return { episode_id: episodeId, hash: episodeId === null ? null : getHashForEpisode(episodeId) }
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -268,6 +247,7 @@ function formatSize(bytes: number): string {
|
||||
|
||||
function onEpisodeChange(i: number) {
|
||||
const item = items.value[i]
|
||||
if (!item) return
|
||||
const newEpId = item.episode_id // v-model a déjà mis à jour la valeur
|
||||
|
||||
item.hash = getHashForEpisode(Number(newEpId))
|
||||
@@ -297,6 +277,7 @@ async function scan() {
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
folderMissing.value = data.exists === false
|
||||
const files: { name: string; path: string; size: number }[] = data.files ?? []
|
||||
|
||||
const organizedByPath = buildOrganizedByPath()
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'fankarr-theme'
|
||||
@@ -32,7 +32,7 @@ const themes = [
|
||||
|
||||
const current = ref('indigo')
|
||||
|
||||
function setTheme(id) {
|
||||
function setTheme(id: string) {
|
||||
current.value = id
|
||||
document.documentElement.setAttribute('data-theme', id)
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
</button>
|
||||
<div v-if="seasonMenuOpen" class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="(t, i) in season.torrents"
|
||||
v-for="(t, i) in (season.torrents as any[])"
|
||||
:key="i"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
|
||||
@@ -231,7 +231,7 @@
|
||||
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-72 shadow-xl flex flex-col gap-0.5">
|
||||
<p class="px-3 pt-1.5 pb-0.5 text-[10px] text-muted font-medium uppercase tracking-wide">Choisir le torrent</p>
|
||||
<button
|
||||
v-for="(t, i) in ep.torrents"
|
||||
v-for="(t, i) in (ep.torrents as any[])"
|
||||
:key="i"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="flex items-start gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
|
||||
@@ -246,7 +246,7 @@
|
||||
<div v-if="!requestMode && epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1"
|
||||
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="(t, i) in ep.torrents"
|
||||
v-for="(t, i) in (ep.torrents as any[])"
|
||||
:key="i"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
|
||||
@@ -433,16 +433,6 @@ function isAlreadyQueued(torrent: any): boolean {
|
||||
|
||||
// ── Computed saison ────────────────────────────────────────────
|
||||
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
|
||||
const seasonProgress = computed(() => {
|
||||
if (props.season.torrents && props.season.torrents.length > 1) {
|
||||
for (const t of props.season.torrents) {
|
||||
const prog = torrentProgress(extractHash(t))
|
||||
if (prog) return prog
|
||||
}
|
||||
return null
|
||||
}
|
||||
return torrentProgress(extractHash(props.season.torrent))
|
||||
})
|
||||
const hasDownloadable = computed(() =>
|
||||
props.season.episodes.some((ep: any) =>
|
||||
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)
|
||||
@@ -540,7 +530,7 @@ function handleEpRequestClick(ep: any) {
|
||||
return
|
||||
}
|
||||
// Torrent unique ou aucun → émettre directement
|
||||
emit('requestEpisode', season.season_number, ep.id, ep.torrent ?? undefined)
|
||||
emit('requestEpisode', props.season.season_number, ep.id, ep.torrent ?? undefined)
|
||||
}
|
||||
|
||||
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
|
||||
@@ -606,33 +596,49 @@ const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parse
|
||||
*/
|
||||
function _parseEpChap(plot: string | null | undefined): {
|
||||
episodes: string | null
|
||||
chapters: string | null
|
||||
season: string | null
|
||||
chapters: string |null
|
||||
cleanPlot: string
|
||||
} {
|
||||
const text = plot ?? ''
|
||||
|
||||
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
||||
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
||||
const epMatch = text.match(
|
||||
/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?(?:\s+de\s+la\s+saison\s+(\d+))?/i
|
||||
)
|
||||
|
||||
const seasonMatch = text.match(/saison\s+(\d+)/i)
|
||||
|
||||
const season = epMatch?.[3] ?? seasonMatch?.[1] ?? null
|
||||
|
||||
const chapMatch = text.match(
|
||||
/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i
|
||||
)
|
||||
|
||||
const episodes = epMatch
|
||||
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`)
|
||||
: null
|
||||
const chapters = chapMatch
|
||||
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
|
||||
: null
|
||||
? `${season ? `S${season} · ` : ''}${
|
||||
epMatch[2]
|
||||
? `Ép. ${epMatch[1]}–${epMatch[2]}`
|
||||
: `Ép. ${epMatch[1]}`
|
||||
}`
|
||||
: null
|
||||
|
||||
|
||||
|
||||
const chapters = chapMatch
|
||||
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
|
||||
: null
|
||||
|
||||
// Retirer les lignes contenant les références d'épisodes ou chapitres
|
||||
let cleanPlot = text
|
||||
if (episodes || chapters) {
|
||||
if (episodes || chapters || season) {
|
||||
cleanPlot = text
|
||||
.split(/\r?\n/)
|
||||
.filter(line => !/[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+/i.test(line))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(line => !/(?:[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+|saison\s+\d+)/i.test(line))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
return { episodes, chapters, cleanPlot }
|
||||
return { episodes, season, chapters, cleanPlot }
|
||||
}
|
||||
function parseEpChap(plot: string | null | undefined) {
|
||||
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
|
||||
|
||||
@@ -75,6 +75,18 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sélection multiple -->
|
||||
<button
|
||||
v-if="selectable"
|
||||
@click="emit('update:selecting', !selecting)"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 shrink-0"
|
||||
:class="selecting ? 'border-accent text-accent' : ''"
|
||||
title="Sélectionner plusieurs séries"
|
||||
>
|
||||
<ListChecks :size="15" />
|
||||
<span class="hidden md:inline text-xs">Sélection</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 2 : tri + taille + count -->
|
||||
@@ -121,7 +133,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
|
||||
import { Search, SlidersHorizontal, ChevronDown, ListChecks } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
defineProps<{
|
||||
@@ -136,6 +148,8 @@ defineProps<{
|
||||
filtersImport : { label: string; value: string; color: string }[]
|
||||
sortOptions : { label: string; value: string }[]
|
||||
legend : { label: string; color: string }[]
|
||||
selectable? : boolean
|
||||
selecting? : boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -143,6 +157,7 @@ const emit = defineEmits<{
|
||||
'update:activeFilter': [value: string]
|
||||
'update:activeSort' : [value: string]
|
||||
'update:posterSize' : [value: string]
|
||||
'update:selecting' : [value: boolean]
|
||||
}>()
|
||||
|
||||
const filterOpen = ref(false)
|
||||
|
||||
@@ -11,11 +11,8 @@ interface Toast {
|
||||
const toasts = ref<Toast[]>([])
|
||||
let nextId = 0
|
||||
const DURATION = 3500
|
||||
const GROUP_WINDOW = 800 // ms — toasts du même type dans cette fenêtre sont groupés
|
||||
|
||||
function add(message: string, type: Toast['type'] = 'info') {
|
||||
const now = Date.now()
|
||||
|
||||
// Cherche un toast récent du même type à grouper
|
||||
const existing = toasts.value.find(t => t.type === type)
|
||||
|
||||
@@ -44,7 +41,7 @@ function buildGroupMessage(type: Toast['type'], count: number): string {
|
||||
function remove(id: number) {
|
||||
const i = toasts.value.findIndex(t => t.id === id)
|
||||
if (i !== -1) {
|
||||
clearTimeout(toasts.value[i].timer)
|
||||
clearTimeout(toasts.value[i]?.timer)
|
||||
toasts.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import { Menu } from 'lucide-vue-next'
|
||||
import SidebarNav from '@/components/nav/SidebarNav.vue'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
import type { NavItem, NavChild } from '@/types/nav'
|
||||
|
||||
const mobileOpen = ref(false)
|
||||
const dlStore = useDownloadsStore()
|
||||
@@ -79,7 +79,7 @@ const navItems = computed<NavItem[]>(() => {
|
||||
|
||||
items.push({ separator: true })
|
||||
|
||||
const settingsChildren: NavItem[] = [
|
||||
const settingsChildren: NavChild[] = [
|
||||
{ label: 'Mon profil', to: '/settings/profile' },
|
||||
]
|
||||
if (auth.isAdmin) {
|
||||
|
||||
+20
-3
@@ -2,10 +2,16 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface TorrentRef {
|
||||
torrent_raw: string
|
||||
torrent_url: string | null
|
||||
magnet: string | null
|
||||
type: string
|
||||
raw?: string | null
|
||||
infohash?: string | null
|
||||
torrent_name?: string | null
|
||||
file_index?: number | null
|
||||
file_path?: string | null
|
||||
fankai?: boolean
|
||||
manual?: boolean
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
@@ -20,7 +26,10 @@ export interface Episode {
|
||||
has_thumbnail: boolean
|
||||
season_id: number
|
||||
available: boolean
|
||||
torrent: TorrentRef | null // non-null seulement si type=episode
|
||||
organized: boolean
|
||||
fankai: boolean | null
|
||||
torrent: TorrentRef | null
|
||||
torrents: TorrentRef[]
|
||||
}
|
||||
|
||||
export interface Season {
|
||||
@@ -33,6 +42,9 @@ export interface Season {
|
||||
premiered: string | null
|
||||
episodes: Episode[]
|
||||
torrent: TorrentRef | null
|
||||
torrents: TorrentRef[]
|
||||
organized_state: 'none' | 'partial' | 'complete'
|
||||
organized_count: number
|
||||
}
|
||||
|
||||
export interface Serie {
|
||||
@@ -46,12 +58,17 @@ export interface Serie {
|
||||
status: string | null
|
||||
torrent_count: number
|
||||
has_torrents: boolean
|
||||
download_state: 'none' | 'downloading' | 'partial' | 'complete'
|
||||
in_client: boolean
|
||||
has_files: boolean
|
||||
rss_synced: boolean
|
||||
}
|
||||
|
||||
export interface SerieDetail {
|
||||
serie: Serie
|
||||
seasons: Season[]
|
||||
torrents_integrale: { raw: string; torrent_url: string | null; magnet: string | null }[]
|
||||
scraper_synced: boolean
|
||||
torrents_integrale: { label: string; raw: string; torrent_url: string | null; magnet: string | null; infohash: string | null; torrent_name: string | null }[]
|
||||
}
|
||||
|
||||
export const useSeriesStore = defineStore('series', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -118,7 +118,7 @@
|
||||
<div v-for="r in req.requesters" :key="r.userId"
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-shell border border-border text-xs">
|
||||
<span class="w-4 h-4 rounded-full bg-border flex items-center justify-center text-[9px] font-semibold text-muted shrink-0">
|
||||
{{ r.username[0].toUpperCase() }}
|
||||
{{ r.username.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
<span class="text-muted">{{ r.username }}</span>
|
||||
<span v-if="r.episodes?.length > 0" class="text-muted/60">
|
||||
@@ -179,7 +179,7 @@ const filters = [
|
||||
{ value: 'approved', label: 'Approuvées' },
|
||||
{ value: 'completed', label: 'Disponibles' },
|
||||
{ value: 'rejected', label: 'Refusées' },
|
||||
]
|
||||
] as const
|
||||
|
||||
const counts = computed(() => ({
|
||||
all : requests.value.length,
|
||||
|
||||
+108
-30
@@ -103,6 +103,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Série absente du scraper -->
|
||||
<div v-if="auth.isAdmin && !data.scraper_synced" class="mx-4 md:mx-8 mt-4 flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
|
||||
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-sm text-yellow-500 font-medium">Série absente du scraper</p>
|
||||
<p class="text-xs text-muted">
|
||||
Téléchargements indisponibles pour le moment. L'import manuel et le renommage s'appuient sur l'API Fankai en attendant la prochaine synchronisation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dossier série à renommer -->
|
||||
<div v-if="auth.isAdmin && staleFolders.length > 0" class="mx-4 md:mx-8 mt-4 flex items-center justify-between gap-3 flex-wrap px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
|
||||
<div class="flex items-start gap-3 min-w-0">
|
||||
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<p class="text-sm text-yellow-500 font-medium">Dossier différent du titre de la série</p>
|
||||
<p class="text-xs text-muted break-all">
|
||||
<span class="font-mono text-primary">{{ staleFolders.map(f => basename(f.path)).join(', ') }}</span>
|
||||
→ <span class="font-mono text-primary">{{ folderTarget }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="renameFolder" :disabled="renamingFolder" class="btn-secondary text-xs shrink-0">
|
||||
{{ renamingFolder ? 'Renommage…' : 'Renommer le dossier' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Barre utilisateur (mode demande) -->
|
||||
<div v-if="!auth.isAdmin" class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
|
||||
<div v-if="myRequest" class="flex items-center gap-3">
|
||||
@@ -377,6 +405,12 @@ const downloadMenuOpen = ref(false)
|
||||
const downloadingAll = ref(false)
|
||||
const downloadingSeason = ref<Record<number, boolean>>({})
|
||||
const rssSync = ref(false)
|
||||
const folderStatus = ref<{ expected: string; current: { path: string; entries: number; exists: boolean }[] } | null>(null)
|
||||
const renamingFolder = ref(false)
|
||||
|
||||
const basename = (p: string) => p.split(/[\\/]/).pop() ?? p
|
||||
const staleFolders = computed(() => folderStatus.value?.current.filter(f => f.exists && f.path !== folderStatus.value?.expected) ?? [])
|
||||
const folderTarget = computed(() => folderStatus.value ? basename(folderStatus.value.expected) : '')
|
||||
|
||||
export interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[]; save_path?: string; name?: string }
|
||||
const activeTorrents = ref<ActiveTorrent[]>([])
|
||||
@@ -405,6 +439,16 @@ function integraleGroupLabels(): string[] {
|
||||
}
|
||||
|
||||
// Épisodes couverts par une intégrale donnée (hash commun)
|
||||
// Épisodes déjà présents dans la médiathèque — sert de garde-fou pour ne pas
|
||||
// redescendre une autre version (x264 / x265) de fichiers déjà importés.
|
||||
const organizedEpisodeIds = computed(() => {
|
||||
const ids = new Set<number>()
|
||||
for (const season of data.value?.seasons ?? [])
|
||||
for (const ep of season.episodes)
|
||||
if (ep.organized) ids.add(ep.id)
|
||||
return ids
|
||||
})
|
||||
|
||||
function integraleEpisodeCoverage(integraleIndex: number): Set<number> {
|
||||
const covered = new Set<number>()
|
||||
const t = data.value?.torrents_integrale[integraleIndex]
|
||||
@@ -463,6 +507,27 @@ async function fetchOrganized() {
|
||||
const res = await fetch(`/api/organized/${route.params.id}`, { credentials: 'include' })
|
||||
if (res.ok) organizedByEpisode.value = await res.json()
|
||||
} catch {}
|
||||
fetchFolderStatus()
|
||||
}
|
||||
|
||||
async function fetchFolderStatus() {
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${route.params.id}/folder`, { credentials: 'include' })
|
||||
folderStatus.value = res.ok ? await res.json() : null
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function renameFolder() {
|
||||
if (!confirm(`Déplacer le contenu vers « ${folderTarget.value} » ? Les fichiers existants ne sont jamais écrasés.`)) return
|
||||
renamingFolder.value = true
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${route.params.id}/folder`, { method: 'POST', credentials: 'include' })
|
||||
const d = await res.json()
|
||||
if (!res.ok) { toast(d.error ?? 'Erreur lors du renommage du dossier', 'error'); return }
|
||||
toast(`Dossier renommé ✓ (${d.moved} fichier${d.moved > 1 ? 's' : ''} déplacé${d.moved > 1 ? 's' : ''})`, 'success')
|
||||
await fetchOrganized()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { renamingFolder.value = false }
|
||||
}
|
||||
|
||||
async function fetchSettings() {
|
||||
@@ -503,6 +568,8 @@ async function fetchActiveDownloads() {
|
||||
const list: any[] = await res.json()
|
||||
|
||||
const isFirstPoll = _prevTorrentStates.size === 0
|
||||
// Un seul import par torrent, même si plusieurs de ses fichiers viennent de se terminer
|
||||
const toOrganize = new Map<string, any>()
|
||||
|
||||
for (const t of list) {
|
||||
const prevState = _prevTorrentStates.get(t.hash)
|
||||
@@ -515,7 +582,7 @@ async function fetchActiveDownloads() {
|
||||
_prevFileProgresses.set(key, f.progress)
|
||||
if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) {
|
||||
_recentlyOrganized.add(key)
|
||||
triggerOrganize(t)
|
||||
toOrganize.set(t.hash, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -523,12 +590,14 @@ async function fetchActiveDownloads() {
|
||||
// Détecter torrent entier passé en seeding (épisodes standalone sans per-file data)
|
||||
if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) {
|
||||
_recentlyOrganized.add(t.hash)
|
||||
triggerOrganize(t)
|
||||
toOrganize.set(t.hash, t)
|
||||
}
|
||||
|
||||
_prevTorrentStates.set(t.hash, t.state)
|
||||
}
|
||||
|
||||
for (const t of toOrganize.values()) triggerOrganize(t)
|
||||
|
||||
activeTorrents.value = list.map(t => ({
|
||||
hash : t.hash,
|
||||
progress : t.progress ?? 0,
|
||||
@@ -582,6 +651,12 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
const result: DlItem[] = []
|
||||
const covered = new Set<number>()
|
||||
|
||||
// Une autre version déjà envoyée couvre déjà ses épisodes
|
||||
data.value.torrents_integrale.forEach((t: any, i: number) => {
|
||||
if (isAlreadyQueued(t) || isDownloaded(`integrale-${i}`))
|
||||
for (const id of integraleEpisodeCoverage(i)) covered.add(id)
|
||||
})
|
||||
|
||||
// ── 1. Intégrale(s) ───────────────────────────────────────────
|
||||
const integrales: { t: any; i: number }[] = integraleIndex !== undefined
|
||||
? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }]
|
||||
@@ -589,15 +664,23 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
|
||||
for (const { t, i } of integrales) {
|
||||
if (!t) continue
|
||||
if (!isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
|
||||
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
// Marquer les épisodes couverts par CETTE intégrale uniquement
|
||||
|
||||
// Épisodes apportés par CETTE intégrale
|
||||
const hash = t.infohash?.toLowerCase() ?? extractHash(t)
|
||||
const coveredByThis = new Set<number>()
|
||||
if (hash)
|
||||
for (const season of data.value!.seasons)
|
||||
for (const ep of season.episodes)
|
||||
if (ep.torrents?.some((et: any) => (et.infohash ?? extractHash(et)) === hash))
|
||||
covered.add(ep.id)
|
||||
coveredByThis.add(ep.id)
|
||||
|
||||
const bringsSomething = coveredByThis.size === 0
|
||||
|| [...coveredByThis].some(id => !covered.has(id) && !organizedEpisodeIds.value.has(id))
|
||||
|
||||
if (bringsSomething && !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
|
||||
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
|
||||
for (const id of coveredByThis) covered.add(id)
|
||||
}
|
||||
|
||||
// ── 2. Packs saison non couverts ──────────────────────────────
|
||||
@@ -606,12 +689,11 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
// Skiper si tous les épisodes disponibles sont déjà couverts par l'intégrale
|
||||
const hasUncovered = season.episodes.some((ep: any) => ep.available && !ep.organized && !covered.has(ep.id))
|
||||
if (!hasUncovered) continue
|
||||
let addedAny = false
|
||||
season.torrents.forEach((t: any, i: number) => {
|
||||
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`
|
||||
if (!isAlreadyQueued(t) && !isDownloaded(key)) { result.push({ key, torrent_url: t.torrent_url, magnet: t.magnet }); addedAny = true }
|
||||
})
|
||||
if (addedAny) for (const ep of season.episodes) covered.add(ep.id)
|
||||
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
|
||||
const alreadyHandled = season.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
|
||||
const t = season.torrents[0]
|
||||
if (!alreadyHandled && t) result.push({ key: packKey(0), torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
for (const ep of season.episodes) covered.add(ep.id)
|
||||
}
|
||||
|
||||
// ── 3. Épisodes individuels non couverts ─────────────────────
|
||||
@@ -625,17 +707,9 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
}
|
||||
|
||||
// Le bouton "Tout télécharger" s'affiche s'il y a quoi que ce soit à lancer
|
||||
const hasSomethingToDownload = computed(() => {
|
||||
if (!data.value) return false
|
||||
if (data.value.torrents_integrale.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))) return true
|
||||
for (const season of data.value.seasons) {
|
||||
if (season.torrents?.length && season.organized_state !== 'complete' &&
|
||||
season.torrents.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`))) return true
|
||||
for (const ep of season.episodes)
|
||||
if (ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
// Dérivé de collectDownloadables pour que le bouton n'apparaisse jamais alors
|
||||
// que le garde-fou versions ne laisserait passer aucun torrent.
|
||||
const hasSomethingToDownload = computed(() => collectDownloadables().length > 0)
|
||||
|
||||
async function downloadAll(integraleIndex?: number) {
|
||||
downloadingAll.value = true
|
||||
@@ -651,15 +725,19 @@ async function downloadAll(integraleIndex?: number) {
|
||||
|
||||
async function downloadSeason(season: Season, packHash?: string) {
|
||||
downloadingSeason.value[season.id] = true
|
||||
const torrents: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = []
|
||||
const torrents: DlItem[] = []
|
||||
const seasonAny = season as any
|
||||
if (seasonAny.torrents && seasonAny.torrents.length > 0 && seasonAny.organized_state !== 'complete') {
|
||||
seasonAny.torrents.forEach((t: any, i: number) => {
|
||||
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`
|
||||
if (!isAlreadyQueued(t) && !isDownloaded(key)) {
|
||||
torrents.push({ key, torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
}
|
||||
})
|
||||
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
|
||||
const alreadyHandled = seasonAny.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
|
||||
if (!alreadyHandled) {
|
||||
const chosen = packHash
|
||||
? seasonAny.torrents.findIndex((t: any) => (t.infohash ?? extractHash(t)) === packHash)
|
||||
: 0
|
||||
const i = chosen >= 0 ? chosen : 0
|
||||
const t = seasonAny.torrents[i]
|
||||
torrents.push({ key: packKey(i), torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
}
|
||||
} else {
|
||||
for (const ep of seasonAny.episodes ?? []) {
|
||||
// Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant
|
||||
|
||||
+102
-9
@@ -13,6 +13,8 @@
|
||||
:filters-import="filtersImport"
|
||||
:sort-options="sortOptions"
|
||||
:legend="legend"
|
||||
:selectable="auth.isAdmin"
|
||||
v-model:selecting="selecting"
|
||||
/>
|
||||
|
||||
<!-- Grille -->
|
||||
@@ -33,14 +35,22 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }">
|
||||
<RouterLink
|
||||
<component
|
||||
:is="selecting ? 'div' : RouterLink"
|
||||
v-for="serie in filtered"
|
||||
:key="serie.id"
|
||||
:to="`/series/${serie.id}`"
|
||||
v-bind="selecting ? {} : { to: `/series/${serie.id}` }"
|
||||
class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5"
|
||||
:class="{ 'opacity-40': !serie.has_torrents && serie.download_state === 'none' }"
|
||||
:class="{
|
||||
'opacity-40': !serie.has_torrents && serie.download_state === 'none' && !selected.has(serie.id),
|
||||
'cursor-pointer select-none': selecting,
|
||||
}"
|
||||
@click="selecting && toggleSelected(serie.id)"
|
||||
>
|
||||
<div class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border border-border">
|
||||
<div
|
||||
class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border"
|
||||
:class="selected.has(serie.id) ? 'border-accent ring-2 ring-accent' : 'border-border'"
|
||||
>
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
@@ -52,10 +62,22 @@
|
||||
<Tv :size="28" />
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
<div v-if="!selecting" class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
<span class="text-xs text-white border border-white/30 px-3 py-1 rounded-md">Voir</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selecting"
|
||||
class="absolute top-2 left-2 w-5 h-5 rounded-md border flex items-center justify-center"
|
||||
:class="selected.has(serie.id) ? 'bg-accent border-accent text-white' : 'bg-black/40 border-white/60'"
|
||||
>
|
||||
<Check v-if="selected.has(serie.id)" :size="13" />
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin && serie.rss_synced" class="absolute top-2 right-2 p-1 rounded-md bg-black/60 text-green-400" title="Surveillée">
|
||||
<Rss :size="11" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 h-1"
|
||||
:style="{ background: stateColor(serie.download_state) }"
|
||||
@@ -77,7 +99,20 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</component>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions de masse -->
|
||||
<div v-if="selecting" class="sticky bottom-0 z-10 px-4 md:px-6 py-3 border-t border-border bg-shell flex items-center gap-3 flex-wrap">
|
||||
<span class="text-xs text-primary font-medium">{{ selected.size }} sélectionnée{{ selected.size > 1 ? 's' : '' }}</span>
|
||||
<button @click="selectAllFiltered" class="text-xs text-accent hover:underline">Tout sélectionner ({{ filtered.length }})</button>
|
||||
<button v-if="selected.size > 0" @click="clearSelection" class="text-xs text-muted hover:text-primary">Désélectionner</button>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Renommer</button>
|
||||
<button @click="bulkSync(true)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Surveiller</button>
|
||||
<button @click="bulkSync(false)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Ne plus surveiller</button>
|
||||
<button @click="selecting = false" class="btn-secondary" title="Quitter la sélection"><X :size="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,10 +120,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onUnmounted, onActivated } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Tv } from 'lucide-vue-next'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { Tv, Check, Rss, X } from 'lucide-vue-next'
|
||||
import { useSeriesStore, type Serie } from '@/stores/series'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -117,6 +152,7 @@ const filtersImport = [
|
||||
{ label: 'Importés', value: 'complete', color: '#22c55e' },
|
||||
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
|
||||
{ label: 'Partiel', value: 'partial', color: '#9b59b6' },
|
||||
{ label: 'Non suivies', value: 'untracked', color: 'var(--border)' },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
@@ -143,6 +179,7 @@ const filtered = computed(() => {
|
||||
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
|
||||
if (activeFilter.value === 'downloading') list = list.filter(s => ['downloading', 'partial'].includes(s.download_state))
|
||||
if (activeFilter.value === 'partial') list = list.filter(s => s.download_state === 'partial')
|
||||
if (activeFilter.value === 'untracked') list = list.filter(s => !s.in_client && !s.has_files)
|
||||
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
@@ -163,6 +200,62 @@ const filtered = computed(() => {
|
||||
return list
|
||||
})
|
||||
|
||||
// ── Sélection multiple ───────────────────────────────────────
|
||||
const selecting = ref(false)
|
||||
const selected = ref<Set<number>>(new Set())
|
||||
const bulkBusy = ref(false)
|
||||
|
||||
watch(selecting, on => { if (!on) clearSelection() })
|
||||
|
||||
function toggleSelected(id: number) {
|
||||
const next = new Set(selected.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
selected.value = next
|
||||
}
|
||||
function selectAllFiltered() { selected.value = new Set(filtered.value.map(s => s.id)) }
|
||||
function clearSelection() { selected.value = new Set() }
|
||||
|
||||
async function bulkRename() {
|
||||
bulkBusy.value = true
|
||||
try {
|
||||
const res = await fetch('/api/rename-all', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ serie_ids: [...selected.value] }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors du renommage', 'error'); return }
|
||||
const errs = data.errors?.length ?? 0
|
||||
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} renommé${data.done > 1 ? 's' : ''}${errs ? ` · ${errs} erreur${errs > 1 ? 's' : ''}` : ''}`, errs ? 'error' : 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
bulkBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkSync(enabled: boolean) {
|
||||
bulkBusy.value = true
|
||||
try {
|
||||
const targets = store.series.filter((s: Serie) => selected.value.has(s.id))
|
||||
const res = await fetch('/api/rss-sync/bulk', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ enabled, series: targets.map((s: Serie) => ({ id: s.id, name: s.title })) }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors de la mise à jour de la surveillance', 'error'); return }
|
||||
for (const s of targets) s.rss_synced = enabled
|
||||
const n = data.changed
|
||||
toast(enabled
|
||||
? `${n} série${n > 1 ? 's' : ''} ajoutée${n > 1 ? 's' : ''} à la surveillance`
|
||||
: `${n} série${n > 1 ? 's' : ''} retirée${n > 1 ? 's' : ''} de la surveillance`, 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
bulkBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function stateColor(state: string): string {
|
||||
return ({
|
||||
complete : '#22c55e',
|
||||
|
||||
@@ -48,6 +48,29 @@
|
||||
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
|
||||
</div>
|
||||
|
||||
<!-- Surveillances orphelines -->
|
||||
<div v-if="syncOrphans.length > 0" class="settings-card flex flex-col gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">Surveillances orphelines ({{ syncOrphans.length }})</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
Séries surveillées qui n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom).
|
||||
Réactivez la surveillance sur la nouvelle fiche si besoin.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col divide-y divide-border/50 -mx-4 px-4">
|
||||
<div v-for="s in syncOrphans" :key="s.serieId" class="flex items-center gap-3 py-2">
|
||||
<span class="flex-1 min-w-0 text-sm text-primary truncate">{{ s.serieName }}</span>
|
||||
<span class="text-xs text-muted font-mono shrink-0">#{{ s.serieId }}</span>
|
||||
<button
|
||||
@click="removeSyncOrphan(s.serieId)"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
</div>
|
||||
@@ -62,13 +85,33 @@ const { add: toast } = useToast()
|
||||
const updating = ref(false)
|
||||
const loaded = ref(false)
|
||||
const status = ref({ exists: false, count: 0, empty: true })
|
||||
const syncOrphans = ref<{ serieId: number; serieName: string }[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
fetchSyncOrphans()
|
||||
const res = await fetch('/api/torrents/status', { credentials: 'include' })
|
||||
if (res.ok) status.value = await res.json()
|
||||
loaded.value = true
|
||||
})
|
||||
|
||||
async function fetchSyncOrphans() {
|
||||
try {
|
||||
const res = await fetch('/api/rss-sync/orphans', { credentials: 'include' })
|
||||
if (res.ok) syncOrphans.value = (await res.json()).orphans ?? []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function removeSyncOrphan(serieId: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/rss-sync/${serieId}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { toast('Erreur lors du retrait de la surveillance', 'error'); return }
|
||||
syncOrphans.value = syncOrphans.value.filter(s => s.serieId !== serieId)
|
||||
toast('Surveillance retirée ✓', 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function update() {
|
||||
updating.value = true
|
||||
try {
|
||||
@@ -77,6 +120,7 @@ async function update() {
|
||||
const { count } = await res.json()
|
||||
status.value = { exists: true, count, empty: count === 0 }
|
||||
toast(`${count} séries chargées ✓`, 'success')
|
||||
fetchSyncOrphans()
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? 'Erreur lors de la mise à jour', 'error')
|
||||
|
||||
@@ -138,16 +138,25 @@
|
||||
<!-- Champs principaux -->
|
||||
<template v-if="currentDefinition">
|
||||
<div v-for="field in basicFields" :key="field.key">
|
||||
<label class="settings-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder ?? ''"
|
||||
class="settings-input"
|
||||
<SettingsToggle
|
||||
v-if="field.type === 'boolean'"
|
||||
:model-value="toBool(modal.config[field.key])"
|
||||
:label="field.label"
|
||||
:description="fieldTooltip(field.key)"
|
||||
@update:model-value="modal.config[field.key] = $event"
|
||||
/>
|
||||
<template v-else>
|
||||
<label class="settings-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder ?? ''"
|
||||
class="settings-input"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Paramètres avancés -->
|
||||
@@ -167,24 +176,33 @@
|
||||
|
||||
<div v-if="advancedOpen" class="flex flex-col gap-3 mt-2 pl-3 border-l border-border">
|
||||
<div v-for="field in advancedFields" :key="field.key">
|
||||
<label class="settings-label flex items-center gap-1.5">
|
||||
{{ field.label }}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-4 h-4 rounded-full border border-border text-[10px] text-muted cursor-default leading-none shrink-0 relative group"
|
||||
tabindex="-1"
|
||||
>
|
||||
i
|
||||
<span class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-56 text-[11px] text-secondary bg-card border border-border rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-10 leading-relaxed">
|
||||
{{ fieldTooltip(field.key) }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
placeholder=""
|
||||
class="settings-input"
|
||||
<SettingsToggle
|
||||
v-if="field.type === 'boolean'"
|
||||
:model-value="toBool(modal.config[field.key])"
|
||||
:label="field.label"
|
||||
:description="fieldTooltip(field.key)"
|
||||
@update:model-value="modal.config[field.key] = $event"
|
||||
/>
|
||||
<template v-else>
|
||||
<label class="settings-label flex items-center gap-1.5">
|
||||
{{ field.label }}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-4 h-4 rounded-full border border-border text-[10px] text-muted cursor-default leading-none shrink-0 relative group"
|
||||
tabindex="-1"
|
||||
>
|
||||
i
|
||||
<span class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-56 text-[11px] text-secondary bg-card border border-border rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-10 leading-relaxed">
|
||||
{{ fieldTooltip(field.key) }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder ?? ''"
|
||||
class="settings-input"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,6 +240,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
@@ -254,11 +273,7 @@ const clientMatrix = [
|
||||
{
|
||||
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
|
||||
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'alldebrid', label: 'AllDebrid', version: 'API v4',
|
||||
auth: 'Clé API', fileSelect: false, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
const modal = ref({
|
||||
@@ -266,24 +281,30 @@ const modal = ref({
|
||||
uuid : null as string | null,
|
||||
name : '',
|
||||
type : '',
|
||||
config : {} as Record<string, string>,
|
||||
config : {} as Record<string, string | number | boolean>,
|
||||
testing: false,
|
||||
saving : false,
|
||||
tested : false,
|
||||
})
|
||||
|
||||
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath']
|
||||
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath', 'ignoreCertificateErrors']
|
||||
|
||||
const FIELD_TOOLTIPS: Record<string, string> = {
|
||||
savePath : 'Optionnel. Permet de placer les téléchargements dans un sous-dossier spécifique à l\'intérieur du dossier déjà configuré dans la gestion des médias.',
|
||||
remotePath : 'Si le client tourne sur une autre machine, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.',
|
||||
localPath : 'Chemin équivalent au chemin distant, mais vu par FanKarr sur sa machine (ex: /mnt/nas/downloads). Les deux champs fonctionnent en binôme pour faire la correspondance.',
|
||||
ignoreCertificateErrors: 'Accepte les certificats auto-signés ou invalides en HTTPS. À n\'utiliser que sur un réseau de confiance.',
|
||||
}
|
||||
|
||||
function fieldTooltip(key: string): string {
|
||||
return FIELD_TOOLTIPS[key] ?? ''
|
||||
}
|
||||
|
||||
// Tolère la forme string "true" en plus du booléen natif
|
||||
function toBool(v: unknown): boolean {
|
||||
return v === true || v === 'true'
|
||||
}
|
||||
|
||||
const advancedOpen = ref(false)
|
||||
|
||||
const currentDefinition = computed(() =>
|
||||
@@ -367,7 +388,8 @@ function onTypeChange() {
|
||||
const def = currentDefinition.value
|
||||
if (def) {
|
||||
for (const field of def.fields) {
|
||||
if (field.default !== undefined) modal.value.config[field.key] = String(field.default)
|
||||
if (field.default === undefined) continue
|
||||
modal.value.config[field.key] = field.type === 'boolean' ? field.default : String(field.default)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -385,7 +407,7 @@ watch(() => modal.value.config, (newConfig) => {
|
||||
// Si au moins un champ non-password a changé → reset tested
|
||||
// Pour les passwords, on reset seulement si la valeur n'est plus le masque
|
||||
for (const [key, val] of Object.entries(newConfig)) {
|
||||
const field = driver.fields.find(f => f.key === key)
|
||||
const field = driver.fields.find((f: any) => f.key === key)
|
||||
if (!field) continue
|
||||
if (field.type === 'password' && val === '••••••••') continue
|
||||
modal.value.tested = false
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
</svg>
|
||||
Actualiser
|
||||
</button>
|
||||
<button
|
||||
@click="forceMetadataRefresh"
|
||||
:disabled="forcingRefresh"
|
||||
class="btn-secondary"
|
||||
title="Recharge les correspondances de renommage depuis le scraper Fankai en ignorant tous les caches, migre les IDs d'épisodes, puis renomme les fichiers importés"
|
||||
>
|
||||
{{ forcingRefresh ? 'Rechargement…' : 'Forcer la MAJ des correspondances' }}
|
||||
</button>
|
||||
<button
|
||||
@click="purgeNfo"
|
||||
:disabled="purgingNfo"
|
||||
@@ -164,6 +172,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entrées orphelines -->
|
||||
<div v-if="!loading && orphans.length > 0" class="settings-card flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">Entrées orphelines ({{ orphans.length }})</p>
|
||||
<p class="text-xs text-muted mt-0.5">Épisodes suivis qui n'existent plus dans le catalogue. Les retirer du suivi ne supprime aucun fichier.</p>
|
||||
</div>
|
||||
<button @click="removeOrphans()" :disabled="removingOrphans" class="btn-secondary text-xs shrink-0">
|
||||
{{ removingOrphans ? '…' : 'Tout retirer' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col divide-y divide-border/50 -mx-4 px-4">
|
||||
<div v-for="o in orphans" :key="`${o.hash}:${o.episode_id}`" class="flex items-center gap-3 py-2">
|
||||
<span class="text-xs text-muted font-mono shrink-0 w-14">
|
||||
{{ o.season != null && o.episode != null ? `S${pad(o.season)}E${pad(o.episode)}` : `#${o.episode_id}` }}
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 text-[11px] font-mono text-muted truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? '—' }}</span>
|
||||
<span v-if="!o.file_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20 shrink-0">Introuvable</span>
|
||||
<button
|
||||
@click="removeOrphans([o.episode_id])"
|
||||
:disabled="removingOrphans"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -177,12 +214,17 @@ const { add: toast } = useToast()
|
||||
const loading = ref(true)
|
||||
const renamingAll = ref(false)
|
||||
const purgingNfo = ref(false)
|
||||
const forcingRefresh = ref(false)
|
||||
const series = ref<any[]>([])
|
||||
const nfoSupport = ref(false)
|
||||
const collapsed = ref<Set<number>>(new Set())
|
||||
const renamingEp = ref<Record<number, boolean>>({})
|
||||
const renameResult = ref<{ done: number; errors: any[] } | null>(null)
|
||||
const showOnlyNeedsRename = ref(false)
|
||||
const orphans = ref<{ hash: string; episode_id: number; season: number | null; episode: number | null; dest_path: string | null; file_exists: boolean }[]>([])
|
||||
const removingOrphans = ref(false)
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
|
||||
@@ -200,6 +242,7 @@ async function load() {
|
||||
const data = await res.json()
|
||||
series.value = data.series ?? []
|
||||
nfoSupport.value = data.nfo_support ?? false
|
||||
orphans.value = data.orphans ?? []
|
||||
// Replier les séries sans rename par défaut
|
||||
collapsed.value = new Set(series.value.filter(s => s.needs_rename === 0).map((s: any) => s.serie_id))
|
||||
} finally {
|
||||
@@ -254,6 +297,30 @@ async function renameSingle(ep: any, serie: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function forceMetadataRefresh() {
|
||||
forcingRefresh.value = true
|
||||
try {
|
||||
const res = await fetch('/api/update', {
|
||||
method : 'POST',
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body : JSON.stringify({ force: true }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors du rechargement', 'error'); return }
|
||||
const parts = [`${data.count} séries rechargées`]
|
||||
if (data.migrated > 0) parts.push(`${data.migrated} ID(s) migré(s)`)
|
||||
if (data.deduped > 0) parts.push(`${data.deduped} doublon(s) retiré(s)`)
|
||||
parts.push(`${data.renamed} fichier${data.renamed > 1 ? 's' : ''} renommé${data.renamed > 1 ? 's' : ''}`)
|
||||
toast(parts.join(' · ') + ' ✓', data.errors > 0 ? 'error' : 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
forcingRefresh.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeNfo() {
|
||||
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
|
||||
purgingNfo.value = true
|
||||
@@ -307,5 +374,26 @@ async function unimportEp(ep: any, deleteFile: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOrphans(episodeIds?: number[]) {
|
||||
if (!episodeIds && !confirm(`Retirer ${orphans.value.length} entrée(s) orpheline(s) du suivi ? Aucun fichier n'est supprimé.`)) return
|
||||
removingOrphans.value = true
|
||||
try {
|
||||
const res = await fetch('/api/organized-summary/orphans', {
|
||||
method : 'DELETE',
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body : JSON.stringify(episodeIds ? { episode_ids: episodeIds } : {}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
|
||||
toast(`${data.removed} entrée${data.removed > 1 ? 's' : ''} retirée${data.removed > 1 ? 's' : ''} du suivi ✓`, 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
removingOrphans.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -103,7 +103,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { copyToClipboard } from '@/composables/useClipboard'
|
||||
|
||||
const jellyfinUrl = ref('')
|
||||
const jellyfinAdminToken = ref('')
|
||||
@@ -116,8 +115,6 @@ const syncing = ref(false)
|
||||
const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null)
|
||||
const syncError = ref<string | null>(null)
|
||||
|
||||
const origin = window.location.origin
|
||||
|
||||
const apiEndpoints = [
|
||||
{ method: 'POST', path: '/api/v1/auth/jellyfin', desc: 'Échanger un token Jellyfin contre un token FanKarr' },
|
||||
{ method: 'GET', path: '/api/v1/auth/me', desc: 'Infos de l\'utilisateur authentifié' },
|
||||
@@ -179,13 +176,6 @@ async function syncUsers() {
|
||||
syncing.value = false
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
try {
|
||||
await copyToClipboard(myToken.value)
|
||||
tokenCopied.value = true
|
||||
setTimeout(() => { tokenCopied.value = false }, 2000)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
@@ -90,7 +90,7 @@
|
||||
:model-value="form.nfoSupport"
|
||||
@update:model-value="onNfoToggle"
|
||||
label="NFO / Métadonnées"
|
||||
description="Télécharge les NFO et images depuis GitLab lors de l'import (Kodi, Infuse, etc.)."
|
||||
description="Télécharge les NFO et images depuis GitLab lors de l'import (Infuse, etc.)."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-if="form.organizeMode === 'move'"
|
||||
@@ -103,6 +103,11 @@
|
||||
label="Désimporter si fichier manquant"
|
||||
description="Si un fichier importé n'est plus trouvé sur le disque lors du scan, il est automatiquement retiré de la bibliothèque."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.englishDirectory"
|
||||
label="Utilise 'Season' au lieu de 'Saison' dans les dossiers"
|
||||
description="Si activé, les dossiers de saisons seront nommés 'Season 01' au lieu de 'Saison 01'."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Plex -->
|
||||
@@ -159,12 +164,12 @@
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h3 class="text-sm font-semibold text-primary">Activer les NFO / Métadonnées ?</h3>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex ou Emby</span>,
|
||||
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex, Emby ou Kodi</span>,
|
||||
les métadonnées sont déjà gérées directement par l'agent — activer les NFO est inutile
|
||||
et peut créer des conflits.
|
||||
</p>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Activez cette option uniquement si vous utilisez <span class="text-primary">Kodi, Infuse</span>
|
||||
Activez cette option uniquement si vous utilisez <span class="text-primary">Infuse</span>
|
||||
ou un lecteur qui lit les fichiers NFO locaux.
|
||||
</p>
|
||||
</div>
|
||||
@@ -215,6 +220,7 @@ const form = ref({
|
||||
autoImport : true,
|
||||
deleteTorrentOnMove : false,
|
||||
autoUnimportMissing : false,
|
||||
englishDirectory : false,
|
||||
})
|
||||
|
||||
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
|
||||
>
|
||||
{{ u.username[0].toUpperCase() }}
|
||||
{{ u.username.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">{{ u.username }}</p>
|
||||
@@ -137,7 +137,7 @@
|
||||
<span
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
|
||||
>{{ u.username[0].toUpperCase() }}</span>
|
||||
>{{ u.username.charAt(0).toUpperCase() }}</span>
|
||||
<span class="text-sm text-primary">{{ u.username }}</span>
|
||||
<span class="text-xs text-muted">{{ u.role === 'admin' ? 'Admin' : 'Utilisateur' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.17.1
|
||||
3.23.1
|
||||
Reference in new issue
Block a user