Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
583097cd16 | ||
|
|
cb1d1759e3 | ||
|
|
563ffb2bd3 | ||
|
|
25ab373539 | ||
|
|
ed0acdda22 | ||
|
|
a84337c928 | ||
|
|
cface2944e | ||
|
|
1e92934f53 | ||
|
|
bf3f30356e | ||
|
|
bfabf9b793 | ||
|
|
1f50f9ae72 | ||
|
|
7da5a8d4e8 | ||
|
|
2b3fd79a92 | ||
|
|
aa8b500f57 | ||
|
|
702847a5c2 | ||
|
|
5cc1aa29fe | ||
|
|
105cc05bdf | ||
|
|
50b8b2992b | ||
|
|
f4c9713815 | ||
|
|
7154dc87d2 | ||
|
|
2c9bef8883 | ||
|
|
0d5d44a484 | ||
|
|
d5953a948c | ||
|
|
132198a3a9 | ||
|
|
add5ec5a3e | ||
|
|
260fd10bf0 | ||
|
|
95d2b270dc | ||
|
|
1aa6c57e58 | ||
|
|
bde377dd37 | ||
|
|
834dc12b05 | ||
|
|
35655d7784 | ||
|
|
434da54ede | ||
|
|
d299b76bd3 | ||
|
|
9f90b6ab6c | ||
|
|
2a77a38655 | ||
|
|
39463bfbcb | ||
|
|
83437def32 | ||
|
|
7f6bbd6323 | ||
|
|
82a82b1e84 | ||
|
|
85bce309e9 | ||
|
|
8b98b0edf9 | ||
|
|
8c765bbf39 | ||
|
|
0fd10576fe | ||
|
|
744e0336c5 | ||
|
|
040a8d7b33 | ||
|
|
39d2f996ae | ||
|
|
97c573cc1d | ||
|
|
f6ef818e60 | ||
|
|
f5094c68c6 | ||
|
|
9834023522 | ||
|
|
c267562aec | ||
|
|
a68b9cff95 | ||
|
|
a604b0d0cc | ||
|
|
24466228ef | ||
|
|
fcb1b88870 | ||
|
|
3fb4119c79 | ||
|
|
eb4cb30efd | ||
|
|
53c226eba8 | ||
|
|
3b74d93b74 | ||
|
|
ff54ab4ef6 | ||
|
|
a7c90b63df | ||
|
|
872962c435 | ||
|
|
b9c4627a77 | ||
|
|
eff7a288cf | ||
|
|
94d419cf01 | ||
|
|
4760d576f3 | ||
|
|
800c9f3f02 | ||
|
|
6c7def3184 | ||
|
|
962279db29 | ||
|
|
bffe286f40 | ||
|
|
abc96dd648 | ||
|
|
6142dbdb25 | ||
|
|
01040ada26 | ||
|
|
ee2d83993a | ||
|
|
0103960645 | ||
|
|
b8e30e13f2 | ||
|
|
b8554af71b | ||
|
|
e130e945d5 | ||
|
|
7c4496ae1d | ||
|
|
cfd0914b9b | ||
|
|
53a6f0abfc | ||
|
|
0c2c9ca456 | ||
|
|
74a6d0a895 | ||
|
|
01e02502c0 | ||
|
|
e1e68ef2bf | ||
|
|
ea3bf622e9 | ||
|
|
da92b54fc9 | ||
|
|
664812ec56 | ||
|
|
86df3054dd | ||
|
|
43de4f8071 | ||
|
|
e685fade30 | ||
|
|
77cc97f0f0 | ||
|
|
39738c0c96 | ||
|
|
16bb46fb55 | ||
|
|
12511eb39f | ||
|
|
c594af7f43 | ||
|
|
10a9b75685 | ||
|
|
806121f95c | ||
|
|
9ee4face73 | ||
|
|
f1c40865da | ||
|
|
245e9e38aa | ||
|
|
a423c08559 | ||
|
|
497ad7bdc8 | ||
|
|
f897116e30 | ||
|
|
79b6a8bbb4 | ||
|
|
7e53bab0fc | ||
|
|
4955a730b7 | ||
|
|
b3ff17ab11 | ||
|
|
f19cc027ee | ||
|
|
ed66c2c0a2 | ||
|
|
e6ca1d2a36 | ||
|
|
c9534c2011 | ||
|
|
1b2036b57b | ||
|
|
247694ab3e | ||
|
|
215379eca4 | ||
|
|
7661b65df4 | ||
|
|
996064413f | ||
|
|
1c43385484 | ||
|
|
d2d21258d4 | ||
|
|
3847ba1c58 | ||
|
|
3a65a4bb39 | ||
|
|
6ab9b4ad1f | ||
|
|
f5127637ca | ||
|
|
f995fd14a6 | ||
|
|
9296d64571 | ||
|
|
f00556ab51 | ||
|
|
1c88e13a97 | ||
|
|
5fed1d523d | ||
|
|
c963847c31 | ||
|
|
bb21addebb | ||
|
|
bf2274b761 | ||
|
|
609ddc777f | ||
|
|
353bd009ed | ||
|
|
2070c1113d | ||
|
|
203df7576c | ||
|
|
4e8487967c | ||
|
|
1b1dbd3ec8 | ||
|
|
7f0e363cfe | ||
|
|
d6f4433541 | ||
|
|
5e79699ed9 | ||
|
|
e41edd2f5e | ||
|
|
b881fdd557 | ||
|
|
e648a81245 | ||
|
|
699c130edd | ||
|
|
acf802e7e1 | ||
|
|
f40e8c37e7 | ||
|
|
2d2043d7ae | ||
|
|
788878ca6c | ||
|
|
f96565a301 | ||
|
|
462ec87e89 |
No files matched your search
@@ -0,0 +1,22 @@
|
||||
# FanKarr — Configuration (binaire bun)
|
||||
# Copier ce fichier en .env dans le même répertoire que le binaire fankarr
|
||||
|
||||
# ── Authentification ──────────────────────────────────────────────────────────
|
||||
|
||||
# Clé secrète JWT (générée automatiquement si absente, mais recommandé de la fixer)
|
||||
# JWT_SECRET=changez-moi-avec-une-chaine-aleatoire-longue
|
||||
|
||||
# Durée de validité du token de connexion
|
||||
# Exemples : "1d", "7d", "30d", "1y", "never" ou "0" pour pas d'expiration
|
||||
# AUTH_TOKEN_EXPIRY=30d
|
||||
|
||||
# ── Serveur ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Port d'écoute (défaut : 9898)
|
||||
# PORT=9898
|
||||
|
||||
# ── Données ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Répertoire de données (défaut : ./config/ à côté du binaire)
|
||||
# Utile pour pointer vers un volume Docker ou un répertoire partagé
|
||||
# DATA_DIR=/opt/fankarr/data
|
||||
@@ -0,0 +1,64 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 300" width="1200" height="300">
|
||||
<defs>
|
||||
<radialGradient id="iconGlow" cx="477" cy="140" r="100" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#e8a030" stop-opacity="0.22"/>
|
||||
<stop offset="100%" stop-color="#e8a030" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="bgVignette" cx="50%" cy="50%" r="60%">
|
||||
<stop offset="0%" stop-color="#111827" stop-opacity="0"/>
|
||||
<stop offset="100%" stop-color="#070a10" stop-opacity="0.6"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="300" fill="#0b0e17" rx="16"/>
|
||||
<rect width="1200" height="300" fill="url(#bgVignette)" rx="16"/>
|
||||
<rect width="1200" height="300" fill="none" stroke="#1c2636" stroke-width="1" rx="16"/>
|
||||
|
||||
<!-- Glow behind kanji box -->
|
||||
<circle cx="477" cy="140" r="100" fill="url(#iconGlow)"/>
|
||||
|
||||
<!-- Kanji icon box -->
|
||||
<rect x="432" y="95" width="90" height="90" rx="14"
|
||||
fill="#0f1520"
|
||||
stroke="#e8a030" stroke-width="1.2" stroke-opacity="0.5"/>
|
||||
|
||||
<!-- Kanji 改 (fankai) -->
|
||||
<text
|
||||
x="477" y="140"
|
||||
font-family="'Yu Mincho', 'MS Mincho', 'Hiragino Mincho Pro', 'Noto Serif CJK JP', 'Source Han Serif', serif"
|
||||
font-size="54"
|
||||
font-weight="700"
|
||||
fill="#e8a030"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
>改</text>
|
||||
|
||||
<!-- FanKarr -->
|
||||
<text
|
||||
x="542" y="140"
|
||||
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Helvetica, Arial, sans-serif"
|
||||
font-size="68"
|
||||
font-weight="700"
|
||||
fill="#ffffff"
|
||||
dominant-baseline="central"
|
||||
letter-spacing="-1"
|
||||
>FanKarr</text>
|
||||
|
||||
<!-- Thin separator -->
|
||||
<line x1="490" y1="204" x2="710" y2="204"
|
||||
stroke="#e8a030" stroke-width="0.6" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text
|
||||
x="600" y="228"
|
||||
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Helvetica, Arial, sans-serif"
|
||||
font-size="14"
|
||||
font-weight="300"
|
||||
font-style="italic"
|
||||
fill="#4a6a84"
|
||||
text-anchor="middle"
|
||||
letter-spacing="2"
|
||||
>par des fans, pour des fans</text>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 859 KiB After Width: | Height: | Size: 820 KiB |
|
After Width: | Height: | Size: 92 KiB |
@@ -1,3 +1,6 @@
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -1,5 +1,233 @@
|
||||
# Changelog
|
||||
|
||||
## [3.14.1](https://github.com/Masutayunikon/FanKarr/compare/v3.14.0...v3.14.1) (2026-05-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* eslint build ([25ab373](https://github.com/Masutayunikon/FanKarr/commit/25ab373539c20942202e82cf678e5b7577ff0027))
|
||||
|
||||
## [3.14.0](https://github.com/Masutayunikon/FanKarr/compare/v3.13.2...v3.14.0) (2026-05-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **qbittorrent:** support API key authentication (≥5.2.0) ([702847a](https://github.com/Masutayunikon/FanKarr/commit/702847a5c2eb5956f54a8475fefc80a08fca70f1))
|
||||
* **qbittorrent:** support cookie name (≥5.2.0) ([aa8b500](https://github.com/Masutayunikon/FanKarr/commit/aa8b500f57efb97aae10eb0bd8e254388a8aff42))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ajout de log pour le cookie ([2b3fd79](https://github.com/Masutayunikon/FanKarr/commit/2b3fd79a92bbc7d7ed2142b7a23e03799efe0a28))
|
||||
* cookie ([7da5a8d](https://github.com/Masutayunikon/FanKarr/commit/7da5a8d4e853f7e92770f20352478a76ee91642d))
|
||||
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([105cc05](https://github.com/Masutayunikon/FanKarr/commit/105cc05bdf38127089869ea201165ac1d3479275))
|
||||
* on ne vois plus le telechargement de fichier qui ont une priorité basse (ne pas télécharger) ([1f50f9a](https://github.com/Masutayunikon/FanKarr/commit/1f50f9ae72ec9958bfb92b2e6936c699e19272f3))
|
||||
* renaming dans le managements des series ([1e92934](https://github.com/Masutayunikon/FanKarr/commit/1e92934f531c82b9da90c8597ec08cd162b5a9ee))
|
||||
* renaming dans le managements des series ([bf3f303](https://github.com/Masutayunikon/FanKarr/commit/bf3f30356e086d310b7213b267356bc20976589c))
|
||||
* rtorrent priority default to 1 ([bfabf9b](https://github.com/Masutayunikon/FanKarr/commit/bfabf9b7937f055579add331cd918a13f05084bf))
|
||||
|
||||
## [3.13.2](https://github.com/Masutayunikon/FanKarr/compare/v3.13.1...v3.13.2) (2026-05-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([0d5d44a](https://github.com/Masutayunikon/FanKarr/commit/0d5d44a4840df27d8b23bde9935d12c664d5ad6c))
|
||||
|
||||
## [3.13.1](https://github.com/Masutayunikon/FanKarr/compare/v3.13.0...v3.13.1) (2026-05-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ajout du champ wiki dans l'api ([95d2b27](https://github.com/Masutayunikon/FanKarr/commit/95d2b270dcb3052b65f5918ec770799b43316cd6))
|
||||
|
||||
## [3.13.0](https://github.com/Masutayunikon/FanKarr/compare/v3.12.1...v3.13.0) (2026-05-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ajout de la mise a jour des id's ([39463bf](https://github.com/Masutayunikon/FanKarr/commit/39463bfbcb74c7241420504ba51bd91108df20e4))
|
||||
* ajout de la prise en charges des codex ([83437de](https://github.com/Masutayunikon/FanKarr/commit/83437def32618c8a5b9ca4364db992762b9359f8))
|
||||
* **organize:** vérification de complétion par fichier avant d'importer ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* **serie:** badges épisodes et chapitres extraits du synopsis ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* **serie:** lien wiki dans le header de la série si serie.wiki est défini ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* **torrent-clients:** ajout du driver Real-Debrid ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ajout de log ([d299b76](https://github.com/Masutayunikon/FanKarr/commit/d299b76bd3fc8a8411e71f9f05318901df8372ef))
|
||||
* **organize:** retry EBUSY sur unlink — 5 tentatives × 500 ms (Windows) ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* **organize:** suppression des entrées orphelines lors de la migration d'IDs ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* progression ([35655d7](https://github.com/Masutayunikon/FanKarr/commit/35655d7784025c9b2d7f992b24181d62b8488130))
|
||||
* **qbittorrent:** FanKarr télécharge le .torrent et l'uploade directement à qBit ([434da54](https://github.com/Masutayunikon/FanKarr/commit/434da54ede8ece06c1fbcf4d66fe6ecdf87557a5))
|
||||
* **qbittorrent:** pause uniquement pour les .torrent, pas pour les magnets ([9f90b6a](https://github.com/Masutayunikon/FanKarr/commit/9f90b6ab6c0aa97e33244d827370492abcfe9a13))
|
||||
* **qbittorrent:** suppression de paused/stopped à l'ajout du torrent ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* **serie:** header mobile — items-start + aspect-[2/3] sur le poster ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* utilisation de formated_name depuis les paths ([39463bf](https://github.com/Masutayunikon/FanKarr/commit/39463bfbcb74c7241420504ba51bd91108df20e4))
|
||||
|
||||
## [3.12.1](https://github.com/Masutayunikon/FanKarr/compare/v3.12.0...v3.12.1) (2026-04-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **UI/UX:** ajout d'un modal de confirmation lors de l'activation des nfo ([8b98b0e](https://github.com/Masutayunikon/FanKarr/commit/8b98b0edf926ba6b542501afdf524050d72f043b))
|
||||
|
||||
## [3.12.0](https://github.com/Masutayunikon/FanKarr/compare/v3.11.5...v3.12.0) (2026-04-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission ([2446622](https://github.com/Masutayunikon/FanKarr/commit/24466228ef5305c36574f58fb2590aee8ee86e13))
|
||||
* désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission ([fcb1b88](https://github.com/Masutayunikon/FanKarr/commit/fcb1b88870bebb1bf0b4e484b2462e56ab3975fc))
|
||||
* désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission ([3fb4119](https://github.com/Masutayunikon/FanKarr/commit/3fb4119c79908c1023f3cbf9999b524eb1bc267e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* qbitorrent ([9834023](https://github.com/Masutayunikon/FanKarr/commit/98340235226394c4583159f36cce74e316eedf26))
|
||||
* qbitorrent header ([f5094c6](https://github.com/Masutayunikon/FanKarr/commit/f5094c68c6834f816b0b6d019e5d5637400e16ab))
|
||||
* spinner infinie apres supression ([39d2f99](https://github.com/Masutayunikon/FanKarr/commit/39d2f996aecb9f07dcd8c1ec7911090276908a2f))
|
||||
* spinner infinie apres supression ([97c573c](https://github.com/Masutayunikon/FanKarr/commit/97c573cc1d4fb76787ba5d1df4fda30faec5fbf2))
|
||||
* **UI/UX:** chevron pour ouvrir la description, bouton desimporter de la serie passer à supprimer et affichage de toute la description de la serie lors d'un clique ([f6ef818](https://github.com/Masutayunikon/FanKarr/commit/f6ef818e60875a1ac5d859480c1e0e53082c0858))
|
||||
* **UI/UX:** meilleur comprehension des parametres avancés des clients ([040a8d7](https://github.com/Masutayunikon/FanKarr/commit/040a8d7b339090d819c6e3dc049f1ab9d8b221f5))
|
||||
|
||||
## [3.11.5](https://github.com/Masutayunikon/FanKarr/compare/v3.11.4...v3.11.5) (2026-04-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* le folder picker affiche tous les disques depuis le bouton homes sous windows ([7149022](https://github.com/Masutayunikon/FanKarr/commit/7149022c8701f79d194c675da87c6cb2d5793a35))
|
||||
|
||||
## [3.11.4](https://github.com/Masutayunikon/FanKarr/compare/v3.11.3...v3.11.4) (2026-04-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ajout de la mise a jour des noms toutes les 1h ([0fe5c8c](https://github.com/Masutayunikon/FanKarr/commit/0fe5c8ccb7ecdc1643b5e69de3957c39dfd8085e))
|
||||
|
||||
## [3.11.3](https://github.com/Masutayunikon/FanKarr/compare/v3.11.2...v3.11.3) (2026-04-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* dockerfile node version to 22 ([5983494](https://github.com/Masutayunikon/FanKarr/commit/598349407750c729579bd639680ebc6a8ebcc12f))
|
||||
|
||||
## [3.11.2](https://github.com/Masutayunikon/FanKarr/compare/v3.11.1...v3.11.2) (2026-04-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ajout de undici dans le package.json ([459e602](https://github.com/Masutayunikon/FanKarr/commit/459e602c1df1c610466be5015a660624b1189620))
|
||||
|
||||
## [3.11.1](https://github.com/Masutayunikon/FanKarr/compare/v3.11.0...v3.11.1) (2026-04-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* le setup plex peux maintenant etre utilisé sur un serveur local (probleme de certificat) ([6907584](https://github.com/Masutayunikon/FanKarr/commit/69075842be396b149949b158922a9dc84dbc7ccf))
|
||||
|
||||
## [3.11.0](https://github.com/Masutayunikon/FanKarr/compare/v3.10.0...v3.11.0) (2026-04-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ajout d'un bouton pour supprimer les nfo / png ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
|
||||
* ajout de log pour le bouton tout renommer ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
|
||||
* ajout du support multifile sur utorrent ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* renommage d'episodes qui ne fonctionne pas en 1 par 1 ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
|
||||
|
||||
## [3.10.0](https://github.com/Masutayunikon/FanKarr/compare/v3.9.2...v3.10.0) (2026-04-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ajout d'une variable d'environment pour augmenter la durée du token ou le desactiver avec "never" ou 0 ([dc5387c](https://github.com/Masutayunikon/FanKarr/commit/dc5387c69a308bdf99f0b77f9769462ffda13db5))
|
||||
* ajout des telechargments par episodes / saisons ([d6e3c99](https://github.com/Masutayunikon/FanKarr/commit/d6e3c9963c9db5fedc579377cbad485f25ab0537))
|
||||
* ajout du .env dans les dossiers du binaires pour pouvoir changer leur valeurs ([dc5387c](https://github.com/Masutayunikon/FanKarr/commit/dc5387c69a308bdf99f0b77f9769462ffda13db5))
|
||||
* ajout du langage ([94e7b66](https://github.com/Masutayunikon/FanKarr/commit/94e7b668e13c8406768a84e7a821e2fd7fdc96fc))
|
||||
* update readme ([01e70ed](https://github.com/Masutayunikon/FanKarr/commit/01e70ed9c00f8c614a6a9947466e17a5128f52db))
|
||||
* utilisation des bar de telechargement par fichier dans le torrent plutot que celui du torrent ([d03f0ac](https://github.com/Masutayunikon/FanKarr/commit/d03f0ac397acd117b75a92b6763441a667f4e6cb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* affichage de la bar de progression et confirmation sur le bouton pour les episodes en doublons ([23be797](https://github.com/Masutayunikon/FanKarr/commit/23be797f97b1e49723140cdd34d0d217a0e819a3))
|
||||
* affichage de la bar de progression et confirmation sur le bouton pour les episodes en doublons et dropdown ([95d4b01](https://github.com/Masutayunikon/FanKarr/commit/95d4b015eeb0298f77cfe5828f7a14d15fb77dd5))
|
||||
* ajout des boutons pour les episodes sans torrent ([94048e4](https://github.com/Masutayunikon/FanKarr/commit/94048e458248a43e50c566e01d985105ee850ec3))
|
||||
* bouton global de serie ([93a3505](https://github.com/Masutayunikon/FanKarr/commit/93a35055467cfab2503f6b0dfefcce8cd3f53559))
|
||||
* bouton tous telecharger a la place de integral, multi torrents sur le bouton saisons et tous telecharger ([02dd4c8](https://github.com/Masutayunikon/FanKarr/commit/02dd4c866ad4c396b324c5185e2b9458d8864b58))
|
||||
* changer les badge de language pour un truc plus explicite ([94a1ed1](https://github.com/Masutayunikon/FanKarr/commit/94a1ed1fe3cb9e49f90c8fbc200aaac969895dff))
|
||||
* coherence des badges ([5d84737](https://github.com/Masutayunikon/FanKarr/commit/5d84737a28c2d0c49e8acce6ccd7a7bf915e1309))
|
||||
* dropdown overflow ([5baf707](https://github.com/Masutayunikon/FanKarr/commit/5baf7072207eca1f62a42c13602c2fdc85d6e54f))
|
||||
* dropdown overflow ([f820ea0](https://github.com/Masutayunikon/FanKarr/commit/f820ea0cf252cbc11c527f1393e332b9acd274fe))
|
||||
* fallback de noms si les noms de torrents se ressemble il fallback sur les noms dans nyaa ([59fb769](https://github.com/Masutayunikon/FanKarr/commit/59fb7693a4726fc31223373559404f63b6963340))
|
||||
* improve container detection to support Podman and other runtimes ([7d6c86f](https://github.com/Masutayunikon/FanKarr/commit/7d6c86f888b4b7c5696a5de21bce12d1864035a2))
|
||||
* le dropdown apparait sur le bouton de telechargement et pas les 3 petit points pour les multiples ([eaf733f](https://github.com/Masutayunikon/FanKarr/commit/eaf733f2e5caccf10a2f30a0e10db00831d6d635))
|
||||
* les fichiers non selectionner sur un torrent ne produise plus d'erreurs d'import ([2a08d7d](https://github.com/Masutayunikon/FanKarr/commit/2a08d7dbf5a8a91323801e9fa3722dfb02a7cd5e))
|
||||
* les fichiers non selectionner sur un torrent ne sont plus en "attente d'import" sur l'affichage de la serie ([7799f7d](https://github.com/Masutayunikon/FanKarr/commit/7799f7de86d0a207c960f72fa8e8db84de86d2a8))
|
||||
* les torrents sont stopé lors de la selection de fichier pour eviter qu'il commence le telechargement des autres fichiers avant de set les indexs ([45d340d](https://github.com/Masutayunikon/FanKarr/commit/45d340d01ab8507423b187447d0f0e36a75d21d4))
|
||||
* les torrents sont stopé lors de la selection de fichier pour eviter qu'il commence le telechargement des autres fichiers avant de set les indexs ([cdfb330](https://github.com/Masutayunikon/FanKarr/commit/cdfb330dd6ac20d2bfe02d196a8d94f0996c763c))
|
||||
* n'affiche plus tous les épisodes en train de se telecharger si seulement un fichier est selectionner ([c4bfa2b](https://github.com/Masutayunikon/FanKarr/commit/c4bfa2ba87167bf20c066b3f682bd70aa9b18467))
|
||||
* n'affiche plus tous les épisodes en train de se telecharger si seulement un fichier est selectionner ([1e4f735](https://github.com/Masutayunikon/FanKarr/commit/1e4f73522dd216d80c57839bd35c15dcc906bf90))
|
||||
* ne retire pas les fichiers quand on en ajoute sur le torrent ([5c1ae3e](https://github.com/Masutayunikon/FanKarr/commit/5c1ae3e7beb59dcc2769bddb0fa63266e10bcbab))
|
||||
* prise en charge de multiple torrent sur les telechargement d'episodes ([dc5387c](https://github.com/Masutayunikon/FanKarr/commit/dc5387c69a308bdf99f0b77f9769462ffda13db5))
|
||||
* qbit utilisation de /start au lieu de /resume pour la v5 ([98a3d81](https://github.com/Masutayunikon/FanKarr/commit/98a3d81c4882ed4e8db1047b2b8c5edddbe037b2))
|
||||
* utilisation de svg pour les drapeau ([c11287b](https://github.com/Masutayunikon/FanKarr/commit/c11287bd3fd88badc91d35b5ec825b09ff5237a1))
|
||||
* utilisation des noms de dossier du torrent pour l'affichage plutot que le nom sur nyaa (notament pour gto kai qui a plusieurs integral et qui est plus explicite qque le nom nyaa) ([4069035](https://github.com/Masutayunikon/FanKarr/commit/4069035956472bf179a3a9bb8bd317d6cce8dc13))
|
||||
* utilisation du fichier torrent avec le magnet pour les bug de metadonnées ([dc5387c](https://github.com/Masutayunikon/FanKarr/commit/dc5387c69a308bdf99f0b77f9769462ffda13db5))
|
||||
|
||||
## [3.9.2](https://github.com/Masutayunikon/FanKarr/compare/v3.9.1...v3.9.2) (2026-04-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* le bouton "tous télécharger" ne s'affiche plus si la series de contient que des integrales comme torrent ([d21270a](https://github.com/Masutayunikon/FanKarr/commit/d21270ae802ee43242365331922393b5cbf689fa))
|
||||
|
||||
## [3.9.1](https://github.com/Masutayunikon/FanKarr/compare/v3.9.0...v3.9.1) (2026-04-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* le badge de rename utilise la meme logique que coté serveur et n'apparait plus sur les vue series si il ne devrais pas ([9764d2d](https://github.com/Masutayunikon/FanKarr/commit/9764d2db8ed2531af3feb6ceba947eaf116c1e76))
|
||||
|
||||
## [3.9.0](https://github.com/Masutayunikon/FanKarr/compare/v3.8.1...v3.9.0) (2026-04-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ajout d'un bouton supprimé l'importation dans le menu d'importation d'une serie ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
|
||||
* ajout de badge quand le fichier est introuvable ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
|
||||
* ajout de la possibilité de renommer des imports dans le bon format ([67444d4](https://github.com/Masutayunikon/FanKarr/commit/67444d418d65cf0d0323c8955adb8d9e136a8797))
|
||||
* ajout du bouton suppresion de torrent en cours ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
|
||||
* ajout setup plex ([2123e73](https://github.com/Masutayunikon/FanKarr/commit/2123e73b7fec9c637b349e63da9c9a88dd9fccd6))
|
||||
* bouton tous télécharger sur les saisons et series ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
|
||||
* suppression des series supprimé ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ajout d'un loader sur certaines pages pour pas avoir des trucs par default qui s'affiche ([d241be3](https://github.com/Masutayunikon/FanKarr/commit/d241be30d126f5b975aca72d2c21ab74e7215fa4))
|
||||
* ajout de la page management dans le layout ([5dc0531](https://github.com/Masutayunikon/FanKarr/commit/5dc0531bfd50ac9ca5a59126670757179ed8a226))
|
||||
* cast string ([16d1bfe](https://github.com/Masutayunikon/FanKarr/commit/16d1bfe7e7766987876db80f84e6c0dc31217403))
|
||||
* Deux corrections : ([5398a4f](https://github.com/Masutayunikon/FanKarr/commit/5398a4fe12b1ac235884bb9c6218174b0c438ac1))
|
||||
* le bouton tout telecharger apparait sur les packs de saisons et les integrals + autres et pas que sur les series avec des episodes telechargable ([e105587](https://github.com/Masutayunikon/FanKarr/commit/e105587147e03e39e2c3b985a114434c2512417a))
|
||||
* move plex settings en haut des boutons ([96af23d](https://github.com/Masutayunikon/FanKarr/commit/96af23d258e14dacf371b043b92b554c2b22f80c))
|
||||
* plex catcher ([ba0dc8d](https://github.com/Masutayunikon/FanKarr/commit/ba0dc8d21bca098b0203f9553d81a47048fad452))
|
||||
* suppression de dest_filename du fichier d'organisation qui est faux et ne sert à rien ([3c69eba](https://github.com/Masutayunikon/FanKarr/commit/3c69eba440f4f83180bd5d3f25a2ad0499b91940))
|
||||
* utorrent prend desormais le dossier cible correctement ([cc68fef](https://github.com/Masutayunikon/FanKarr/commit/cc68fef88d6e13339867f6b6b47667a141a86091))
|
||||
* wrong file copy ([fa7da76](https://github.com/Masutayunikon/FanKarr/commit/fa7da7681193e89214a521c5bd16106f0ca03f46))
|
||||
|
||||
## [3.8.1](https://github.com/Masutayunikon/FanKarr/compare/v3.8.0...v3.8.1) (2026-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* la version dev se met uniquement en dev ([79e5478](https://github.com/Masutayunikon/FanKarr/commit/79e547859720f36330d95036de813240da034bbd))
|
||||
|
||||
## [3.8.0](https://github.com/Masutayunikon/FanKarr/compare/v3.7.1...v3.8.0) (2026-04-11)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-slim AS base
|
||||
FROM node:22-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
@@ -15,7 +15,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN pnpm run build
|
||||
|
||||
# ── Production ─────────────────────────────────────────────────
|
||||
FROM node:20-slim
|
||||
FROM node:22-slim
|
||||
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -28,4 +28,4 @@ RUN mkdir -p /config
|
||||
|
||||
EXPOSE 3001
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -1,58 +1,72 @@
|
||||
<div align="center">
|
||||
|
||||
# Fankarr
|
||||
# FanKarr
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
|
||||
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
|
||||
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Aperçu
|
||||
|
||||
<!-- [img] : vue catalogue complète — grille des affiches avec les barres d'état en bas de chaque affiche (couleurs : dispo / en cours / importé), barre de recherche visible -->
|
||||

|
||||
|
||||
> Vue catalogue : toutes les séries Fankai avec leur état d'organisation. Les badges indiquent si les fichiers sont déjà présents dans votre médiathèque Jellyfin.
|
||||
> Vue catalogue : toutes les séries Fankai. Une barre en bas de chaque affiche indique l'état — disponible, en cours de téléchargement ou déjà importé dans votre médiathèque.
|
||||
|
||||
---
|
||||
|
||||
<!-- [img] : vue série — détail des saisons et épisodes avec badges VOSTFR/MULTI + drapeaux, boutons de téléchargement avec dropdown multi-sources, barres de progression par épisode -->
|
||||

|
||||
|
||||
> Vue série : épisodes avec badge de langue (VOSTFR / MULTI), progression par fichier, et dropdown de sélection quand plusieurs sources sont disponibles.
|
||||
|
||||
---
|
||||
|
||||
<!-- [img] : vue téléchargements — liste des torrents actifs avec barres de progression, état d'organisation, badges erreur au hover -->
|
||||

|
||||
|
||||
> Vue téléchargements : suivi en temps réel, état d'organisation par torrent et détail des erreurs au survol.
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Catalogue complet** des séries Fankai avec affiches, statut et badges d'état
|
||||
- **Téléchargement en un clic** vers qBittorrent — par épisode, par saison ou intégrale
|
||||
- **Organisation automatique** des fichiers téléchargés vers votre médiathèque (hardlink ou déplacement)
|
||||
- **Suivi par épisode** — badge ✓ organisé sur chaque épisode et compteur par saison
|
||||
- **Notifications** toast automatiques quand une organisation se termine
|
||||
- **Page téléchargements** avec statut d'organisation et badge erreurs avec détail au hover
|
||||
- **Logs centralisés** avec filtre par niveau/source, rotation automatique et clear depuis l'UI
|
||||
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
|
||||
### 📥 Téléchargement
|
||||
- **Catalogue complet** des séries Fankai avec affiches et état de disponibilité
|
||||
- **Téléchargement en un clic** vers votre client torrent — par épisode, par saison ou en intégrale
|
||||
- **Dropdown intelligent** sur chaque bouton quand plusieurs sources sont disponibles (qualités différentes, packs différents)
|
||||
- **"Tout télécharger"** unifié — sélectionne automatiquement les épisodes non couverts par l'intégrale choisie et complète avec les packs individuels
|
||||
- **Progression par fichier** — barre de téléchargement au niveau de l'épisode, même pour les fichiers dans un pack intégrale
|
||||
|
||||
### 🗂️ Organisation
|
||||
- **Import automatique** des fichiers terminés vers votre médiathèque, déclenché dès la fin d'un téléchargement ou toutes les 5 minutes en arrière-plan
|
||||
- **Scan de médiathèque** — détecte les fichiers déjà présents sur le disque et les référence sans les déplacer
|
||||
- **Mode hardlink** (recommandé), **déplacement** ou **copie** — le hardlink préserve le seeding du client torrent
|
||||
- **Désimport automatique** optionnel quand un fichier est supprimé du disque
|
||||
|
||||
### 🏷️ Métadonnées & Affichage
|
||||
- **Badges de langue** VOSTFR et MULTI avec drapeaux sur chaque épisode (détectés automatiquement)
|
||||
- **Support NFO** — nommage compatible avec les plugins Jellyfin/Plex utilisant les fichiers `.nfo`
|
||||
- **Intégration Plex** — connexion à votre serveur Plex pour déclencher le scan médiathèque après import
|
||||
|
||||
### ⚙️ Système
|
||||
- **Logs centralisés** — filtre par niveau et source, rotation automatique, clear depuis l'UI
|
||||
- **Authentification** par mot de passe avec session JWT
|
||||
- **Compatible Runtipi / Docker**
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Vue série : détail des épisodes avec leur état de disponibilité torrent et d'organisation dans Jellyfin.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Vue téléchargements : suivi en temps réel de l'organisation avec détail des erreurs au survol.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Page logs : historique complet des événements avec filtres et gestion de la taille.
|
||||
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
|
||||
- **Compatible Docker / Runtipi / Binaire autonome**
|
||||
|
||||
---
|
||||
|
||||
@@ -60,9 +74,9 @@ Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
|
||||
|
||||
Les données du catalogue sont récupérées depuis le repo [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
|
||||
|
||||
Ce repo contient un pipeline de scripts Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, puis les croise avec l'API metadata de Fankai pour identifier chaque épisode. Les scripts tournent automatiquement **toutes les 6 heures** via GitHub Actions et publient le fichier `torrent_final.json` dans le repo.
|
||||
Ce repo contient un pipeline Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, les croise avec l'API metadata de Fankai pour identifier chaque épisode, et publie le résultat automatiquement **toutes les 6 heures** via GitHub Actions. Les données sont organisées par série dans un dossier `/serie` et des fichiers d'index à la racine du repo.
|
||||
|
||||
Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
|
||||
FanKarr récupère ces données au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
|
||||
|
||||
---
|
||||
|
||||
@@ -70,7 +84,7 @@ Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour**
|
||||
|
||||
### Docker Compose (recommandé)
|
||||
|
||||
**Prérequis** : Docker + Docker Compose, qBittorrent accessible en réseau.
|
||||
**Prérequis** : Docker + Docker Compose, un client torrent accessible en réseau.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -93,83 +107,121 @@ services:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> **⚠️ Note sur les hardlinks** : Pour que le mode `hardlink` fonctionne, vos dossiers de téléchargement complets et votre médiathèque doivent être sur le **même filesystem**. La solution recommandée est de monter un seul volume racine (ex: `/votre/chemin:/media`) et de configurer vos chemins à l'intérieur — comme le font Radarr et Sonarr.
|
||||
> **⚠️ Note sur les hardlinks** : Pour que le mode `hardlink` fonctionne, vos dossiers de téléchargement complets et votre médiathèque doivent être sur le **même filesystem**. La solution recommandée est de monter un seul volume racine (ex: `/votre/chemin:/media`) et de configurer vos chemins à l'intérieur — comme Radarr et Sonarr le font.
|
||||
>
|
||||
> Vous pouvez tout à fait monter plusieurs volumes si vous le souhaitez, mais dans ce cas les hardlinks ne seront pas possibles entre deux volumes différents et Fankarr basculera automatiquement en copie.
|
||||
> Plusieurs volumes sont possibles mais les hardlinks ne fonctionneront pas entre deux volumes différents — FanKarr basculera automatiquement en copie.
|
||||
|
||||
---
|
||||
|
||||
### Binaire autonome (Windows / Linux / macOS)
|
||||
|
||||
Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
|
||||
|
||||
| Système | Archive |
|
||||
| --------------------- | ------------------------- |
|
||||
| Windows x64 | `fankarr-windows-x64.zip` |
|
||||
| Linux x64 | `fankarr-linux-x64.zip` |
|
||||
| macOS (Apple Silicon) | `fankarr-macos.zip` |
|
||||
| Système | Archive |
|
||||
| -------------------------------- | -------------------------------- |
|
||||
| Windows x64 | `fankarr-windows-x64.zip` |
|
||||
| Windows Legacy (anciens CPU) | `fankarr-windows-legacy.zip` |
|
||||
| Linux x64 | `fankarr-linux-x64.zip` |
|
||||
| macOS (Apple Silicon) | `fankarr-macos.zip` |
|
||||
|
||||
Extrayez l'archive — elle contient le binaire **et** le dossier `public/` qui doivent rester ensemble :
|
||||
Extrayez l'archive — tous les fichiers doivent rester ensemble dans le même répertoire :
|
||||
|
||||
```
|
||||
fankarr-windows-x64/
|
||||
├── fankarr.exe ← lancer ce fichier
|
||||
└── public/ ← ne pas déplacer séparément
|
||||
├── fankarr.exe ← lancer ce fichier
|
||||
├── organize-worker.js ← worker d'organisation (ne pas déplacer)
|
||||
├── .env ← configuration optionnelle (voir Variables d'environnement)
|
||||
└── public/ ← assets frontend (ne pas déplacer)
|
||||
```
|
||||
|
||||
**Linux / macOS** : rendre le binaire exécutable avant le premier lancement :
|
||||
> Le fichier `.env` permet de configurer le port, la durée des sessions, etc. sans passer par des variables d'environnement système. Il est chargé automatiquement au démarrage si présent à côté du binaire.
|
||||
|
||||
**Linux / macOS** — rendre le binaire exécutable avant le premier lancement :
|
||||
|
||||
```bash
|
||||
chmod +x fankarr
|
||||
./fankarr
|
||||
```
|
||||
|
||||
Fankarr sera accessible sur `http://localhost:9898`. Les données (config, logs, base de données) sont sauvegardées dans un dossier `data/` créé automatiquement à côté du binaire.
|
||||
FanKarr sera accessible sur `http://localhost:9898`. La configuration, les logs et la base de données sont sauvegardés dans un dossier `config/` créé automatiquement à côté du binaire.
|
||||
|
||||
---
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Défaut | Description |
|
||||
| ---------------- | ------------ | ----------------------------------------------------------------------- |
|
||||
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
|
||||
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
|
||||
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
|
||||
| `PORT` | `9898` | Port d'écoute du serveur |
|
||||
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent |
|
||||
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
|
||||
| Variable | Défaut | Description |
|
||||
| ------------------ | ------------- | ------------------------------------------------------------------------------- |
|
||||
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
|
||||
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
|
||||
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
|
||||
| `PORT` | `9898` | Port d'écoute du serveur |
|
||||
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent |
|
||||
| `AUTH_TOKEN_EXPIRY`| `30d` | Durée de validité du token de connexion (ex: `7d`, `1y`, `never`) |
|
||||
| `DATA_DIR` | `./config` | Répertoire de stockage des données (binaire uniquement) |
|
||||
| `GITHUB_RAW_URL` | repo scraper | URL de base du repo scraper si vous hébergez votre propre instance |
|
||||
|
||||
---
|
||||
|
||||
### Premier démarrage
|
||||
|
||||
1. Ouvrir `http://localhost:9898`
|
||||
2. Créer un mot de passe à la première connexion
|
||||
3. Aller dans **Paramètres** → ajouter votre client qBittorrent
|
||||
4. Vérifier les chemins `completePath` et `mediaPath`
|
||||
5. Choisir le mode d'organisation : `hardlink` (recommandé) ou `move`
|
||||
6. Retourner au catalogue et télécharger
|
||||
3. Aller dans **Paramètres** → configurer votre client torrent
|
||||
4. Renseigner le chemin vers le dossier des **torrents terminés** et celui de votre **médiathèque**
|
||||
5. Choisir le mode d'organisation : `hardlink` (recommandé), `move` ou `copy`
|
||||
6. Optionnel : lancer le **scan** pour référencer les fichiers déjà présents sur le disque
|
||||
7. Retourner au catalogue et télécharger
|
||||
|
||||
<!-- [img] : page paramètres — formulaire de configuration client torrent, chemins, mode d'organisation, toggles autoImport / NFO / désimport auto -->
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Organisation des fichiers
|
||||
|
||||
Fankarr organise automatiquement les fichiers terminés toutes les **30 secondes** vers la structure attendue par Jellyfin :
|
||||
FanKarr organise les fichiers terminés vers la structure attendue par Jellyfin / Plex :
|
||||
|
||||
```
|
||||
/media/Kai/
|
||||
└── Black Lagoon Henshū/
|
||||
└── Saison 1/
|
||||
├── Black Lagoon Henshū S01E01.mkv
|
||||
├── Black Lagoon Henshū S01E02.mkv
|
||||
└── ...
|
||||
Black Lagoon Henshū/
|
||||
├── Saison 1/
|
||||
│ ├── Black Lagoon Henshū.S01E01.MULTI.1080p.x264-FANKAI.mkv
|
||||
│ ├── Black Lagoon Henshū.S01E02.MULTI.1080p.x264-FANKAI.mkv
|
||||
│ ├── Black Lagoon Henshū.S01E03.MULTI.1080p.x264-FANKAI.mkv
|
||||
│ └── Black Lagoon Henshū.S01E04.MULTI.1080p.x264-FANKAI.mkv
|
||||
├── Saison 2/
|
||||
│ ├── Black Lagoon Henshū.S02E05.MULTI.1080p.x264-FANKAI.mkv
|
||||
│ ├── Black Lagoon Henshū.S02E06.MULTI.1080p.x264-FANKAI.mkv
|
||||
│ └── Black Lagoon Henshū.S02E07.MULTI.1080p.x264-FANKAI.mkv
|
||||
└── Saison 3/
|
||||
└── Black Lagoon Henshū.S03E08.MULTI.1080p.x264-FANKAI.mkv
|
||||
```
|
||||
|
||||
Le mode **hardlink** est recommandé si vos dossiers `complete` et `media` sont sur le même filesystem — les fichiers ne sont pas déplacés, qBittorrent continue de seeder.
|
||||
Le mode **hardlink** est recommandé si vos dossiers de téléchargement et de médiathèque sont sur le même filesystem — les fichiers ne sont pas déplacés et le client torrent continue de seeder sans interruption.
|
||||
|
||||
L'organisation se déclenche :
|
||||
- **Automatiquement** dès la fin d'un téléchargement, et toutes les 5 minutes en arrière-plan pour les torrents déjà en seeding
|
||||
- **Manuellement** depuis le bouton **Importer** dans la vue téléchargements
|
||||
|
||||
Le **scan médiathèque** permet de référencer des fichiers déjà présents sur le disque sans les déplacer — utile si vous avez déjà une bibliothèque existante.
|
||||
|
||||
---
|
||||
|
||||
## Logs
|
||||
|
||||
<!-- [img] : page logs — liste des événements avec filtres niveau/source, bouton clear, affichage compact -->
|
||||

|
||||
|
||||
> Tous les événements sont horodatés et filtrables par source (`organize`, `api`, `torrent`, …) et niveau (`info`, `warn`, `error`, `debug`).
|
||||
|
||||
---
|
||||
|
||||
## Runtipi
|
||||
|
||||
Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
||||
FanKarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
||||
|
||||
1. Dans Runtipi, aller dans **Paramètres → App Stores**
|
||||
2. Ajouter l'URL : `https://github.com/Masutayunikon/runtipi-appstore`
|
||||
3. Fankarr apparaît dans le catalogue → **Installer**
|
||||
3. FanKarr apparaît dans le catalogue → **Installer**
|
||||
|
||||
---
|
||||
|
||||
@@ -177,9 +229,10 @@ Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
||||
|
||||
| Couche | Technologie |
|
||||
| ----------------- | ---------------------------------------- |
|
||||
| Frontend | Vue 3 + Vite + Tailwind CSS |
|
||||
| Frontend | Vue 3 + Vite + Tailwind CSS v4 |
|
||||
| Backend | Express 5 + TypeScript |
|
||||
| Auth | JWT + bcrypt |
|
||||
| Worker | Node Worker Threads (organisation async) |
|
||||
| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) |
|
||||
| Packaging binaire | Bun (self-contained, pas de Node requis) |
|
||||
|
||||
@@ -190,4 +243,4 @@ Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
||||
- [fankai.fr](https://fankai.fr) — Le projet Fankai
|
||||
- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) — Reconnaissance des métadonnées dans Jellyfin
|
||||
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents
|
||||
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
|
||||
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
|
||||
@@ -26,6 +26,7 @@
|
||||
"node-fetch": "^3.3.2",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"undici": "^8.1.0",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
|
||||
@@ -38,6 +38,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
undici:
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.0
|
||||
vue:
|
||||
specifier: ^3.5.25
|
||||
version: 3.5.29(typescript@5.9.3)
|
||||
@@ -1343,6 +1346,10 @@ packages:
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
undici@8.1.0:
|
||||
resolution: {integrity: sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2645,6 +2652,8 @@ snapshots:
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici@8.1.0: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
unplugin-utils@0.3.1:
|
||||
|
||||
@@ -7,6 +7,18 @@ import { JWT_SECRET } from './secret.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
|
||||
// AUTH_TOKEN_EXPIRY : durée de validité du token JWT
|
||||
// Exemples : "1d", "7d", "30d", "1y", "never" / "0" = pas d'expiration
|
||||
const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
|
||||
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
|
||||
const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY
|
||||
|
||||
function signToken(username: string): string {
|
||||
return NO_EXPIRY
|
||||
? jwt.sign({ username }, JWT_SECRET)
|
||||
: jwt.sign({ username }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
|
||||
}
|
||||
|
||||
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
@@ -66,7 +78,7 @@ export function authSetup(req: Request, res: Response): void {
|
||||
writeAuth({ username, passwordHash })
|
||||
logger.info('auth', `Compte créé pour "${username}"`)
|
||||
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
|
||||
const token = signToken(username)
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true })
|
||||
}
|
||||
@@ -91,9 +103,9 @@ export function authLogin(req: Request, res: Response): void {
|
||||
return
|
||||
}
|
||||
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
|
||||
const token = signToken(username)
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
logger.info('auth', `Connexion réussie pour "${username}"`)
|
||||
logger.info('auth', `Connexion réussie pour "${username}" (expiry: ${NO_EXPIRY ? 'aucune' : TOKEN_EXPIRY})`)
|
||||
res.json({ success: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,63 @@ import path from 'path'
|
||||
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
|
||||
// ── Chargement anticipé du fichier .env pour les binaires bun ─────────────
|
||||
// Doit s'exécuter avant tout autre module qui lit process.env
|
||||
// (secret.ts, auth.ts…) car config.ts est la dépendance la plus profonde.
|
||||
;(() => {
|
||||
if (!_isBunBinary) return
|
||||
const binDir = path.dirname((process as any).execPath)
|
||||
const envPath = path.join(binDir, '.env')
|
||||
if (!fs.existsSync(envPath)) return
|
||||
try {
|
||||
const lines = fs.readFileSync(envPath, 'utf-8').split('\n')
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const eq = line.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
const key = line.slice(0, eq).trim()
|
||||
let val = line.slice(eq + 1).trim()
|
||||
// Supprimer les guillemets optionnels
|
||||
if ((val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1)
|
||||
}
|
||||
// Ne pas écraser les variables déjà définies (CLI/système prioritaires)
|
||||
if (key && !(key in process.env)) process.env[key] = val
|
||||
}
|
||||
console.log(`[config] .env chargé depuis ${envPath}`)
|
||||
} catch (err) {
|
||||
console.warn(`[config] Impossible de charger .env : ${err}`)
|
||||
}
|
||||
})()
|
||||
|
||||
export const BASE_DIR = _isBunBinary
|
||||
? path.dirname((process as any).execPath)
|
||||
: process.cwd()
|
||||
|
||||
export const DATA_DIR = fs.existsSync('/.dockerenv')
|
||||
const isInContainer = (): boolean => {
|
||||
// Docker
|
||||
if (fs.existsSync('/.dockerenv')) return true;
|
||||
|
||||
// Podman (crée un fichier /.containerenv)
|
||||
if (fs.existsSync('/.containerenv')) return true;
|
||||
|
||||
// Vérification via cgroup (Docker, Podman, LXC, etc.)
|
||||
try {
|
||||
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
|
||||
if (/docker|podman|containerd|lxc/i.test(cgroup)) return true;
|
||||
} catch {}
|
||||
|
||||
// Vérification via /proc/1/environ (fallback général)
|
||||
try {
|
||||
const environ = fs.readFileSync('/proc/1/environ', 'utf8');
|
||||
if (environ.includes('container=')) return true;
|
||||
} catch {}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const DATA_DIR = isInContainer()
|
||||
? '/config'
|
||||
: path.join(BASE_DIR, 'config')
|
||||
: path.join(BASE_DIR, 'config');
|
||||
@@ -3,35 +3,41 @@ import cookieParser from 'cookie-parser'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { authStatus, authSetup, authLogin, authLogout, requireAuth } from './auth.js'
|
||||
import { readSettings, writeSettings } from './settings.js'
|
||||
import {
|
||||
registerDriver, getDriver, getAvailableClients,
|
||||
listClients, addClient, removeClient, getClient,
|
||||
sanitizeClient, dispatchDownload, dispatchList, updateClient, dispatchRemove
|
||||
} from './torrent-clients/index.js'
|
||||
import { authStatus, authSetup, authLogin, authLogout } from './auth.js'
|
||||
import { readSettings } from './settings.js'
|
||||
import { registerDriver, dispatchList, dispatchRemove } from './torrent-clients/index.js'
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
import transmissionDriver from './torrent-clients/transmission.js'
|
||||
import synologyDsDriver from './torrent-clients/synology-ds.js'
|
||||
import utorrentDriver from './torrent-clients/utorrent.js'
|
||||
import rtorrentDriver from './torrent-clients/rtorrent.js'
|
||||
import { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js'
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||
import realDebridDriver from './torrent-clients/real-debrid.js'
|
||||
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js'
|
||||
import { logger } from './logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||
import { systemInfo } from './system.js'
|
||||
import { getGitlabTitle } from './gitlab-map.js'
|
||||
import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js'
|
||||
import { pushNotif } from './lib/notifs.js'
|
||||
import { checkNfoUpdates } from './lib/nfo.js'
|
||||
|
||||
import settingsRouter from './routes/settings.js'
|
||||
import torrentClientsRouter from './routes/torrent-clients.js'
|
||||
import seriesRouter from './routes/series.js'
|
||||
import downloadsRouter from './routes/downloads.js'
|
||||
import organizeRouter from './routes/organize.js'
|
||||
import importRouter from './routes/import.js'
|
||||
import systemRouter from './routes/system.js'
|
||||
import nfoUpdatesRouter from './routes/nfo-updates.js'
|
||||
import plexRouter from './routes/plex.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
registerDriver(transmissionDriver)
|
||||
registerDriver(synologyDsDriver)
|
||||
registerDriver(utorrentDriver)
|
||||
registerDriver(rtorrentDriver)
|
||||
registerDriver(realDebridDriver)
|
||||
|
||||
const app = express()
|
||||
const PORT = Number(process.env.PORT) || 9898
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
const GITHUB_BASE = process.env.GITHUB_BASE
|
||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
||||
|
||||
app.use(express.json())
|
||||
app.use(cookieParser())
|
||||
@@ -47,846 +53,16 @@ app.post('/api/auth/setup', authSetup)
|
||||
app.post('/api/auth/login', authLogin)
|
||||
app.post('/api/auth/logout', authLogout)
|
||||
|
||||
app.get('/api/system/info', systemInfo)
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────
|
||||
app.get('/api/settings', requireAuth, (_req, res) => {
|
||||
res.json(readSettings())
|
||||
})
|
||||
app.post('/api/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove } = req.body
|
||||
const patch: Record<string, any> = {}
|
||||
if (mediaPath !== undefined) patch.mediaPath = mediaPath
|
||||
if (completePath !== undefined) patch.completePath = completePath
|
||||
if (organizeMode !== undefined) patch.organizeMode = organizeMode
|
||||
if (category !== undefined) patch.category = category
|
||||
if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport
|
||||
if (autoImport !== undefined) patch.autoImport = autoImport
|
||||
if (devMode !== undefined) patch.devMode = devMode
|
||||
if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
|
||||
const updated = writeSettings(patch)
|
||||
logger.info('api', 'Paramètres mis à jour')
|
||||
res.json(updated)
|
||||
})
|
||||
|
||||
// ── Torrent clients ────────────────────────────────────────────
|
||||
app.get('/api/torrent-clients/available', requireAuth, (_req, res) => {
|
||||
res.json(getAvailableClients())
|
||||
})
|
||||
app.get('/api/torrent-clients', requireAuth, (_req, res) => {
|
||||
res.json(listClients().map((c: any) => sanitizeClient(c)))
|
||||
})
|
||||
app.post('/api/torrent-clients', requireAuth, (req, res) => {
|
||||
const { name, type, config } = req.body
|
||||
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
res.json(sanitizeClient(addClient(name, type, config)))
|
||||
})
|
||||
app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
const ok = removeClient(String(req.params.uuid))
|
||||
if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
res.json({ ok: true })
|
||||
})
|
||||
app.put('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
const { name, type, config } = req.body
|
||||
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
const oldClient = getClient(String(req.params.uuid))
|
||||
if (oldClient) {
|
||||
const driver = getDriver(type)
|
||||
if (driver) {
|
||||
for (const field of driver.definition.fields) {
|
||||
if (field.type === 'password' && config[field.key] === '••••••••') {
|
||||
config[field.key] = oldClient.config[field.key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const updated = updateClient(String(req.params.uuid), name, type, config)
|
||||
if (!updated) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
res.json(sanitizeClient(updated))
|
||||
})
|
||||
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
|
||||
const { type, config, uuid } = req.body
|
||||
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
|
||||
const driver = getDriver(type)
|
||||
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
if (uuid) {
|
||||
const oldClient = getClient(uuid)
|
||||
if (oldClient) {
|
||||
for (const field of driver.definition.fields) {
|
||||
if (field.type === 'password' && config[field.key] === '••••••••') {
|
||||
config[field.key] = oldClient.config[field.key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json(await driver.test(config))
|
||||
})
|
||||
app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
|
||||
const client = getClient(String(req.params.uuid))
|
||||
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
|
||||
res.json(await driver.test(client.config))
|
||||
})
|
||||
app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) => {
|
||||
const client = getClient(String(req.params.uuid))
|
||||
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
|
||||
res.json(await driver.healthcheck(client.config))
|
||||
})
|
||||
|
||||
// ── Cache GitHub ───────────────────────────────────────────────
|
||||
const CACHE_TTL_MS = 6 * 60 * 60 * 1000
|
||||
|
||||
interface CacheEntry<T> { data: T; expiresAt: number }
|
||||
const _cache = new Map<string, CacheEntry<any>>()
|
||||
|
||||
function cacheGet<T>(key: string): T | null {
|
||||
const entry = _cache.get(key)
|
||||
if (!entry) return null
|
||||
if (Date.now() > entry.expiresAt) { _cache.delete(key); return null }
|
||||
return entry.data as T
|
||||
}
|
||||
|
||||
function cacheSet<T>(key: string, data: T): void {
|
||||
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
|
||||
}
|
||||
|
||||
function cacheClear(): void { _cache.clear() }
|
||||
|
||||
async function githubGet(urlPath: string): Promise<any> {
|
||||
const cached = cacheGet(urlPath)
|
||||
if (cached) return cached
|
||||
const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
|
||||
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
|
||||
const data = await res.json()
|
||||
cacheSet(urlPath, data)
|
||||
return data
|
||||
}
|
||||
|
||||
async function readAvailable(): Promise<number[]> {
|
||||
try { return await githubGet('available.json') as number[] }
|
||||
catch { return [] }
|
||||
}
|
||||
|
||||
async function readInfohashMap(): Promise<Record<string, string>> {
|
||||
try { return await githubGet('infohash_map.json') as Record<string, string> }
|
||||
catch { return {} }
|
||||
}
|
||||
|
||||
async function readSerieData(serieId: number): Promise<any | null> {
|
||||
try { return await githubGet(`series/${serieId}.json`) }
|
||||
catch { return null }
|
||||
}
|
||||
|
||||
function extractTorrentsFromSerieData(sd: any): any[] {
|
||||
const result: any[] = []
|
||||
if (!sd) return result
|
||||
for (const t of sd.torrents ?? []) {
|
||||
result.push({ ...t, raw: t.title, type: 'pack_integrale', serie_id: sd.id, serie_title: sd.title, _serieLevel: true, _serieData: sd, _torrentIdx: (sd.torrents ?? []).indexOf(t) })
|
||||
}
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
result.push({ ...t, raw: t.title, type: 'pack_saison', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, _seasonLevel: true, _season: season, _torrentIdx: (season.torrents ?? []).indexOf(t) })
|
||||
}
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
result.push({ ...t, raw: t.title, type: 'episode', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, episode_id: ep.id, episode_number: ep.episode_number, _episodeLevel: true, _episode: ep, _torrentIdx: (ep.torrents ?? []).indexOf(t) })
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] {
|
||||
const resolved: any[] = []
|
||||
const h = hash.toLowerCase()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const paths: any[] = ep.paths ?? []
|
||||
let filePath: string | null = null
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
|
||||
else if (p?.infohash?.toLowerCase() === h) { filePath = p.path.replace(/\\/g, '/'); break }
|
||||
}
|
||||
if (!filePath) continue
|
||||
const filename = filePath.split('/').pop() ?? filePath
|
||||
resolved.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, season_id: season.id, filename, original_filename: filename })
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function fankaiGet(endpoint: string): Promise<any> {
|
||||
const res = await fetch(`${FANKAI_API}${endpoint}`)
|
||||
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
|
||||
if (!url) return null
|
||||
const stripped = url.replace(/^https?:\/\//, '')
|
||||
return `https://wsrv.nl/?url=${stripped}&w=${w}&q=${q}`
|
||||
}
|
||||
|
||||
function normalizeSerie(serie: any): any {
|
||||
return { ...serie, poster_image: imgProxy(serie.poster_image ?? serie.images?.poster ?? null, 300), fanart_image: imgProxy(serie.fanart_image ?? serie.images?.fanart ?? null, 1280, 60) }
|
||||
}
|
||||
function normalizeSeason(season: any): any {
|
||||
return { ...season, poster_image: imgProxy(season.poster_image ?? season.images?.poster ?? null, 300) }
|
||||
}
|
||||
function normalizeEpisode(ep: any): any {
|
||||
return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 400) }
|
||||
}
|
||||
|
||||
async function loadEnrichedSeriesData(): Promise<any[]> {
|
||||
const ids = await readAvailable()
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
|
||||
return allSd.filter(Boolean)
|
||||
}
|
||||
|
||||
function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, any>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
if (!serieData) return 'none'
|
||||
const allTorrents = extractTorrentsFromSerieData(serieData)
|
||||
|
||||
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
|
||||
|
||||
// Collecter tous les épisodes de la série
|
||||
const allEpisodeIds = new Set<number>()
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
allEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
|
||||
// Vérifier via les torrents
|
||||
for (const t of allTorrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (!hash) continue
|
||||
const orgFiles = organized[hash] ?? {}
|
||||
if (Object.keys(orgFiles).length === 0) continue
|
||||
for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) {
|
||||
const isOrganized = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
||||
if (isOrganized) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier aussi les imports manuels (clé "manual" dans organized.json)
|
||||
const manualOrg = organized['manual'] ?? {}
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none'
|
||||
if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete'
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
// ── Routes série ───────────────────────────────────────────────
|
||||
app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()])
|
||||
const availableSet = new Set<number>(availableIds)
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const activeTorrents = new Set<string>()
|
||||
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
|
||||
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
|
||||
const serieDataMap = new Map<number, any>()
|
||||
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
|
||||
res.json({ series: seriesRaw.map((serie: any) => {
|
||||
const serieData = serieDataMap.get(serie.id) ?? null
|
||||
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false
|
||||
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) }
|
||||
})})
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const [serieRaw, seasonsData, serieData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`), readSerieData(id)])
|
||||
const serie = normalizeSerie(serieRaw)
|
||||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
||||
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
|
||||
return { ...normalizeSeason(season), episodes }
|
||||
}))
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
const episodeTorrentMap : Record<number, any> = {}
|
||||
const seasonTorrentMapBySn : Record<number, any> = {}
|
||||
const integraleTorrents : any[] = []
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
|
||||
// Vérifier les imports manuels (clé "manual") pour tous les épisodes de la série
|
||||
const manualOrg = organized['manual'] ?? {}
|
||||
if (serieData) {
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (serieData) {
|
||||
for (const t of (serieData.torrents ?? [])) {
|
||||
integraleTorrents.push({ ...t, raw: t.title })
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of resolved) {
|
||||
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
||||
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const t of (season.torrents ?? [])) {
|
||||
seasonTorrentMapBySn[season.season_number] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false }
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of resolved) {
|
||||
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
||||
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of (ep.torrents ?? [])) {
|
||||
episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true }
|
||||
availableEpisodeIds.add(ep.id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
const isOrg = orgFiles[String(ep.id)] !== undefined
|
||||
if (!isOrg) {
|
||||
const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
const filename = match ? match.path.split('/').pop() : null
|
||||
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
|
||||
} else {
|
||||
organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
|
||||
const eps = season.episodes.map((ep: any) => {
|
||||
const epTorrent = episodeTorrentMap[ep.id] ?? null
|
||||
let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null
|
||||
if (fankai === null && serieData) {
|
||||
outer: for (const sd_season of serieData.seasons ?? []) {
|
||||
for (const sdEp of sd_season.episodes ?? []) {
|
||||
if (sdEp.id !== ep.id) continue
|
||||
for (const p of sdEp.paths ?? []) {
|
||||
const ih = typeof p === 'object' ? p.infohash?.toLowerCase() : null
|
||||
if (!ih) continue
|
||||
for (const t of serieData.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } }
|
||||
for (const s of serieData.seasons ?? []) { for (const t of s.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...ep, available: availableEpisodeIds.has(ep.id), torrent: epTorrent ? { ...epTorrent, fankai: fankai ?? true } : null, fankai, organized: organizedEpisodeIds.has(ep.id) }
|
||||
})
|
||||
const total = eps.filter((e: any) => e.available).length
|
||||
const orgCount = eps.filter((e: any) => e.organized).length
|
||||
const epTotal = eps.length // total réel incluant les imports manuels
|
||||
const orgState = orgCount === 0
|
||||
? 'none'
|
||||
: orgCount >= epTotal
|
||||
? 'complete'
|
||||
: total > 0 && orgCount >= total
|
||||
? 'complete'
|
||||
: 'partial'
|
||||
return { ...season, torrent: seasonTorrentMapBySn[season.season_number] ?? null, organized_state: orgState, organized_count: orgCount, episodes: eps }
|
||||
})
|
||||
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })) })
|
||||
} 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' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Download ───────────────────────────────────────────────────
|
||||
app.post('/api/download', requireAuth, async (req, res) => {
|
||||
const url = req.body.magnet ?? req.body.torrent_url
|
||||
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
|
||||
logger.info('api', 'Téléchargement demandé')
|
||||
const results = await dispatchDownload(url)
|
||||
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
|
||||
})
|
||||
|
||||
app.delete('/api/torrent/:hash', requireAuth, async (req, res) => {
|
||||
const hash = req.params.hash as string
|
||||
const deleteFiles = req.query.deleteFiles === 'true'
|
||||
if (!hash) { res.status(400).json({ error: 'hash requis' }); return }
|
||||
try {
|
||||
const result = await dispatchRemove(hash, deleteFiles)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
logger.error('api', `Suppression torrent échouée : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Downloads list ─────────────────────────────────────────────
|
||||
app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const infohashMap = await readInfohashMap()
|
||||
const torrents = await dispatchList(category ?? 'fankai', infohashMap)
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const enriched = await Promise.all(torrents.map(async (t: any) => {
|
||||
const orgFiles = organized[t.hash] ?? {}
|
||||
let totalFiles = 1
|
||||
try {
|
||||
const availableIds = await readAvailable()
|
||||
for (const id of availableIds) {
|
||||
const sd = await readSerieData(id)
|
||||
if (!sd) continue
|
||||
const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
|
||||
if (match) { totalFiles = buildResolvedEpisodes(sd, match.infohash, match.season_number).length || 1; break }
|
||||
}
|
||||
} catch {}
|
||||
const doneFiles = Object.keys(orgFiles).length
|
||||
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
|
||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [] }
|
||||
}))
|
||||
res.json(enriched)
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Organize ───────────────────────────────────────────────────
|
||||
interface OrganizeNotif {
|
||||
hash: string; name: string; done: number; skipped: number; errors: number
|
||||
errorFiles: { file: string; error: string }[]; at: string
|
||||
}
|
||||
const recentOrganized: OrganizeNotif[] = []
|
||||
const MAX_NOTIFS = 20
|
||||
|
||||
function pushNotif(n: OrganizeNotif) {
|
||||
recentOrganized.unshift(n)
|
||||
if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
|
||||
}
|
||||
|
||||
app.get('/api/organize/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentOrganized)
|
||||
})
|
||||
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
|
||||
recentOrganized.length = 0
|
||||
logger.info('api', 'Historique des imports effacé')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
app.post('/api/organize', requireAuth, async (req, res) => {
|
||||
const { hash, name, save_path } = req.body
|
||||
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await organizeTorrent(hash, name, save_path, seriesData)
|
||||
if (result.done > 0 || result.errors.length > 0) {
|
||||
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
||||
}
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
logger.error('api', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Logs ───────────────────────────────────────────────────────
|
||||
app.get('/api/logs', requireAuth, (req, res) => {
|
||||
const limit = Number(req.query.limit) || 100
|
||||
const level = (req.query.level as string) || 'all'
|
||||
const source = (req.query.source as string) || undefined
|
||||
res.json({ entries: readLogs({ limit, level: level as any, source }), size: logsFileSize() })
|
||||
})
|
||||
app.post('/api/logs/clear', requireAuth, (_req, res) => {
|
||||
clearLogs()
|
||||
logger.info('api', 'Logs effacés')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── Système ────────────────────────────────────────────────────
|
||||
app.get('/api/system', requireAuth, (_req, res) => {
|
||||
res.json({ isDocker: fs.existsSync('/.dockerenv') })
|
||||
})
|
||||
|
||||
app.get('/api/version', (_req, res) => {
|
||||
try {
|
||||
const versionPath = path.join(BASE_DIR, 'version.txt')
|
||||
const version = fs.existsSync(versionPath)
|
||||
? fs.readFileSync(versionPath, 'utf-8').trim()
|
||||
: 'dev'
|
||||
res.json({ version })
|
||||
} catch {
|
||||
res.json({ version: 'dev' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/browse', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b))
|
||||
res.json({ path: dirPath, parent: dirPath === '/' ? null : path.dirname(dirPath), dirs })
|
||||
} catch (err) {
|
||||
logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/browse-files', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
|
||||
try {
|
||||
// Créer le dossier s'il n'existe pas (cas import manuel série non encore importée)
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
logger.info('api', `Dossier créé : ${dirPath}`)
|
||||
}
|
||||
const files: { name: string; path: string; size: number }[] = []
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
|
||||
catch { return }
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(full)
|
||||
} else if (entry.isFile() && VIDEO_EXTS.has(path.extname(entry.name).toLowerCase())) {
|
||||
try {
|
||||
const stat = fs.statSync(full)
|
||||
files.push({ name: entry.name, path: full, size: stat.size })
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(dirPath)
|
||||
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||
res.json({ path: dirPath, files })
|
||||
} catch (err) {
|
||||
logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/torrents/status', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const available = await readAvailable()
|
||||
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
|
||||
} catch { res.json({ exists: false, count: 0, empty: true }) }
|
||||
})
|
||||
|
||||
app.post('/api/update', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
cacheClear()
|
||||
logger.info('api', 'Cache GitHub vidé — rechargement forcé')
|
||||
const availableIds = await readAvailable()
|
||||
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
|
||||
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
|
||||
res.json({ ok: true, count: availableIds.length })
|
||||
} catch (err) {
|
||||
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/scan', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { mediaPath } = readSettings()
|
||||
logger.info('api', 'Scan médiathèque manuel lancé')
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error('api', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Import manuel ──────────────────────────────────────────────
|
||||
app.post('/api/manual-import', requireAuth, async (req, res) => {
|
||||
const { serie_id, items } = req.body
|
||||
if (!serie_id || !Array.isArray(items) || items.length === 0) {
|
||||
res.status(400).json({ error: 'serie_id et items requis' }); return
|
||||
}
|
||||
const { mediaPath, organizeMode, nfoSupport } = readSettings()
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { if (fs.existsSync(ORGANIZED_PATH)) organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
|
||||
const sd = await readSerieData(Number(serie_id))
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return }
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
const episodeIndex = new Map<number, { ep: any; season: any }>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
episodeIndex.set(ep.id, { ep, season })
|
||||
}
|
||||
}
|
||||
const done: number[] = []
|
||||
const errors: { file: string; error: string }[] = []
|
||||
for (const item of items) {
|
||||
const { file_path, episode_id, hash } = item
|
||||
if (!file_path || !episode_id) { errors.push({ file: file_path ?? '?', error: 'Paramètres manquants' }); continue }
|
||||
const found = episodeIndex.get(Number(episode_id))
|
||||
if (!found) { errors.push({ file: file_path, error: `Épisode ${episode_id} introuvable` }); continue }
|
||||
const { ep, season } = found
|
||||
const srcFilename = path.basename(file_path)
|
||||
const srcExt = path.extname(srcFilename)
|
||||
const fmtName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null
|
||||
const resolvedName = nfoSupport
|
||||
? (ep.nfo_filename ?? ep.original_filename ?? srcFilename)
|
||||
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? srcFilename)
|
||||
// FIX : swap l'extension avec celle du fichier source (.nfo/.mkv → .mp4 si besoin)
|
||||
const resolvedExt = path.extname(resolvedName)
|
||||
const destFilename = resolvedExt && resolvedExt !== srcExt
|
||||
? resolvedName.slice(0, -resolvedExt.length) + srcExt
|
||||
: resolvedName
|
||||
const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}`
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
|
||||
const destPath = path.join(destDir, destFilename)
|
||||
try {
|
||||
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
|
||||
if (file_path !== destPath) {
|
||||
// Si le fichier est déjà dans mediaPath/[série]/ → rename sur place
|
||||
const serieRootPath = path.join(mediaPath, serieTitle)
|
||||
const isInSeriePath = file_path.startsWith(serieRootPath + path.sep) || file_path.startsWith(serieRootPath + '/')
|
||||
|
||||
if (isInSeriePath && !fs.existsSync(destPath)) {
|
||||
fs.renameSync(file_path, destPath)
|
||||
logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
|
||||
} else if (!isInSeriePath && !fs.existsSync(destPath)) {
|
||||
if (organizeMode === 'hardlink') {
|
||||
try { fs.linkSync(file_path, destPath) }
|
||||
catch { await fs.promises.copyFile(file_path, destPath) }
|
||||
} else if (organizeMode === 'move') {
|
||||
await fs.promises.copyFile(file_path, destPath)
|
||||
await fs.promises.unlink(file_path)
|
||||
} else {
|
||||
await fs.promises.copyFile(file_path, destPath)
|
||||
}
|
||||
logger.info('api', `Import manuel : "${srcFilename}" → "${destPath}"`)
|
||||
}
|
||||
}
|
||||
|
||||
const torrentHash = String(hash || '').toLowerCase() || 'manual'
|
||||
if (!organized[torrentHash]) organized[torrentHash] = {}
|
||||
organized[torrentHash][String(episode_id)] = {
|
||||
at: new Date().toISOString(), season: season.season_number,
|
||||
episode: ep.episode_number, episode_id: ep.id,
|
||||
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
|
||||
}
|
||||
done.push(episode_id)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
errors.push({ file: srcFilename, error: msg })
|
||||
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
|
||||
// Télécharger les NFO en arrière-plan si activé
|
||||
if (nfoSupport && done.length > 0) {
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
;(async () => {
|
||||
try {
|
||||
let files: any[] = [], page = 1
|
||||
while (true) {
|
||||
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
|
||||
if (!Array.isArray(batch) || batch.length === 0) break
|
||||
files.push(...batch); if (batch.length < 100) break; page++
|
||||
}
|
||||
for (const entry of files.filter((f: any) => f.type === 'blob')) {
|
||||
const rel = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
||||
const dest = path.join(mediaPath, serieTitle, rel)
|
||||
if (fs.existsSync(dest)) continue
|
||||
const raw = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${rel.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!raw.ok) continue
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
||||
fs.writeFileSync(dest, Buffer.from(await raw.arrayBuffer()))
|
||||
}
|
||||
logger.info('api', `NFO téléchargés pour "${gitlabTitle}" (import manuel)`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Échec téléchargement NFO pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
res.json({ ok: true, done: done.length, errors })
|
||||
})
|
||||
|
||||
app.get('/api/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 }
|
||||
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 result: Record<string, any> = {}
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p !== 'object' || !p.infohash) continue
|
||||
const hash = p.infohash.toLowerCase()
|
||||
const entry = (organized[hash] ?? {})[String(ep.id)]
|
||||
if (entry) { result[String(ep.id)] = entry; break }
|
||||
}
|
||||
if (!result[String(ep.id)] && organized['manual']?.[String(ep.id)]) {
|
||||
result[String(ep.id)] = organized['manual'][String(ep.id)]
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Debug ──────────────────────────────────────────────────────
|
||||
app.get('/api/debug/stats', requireAuth, (req, res) => {
|
||||
const { devMode } = readSettings()
|
||||
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
|
||||
const mem = process.memoryUsage()
|
||||
const uptimeS = Math.floor(process.uptime())
|
||||
const h = Math.floor(uptimeS / 3600)
|
||||
const m = Math.floor((uptimeS % 3600) / 60)
|
||||
const s = uptimeS % 60
|
||||
let organizedCount = 0
|
||||
try {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath)) {
|
||||
const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
organizedCount = Object.values(org).reduce((acc: number, episodes: any) => acc + Object.keys(episodes).length, 0)
|
||||
}
|
||||
} catch {}
|
||||
res.json({
|
||||
memory : { heapUsed: Math.round(mem.heapUsed / 1024 / 1024), heapTotal: Math.round(mem.heapTotal / 1024 / 1024), rss: Math.round(mem.rss / 1024 / 1024) },
|
||||
cache : { entries: _cache.size, ttlHours: 6 },
|
||||
uptime : `${h}h ${m}m ${s}s`,
|
||||
uptimeSeconds: uptimeS,
|
||||
worker : { running: workerRunning },
|
||||
organized: { trackedFiles: organizedCount },
|
||||
requests : { notifs: recentOrganized.length },
|
||||
})
|
||||
})
|
||||
|
||||
// ── NFO Updates ────────────────────────────────────────────────
|
||||
interface NfoUpdateNotif {
|
||||
serieTitle: string; commitSha: string; commitMsg: string
|
||||
updatedAt: string; status: 'updated' | 'error'; error?: string
|
||||
}
|
||||
const recentNfoUpdates: NfoUpdateNotif[] = []
|
||||
const MAX_NFO_NOTIFS = 30
|
||||
const NFO_COMMITS_PATH = path.join(DATA_DIR, 'nfo_commits.json')
|
||||
const GITLAB_API_NFO = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
|
||||
const GITLAB_RAW_NFO = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
|
||||
|
||||
function loadNfoCommits(): Record<string, string> {
|
||||
try { if (!fs.existsSync(NFO_COMMITS_PATH)) return {}; return JSON.parse(fs.readFileSync(NFO_COMMITS_PATH, 'utf-8')) }
|
||||
catch { return {} }
|
||||
}
|
||||
function saveNfoCommits(commits: Record<string, string>) {
|
||||
fs.mkdirSync(path.dirname(NFO_COMMITS_PATH), { recursive: true })
|
||||
fs.writeFileSync(NFO_COMMITS_PATH, JSON.stringify(commits, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
async function getLatestGitlabCommit(gitlabTitle: string): Promise<{ sha: string; message: string } | null> {
|
||||
try {
|
||||
const res = await fetch(`${GITLAB_API_NFO}/commits?path=${encodeURIComponent('pack/' + gitlabTitle)}&per_page=1&ref_name=main`, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!res.ok) return null
|
||||
const data: any[] = await res.json()
|
||||
if (!data.length) return null
|
||||
return { sha: data[0].id, message: data[0].title ?? data[0].message ?? '' }
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
async function downloadNfoFolder(gitlabTitle: string, destRoot: string): Promise<void> {
|
||||
let files: any[] = [], page = 1
|
||||
while (true) {
|
||||
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
|
||||
if (!Array.isArray(batch) || batch.length === 0) break
|
||||
files.push(...batch); if (batch.length < 100) break; page++
|
||||
}
|
||||
let downloaded = 0, skipped = 0
|
||||
for (const entry of files.filter((f: any) => f.type === 'blob')) {
|
||||
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
||||
const destPath = path.join(destRoot, relativePath)
|
||||
try {
|
||||
const res = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!res.ok) { skipped++; continue }
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
fs.writeFileSync(destPath, Buffer.from(await res.arrayBuffer()))
|
||||
downloaded++
|
||||
} catch { skipped++ }
|
||||
}
|
||||
logger.info('nfo-update', `"${gitlabTitle}" — ${downloaded} fichiers mis à jour, ${skipped} ignorés`)
|
||||
}
|
||||
|
||||
async function checkNfoUpdates() {
|
||||
const { mediaPath, nfoSupport } = readSettings()
|
||||
if (!nfoSupport) return
|
||||
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 {}
|
||||
if (Object.keys(organized).length === 0) return
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const knownHashes = new Set(Object.keys(organized))
|
||||
const serieTitles = new Set<string>()
|
||||
for (const sd of seriesData) {
|
||||
const allTorrents = [...(sd.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => s.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => (s.episodes ?? []).flatMap((e: any) => e.torrents ?? []))]
|
||||
if (allTorrents.some((t: any) => knownHashes.has(t.infohash?.toLowerCase()))) {
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
if (rawTitle) serieTitles.add(rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim())
|
||||
}
|
||||
}
|
||||
if (serieTitles.size === 0) return
|
||||
const commits = loadNfoCommits()
|
||||
let hasChanges = false
|
||||
for (const serieTitle of serieTitles) {
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
const latest = await getLatestGitlabCommit(gitlabTitle)
|
||||
if (!latest || commits[gitlabTitle] === latest.sha) continue
|
||||
logger.info('nfo-update', `MAJ NFO détectée pour "${gitlabTitle}" (${latest.sha.slice(0, 8)})`)
|
||||
try {
|
||||
await downloadNfoFolder(gitlabTitle, path.join(mediaPath, serieTitle))
|
||||
commits[gitlabTitle] = latest.sha
|
||||
hasChanges = true
|
||||
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'updated' })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('nfo-update', `Échec MAJ NFO "${gitlabTitle}" : ${msg}`)
|
||||
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'error', error: msg })
|
||||
}
|
||||
if (recentNfoUpdates.length > MAX_NFO_NOTIFS) recentNfoUpdates.pop()
|
||||
}
|
||||
if (hasChanges) saveNfoCommits(commits)
|
||||
}
|
||||
|
||||
app.get('/api/nfo-updates/recent', requireAuth, (_req, res) => { res.json(recentNfoUpdates) })
|
||||
app.post('/api/nfo-updates/check', requireAuth, async (_req, res) => {
|
||||
logger.info('nfo-update', 'Vérification manuelle des MAJ NFO lancée')
|
||||
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif manuelle échouée : ${err instanceof Error ? err.message : err}`))
|
||||
res.json({ ok: true, message: 'Vérification lancée en arrière-plan' })
|
||||
})
|
||||
// ── Routes ─────────────────────────────────────────────────────
|
||||
app.use('/api', settingsRouter)
|
||||
app.use('/api', torrentClientsRouter)
|
||||
app.use('/api', seriesRouter)
|
||||
app.use('/api', downloadsRouter)
|
||||
app.use('/api', organizeRouter)
|
||||
app.use('/api', importRouter)
|
||||
app.use('/api', systemRouter)
|
||||
app.use('/api', nfoUpdatesRouter)
|
||||
app.use('/api', plexRouter)
|
||||
|
||||
// ── Catch-all SPA ──────────────────────────────────────────────
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
@@ -923,7 +99,10 @@ server.listen(PORT, async () => {
|
||||
} catch {}
|
||||
|
||||
loadEnrichedSeriesData()
|
||||
.then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData))
|
||||
.then(async seriesData => {
|
||||
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
|
||||
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
|
||||
})
|
||||
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
|
||||
|
||||
const autoOrganize = async () => {
|
||||
@@ -938,7 +117,6 @@ server.listen(PORT, async () => {
|
||||
if (result.done > 0 || result.errors > 0) {
|
||||
pushNotif({ ...result, at: new Date().toISOString() })
|
||||
}
|
||||
// Supprimer le torrent après move si option activée
|
||||
const { organizeMode, deleteTorrentOnMove } = readSettings()
|
||||
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
|
||||
try {
|
||||
@@ -966,4 +144,21 @@ server.listen(PORT, async () => {
|
||||
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif horaire échouée : ${err instanceof Error ? err.message : err}`))
|
||||
}, 60 * 60_000)
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
// Sync automatique des noms de fichiers toutes les heures.
|
||||
// Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches.
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
||||
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
if (updated > 0)
|
||||
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`)
|
||||
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
|
||||
if (renamed > 0)
|
||||
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
|
||||
} catch (err) {
|
||||
logger.error('api', `Sync noms auto échoué : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}, 60 * 60_000)
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
const GITHUB_BASE = process.env.GITHUB_BASE
|
||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
||||
|
||||
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
|
||||
|
||||
interface CacheEntry<T> { data: T; expiresAt: number }
|
||||
const _cache = new Map<string, CacheEntry<any>>()
|
||||
|
||||
export function cacheGet<T>(key: string): T | null {
|
||||
const entry = _cache.get(key)
|
||||
if (!entry) return null
|
||||
if (Date.now() > entry.expiresAt) { _cache.delete(key); return null }
|
||||
return entry.data as T
|
||||
}
|
||||
|
||||
export function cacheSet<T>(key: string, data: T): void {
|
||||
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
|
||||
}
|
||||
|
||||
export function cacheClear(): void { _cache.clear() }
|
||||
|
||||
export function cacheSize(): number { return _cache.size }
|
||||
|
||||
export async function githubGet(urlPath: string): Promise<any> {
|
||||
const cached = cacheGet(urlPath)
|
||||
if (cached) return cached
|
||||
const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
|
||||
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
|
||||
const data = await res.json()
|
||||
cacheSet(urlPath, data)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function readAvailable(): Promise<number[]> {
|
||||
try { return await githubGet('available.json') as number[] }
|
||||
catch { return [] }
|
||||
}
|
||||
|
||||
export async function readInfohashMap(): Promise<Record<string, string>> {
|
||||
try { return await githubGet('infohash_map.json') as Record<string, string> }
|
||||
catch { return {} }
|
||||
}
|
||||
|
||||
export async function readSerieData(serieId: number): Promise<any | null> {
|
||||
try { return await githubGet(`series/${serieId}.json`) }
|
||||
catch { return null }
|
||||
}
|
||||
|
||||
export async function loadEnrichedSeriesData(): Promise<any[]> {
|
||||
const ids = await readAvailable()
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
|
||||
return allSd.filter(Boolean)
|
||||
}
|
||||
|
||||
export function loadOrganized(): Record<string, Record<string, any>> {
|
||||
try {
|
||||
const p = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { getGitlabTitle } from '../gitlab-map.js'
|
||||
import { loadEnrichedSeriesData, loadOrganized } from './github-cache.js'
|
||||
|
||||
export const GITLAB_API_NFO = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
|
||||
export const GITLAB_RAW_NFO = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
|
||||
|
||||
export interface NfoUpdateNotif {
|
||||
serieTitle: string; commitSha: string; commitMsg: string
|
||||
updatedAt: string; status: 'updated' | 'error'; error?: string
|
||||
}
|
||||
|
||||
export const recentNfoUpdates: NfoUpdateNotif[] = []
|
||||
const MAX_NFO_NOTIFS = 30
|
||||
const NFO_COMMITS_PATH = path.join(DATA_DIR, 'nfo_commits.json')
|
||||
|
||||
export function loadNfoCommits(): Record<string, string> {
|
||||
try { if (!fs.existsSync(NFO_COMMITS_PATH)) return {}; return JSON.parse(fs.readFileSync(NFO_COMMITS_PATH, 'utf-8')) }
|
||||
catch { return {} }
|
||||
}
|
||||
|
||||
export function saveNfoCommits(commits: Record<string, string>) {
|
||||
fs.mkdirSync(path.dirname(NFO_COMMITS_PATH), { recursive: true })
|
||||
fs.writeFileSync(NFO_COMMITS_PATH, JSON.stringify(commits, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export async function getLatestGitlabCommit(gitlabTitle: string): Promise<{ sha: string; message: string } | null> {
|
||||
try {
|
||||
const res = await fetch(`${GITLAB_API_NFO}/commits?path=${encodeURIComponent('pack/' + gitlabTitle)}&per_page=1&ref_name=main`, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!res.ok) return null
|
||||
const data: any[] = await res.json()
|
||||
if (!data.length) return null
|
||||
return { sha: data[0].id, message: data[0].title ?? data[0].message ?? '' }
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export async function downloadNfoFolder(gitlabTitle: string, destRoot: string): Promise<void> {
|
||||
let files: any[] = [], page = 1
|
||||
while (true) {
|
||||
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
|
||||
if (!Array.isArray(batch) || batch.length === 0) break
|
||||
files.push(...batch); if (batch.length < 100) break; page++
|
||||
}
|
||||
let downloaded = 0, skipped = 0
|
||||
for (const entry of files.filter((f: any) => f.type === 'blob')) {
|
||||
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
||||
const destPath = path.join(destRoot, relativePath)
|
||||
try {
|
||||
const res = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!res.ok) { skipped++; continue }
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
fs.writeFileSync(destPath, Buffer.from(await res.arrayBuffer()))
|
||||
downloaded++
|
||||
} catch { skipped++ }
|
||||
}
|
||||
logger.info('nfo-update', `"${gitlabTitle}" — ${downloaded} fichiers mis à jour, ${skipped} ignorés`)
|
||||
}
|
||||
|
||||
export async function checkNfoUpdates() {
|
||||
const { mediaPath, nfoSupport } = readSettings()
|
||||
if (!nfoSupport) return
|
||||
const organized = loadOrganized()
|
||||
if (Object.keys(organized).length === 0) return
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const knownHashes = new Set(Object.keys(organized))
|
||||
const serieTitles = new Set<string>()
|
||||
for (const sd of seriesData) {
|
||||
const allTorrents = [...(sd.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => s.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => (s.episodes ?? []).flatMap((e: any) => e.torrents ?? []))]
|
||||
if (allTorrents.some((t: any) => knownHashes.has(t.infohash?.toLowerCase()))) {
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
if (rawTitle) serieTitles.add(rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim())
|
||||
}
|
||||
}
|
||||
if (serieTitles.size === 0) return
|
||||
const commits = loadNfoCommits()
|
||||
let hasChanges = false
|
||||
for (const serieTitle of serieTitles) {
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
const latest = await getLatestGitlabCommit(gitlabTitle)
|
||||
if (!latest || commits[gitlabTitle] === latest.sha) continue
|
||||
logger.info('nfo-update', `MAJ NFO détectée pour "${gitlabTitle}" (${latest.sha.slice(0, 8)})`)
|
||||
try {
|
||||
await downloadNfoFolder(gitlabTitle, path.join(mediaPath, serieTitle))
|
||||
commits[gitlabTitle] = latest.sha
|
||||
hasChanges = true
|
||||
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'updated' })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('nfo-update', `Échec MAJ NFO "${gitlabTitle}" : ${msg}`)
|
||||
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'error', error: msg })
|
||||
}
|
||||
if (recentNfoUpdates.length > MAX_NFO_NOTIFS) recentNfoUpdates.pop()
|
||||
}
|
||||
if (hasChanges) saveNfoCommits(commits)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface OrganizeNotif {
|
||||
hash: string; name: string; done: number; skipped: number; errors: number
|
||||
errorFiles: { file: string; error: string }[]; at: string
|
||||
}
|
||||
|
||||
export const recentOrganized: OrganizeNotif[] = []
|
||||
const MAX_NOTIFS = 20
|
||||
|
||||
export function pushNotif(n: OrganizeNotif) {
|
||||
recentOrganized.unshift(n)
|
||||
if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import path from 'path'
|
||||
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
|
||||
export async function fankaiGet(endpoint: string): Promise<any> {
|
||||
const res = await fetch(`${FANKAI_API}${endpoint}`)
|
||||
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
|
||||
if (!url) return null
|
||||
const stripped = url.replace(/^https?:\/\//, '')
|
||||
return `https://wsrv.nl/?url=${stripped}&w=${w}&q=${q}`
|
||||
}
|
||||
|
||||
export function normalizeSerie(serie: any): any {
|
||||
return { ...serie, poster_image: imgProxy(serie.poster_image ?? serie.images?.poster ?? null, 300), fanart_image: imgProxy(serie.fanart_image ?? serie.images?.fanart ?? null, 1280, 60) }
|
||||
}
|
||||
|
||||
export function normalizeSeason(season: any): any {
|
||||
return { ...season, poster_image: imgProxy(season.poster_image ?? season.images?.poster ?? null, 300) }
|
||||
}
|
||||
|
||||
export function normalizeEpisode(ep: any): any {
|
||||
return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 400) }
|
||||
}
|
||||
|
||||
export function extractTorrentsFromSerieData(sd: any): any[] {
|
||||
const result: any[] = []
|
||||
if (!sd) return result
|
||||
for (const t of sd.torrents ?? []) {
|
||||
result.push({ ...t, raw: t.title, type: 'pack_integrale', serie_id: sd.id, serie_title: sd.title, _serieLevel: true, _serieData: sd, _torrentIdx: (sd.torrents ?? []).indexOf(t) })
|
||||
}
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
result.push({ ...t, raw: t.title, type: 'pack_saison', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, _seasonLevel: true, _season: season, _torrentIdx: (season.torrents ?? []).indexOf(t) })
|
||||
}
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
result.push({ ...t, raw: t.title, type: 'episode', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, episode_id: ep.id, episode_number: ep.episode_number, _episodeLevel: true, _episode: ep, _torrentIdx: (ep.torrents ?? []).indexOf(t) })
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout les noms d'un épisode en préférant les valeurs du path entry
|
||||
* correspondant au hash donné (spécifique au torrent/encoding),
|
||||
* avec fallback sur les champs de niveau épisode.
|
||||
*
|
||||
* Utilisation :
|
||||
* const { formatted_name, nfo_filename, original_filename } = resolveEpNaming(ep, hash)
|
||||
*/
|
||||
export function resolveEpNaming(ep: any, hash?: string | null): {
|
||||
formatted_name : string | null
|
||||
nfo_filename : string | null
|
||||
original_filename: string | null
|
||||
} {
|
||||
const h = hash?.toLowerCase() ?? null
|
||||
const pathEntry = h
|
||||
? (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === h)
|
||||
: null
|
||||
return {
|
||||
formatted_name : pathEntry?.formatted_name ?? ep.formatted_name ?? null,
|
||||
nfo_filename : pathEntry?.nfo_filename ?? ep.nfo_filename ?? null,
|
||||
original_filename: pathEntry?.original_filename ?? ep.original_filename ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le nom attendu d'un fichier épisode selon le mode (NFO ou formaté).
|
||||
* Source unique de vérité partagée entre organized-summary, organized/:id, rename-episode et rename-all.
|
||||
*/
|
||||
export function computeExpectedName(
|
||||
ep : any,
|
||||
orgEntry: any,
|
||||
hash : string | null,
|
||||
nfoSupport: boolean,
|
||||
): { currentName: string; expectedName: string; needsRename: boolean } {
|
||||
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
|
||||
const srcExt = path.extname(currentName)
|
||||
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, hash)
|
||||
const expectedName = nfoSupport
|
||||
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
|
||||
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
|
||||
return { currentName, expectedName, needsRename: expectedName !== currentName }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dédoublonne une liste d'épisodes par episode_number.
|
||||
* Quand plusieurs variantes existent (ex. x264 + x265), on garde la première
|
||||
* comme épisode principal et on fusionne les torrents des autres dans sa liste.
|
||||
*/
|
||||
export function deduplicateEpisodes(episodes: any[]): any[] {
|
||||
const map = new Map<number, any>()
|
||||
for (const ep of episodes) {
|
||||
const key = ep.episode_number
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { ...ep, torrents: [...(ep.torrents ?? [])] })
|
||||
} else {
|
||||
const primary = map.get(key)!
|
||||
const seenHashes = new Set(primary.torrents.map((t: any) => t.infohash).filter(Boolean))
|
||||
for (const t of ep.torrents ?? []) {
|
||||
if (!t.infohash || !seenHashes.has(t.infohash)) {
|
||||
primary.torrents.push(t)
|
||||
if (t.infohash) seenHashes.add(t.infohash)
|
||||
}
|
||||
}
|
||||
primary.torrent = primary.torrents[0] ?? null
|
||||
primary.available = primary.available || ep.available
|
||||
primary.organized = primary.organized || ep.organized
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] {
|
||||
const resolved: any[] = []
|
||||
const h = hash.toLowerCase()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const paths: any[] = ep.paths ?? []
|
||||
let filePath: string | null = null
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
|
||||
else if (p?.infohash?.toLowerCase() === h) { filePath = p.path.replace(/\\/g, '/'); break }
|
||||
}
|
||||
if (!filePath) continue
|
||||
const filename = filePath.split('/').pop() ?? filePath
|
||||
resolved.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, season_id: season.id, filename, original_filename: filename })
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, any>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
if (!serieData) return 'none'
|
||||
const allTorrents = extractTorrentsFromSerieData(serieData)
|
||||
|
||||
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
|
||||
|
||||
// Dédoublonnage : épisodes uniques par (season_number, episode_number)
|
||||
// pour éviter de compter x264 + x265 comme deux épisodes distincts
|
||||
const uniqueEpKeys = new Set<string>()
|
||||
const epIdToKey = new Map<number, string>()
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const key = `${season.season_number}:${ep.episode_number}`
|
||||
uniqueEpKeys.add(key)
|
||||
epIdToKey.set(ep.id, key)
|
||||
}
|
||||
}
|
||||
|
||||
const organizedEpKeys = new Set<string>()
|
||||
|
||||
for (const t of allTorrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (!hash) continue
|
||||
const orgFiles = organized[hash] ?? {}
|
||||
if (Object.keys(orgFiles).length === 0) continue
|
||||
for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) {
|
||||
const isOrganized = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
||||
if (isOrganized) {
|
||||
const key = epIdToKey.get(ep.episode_id)
|
||||
if (key) organizedEpKeys.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manualOrg = organized['manual'] ?? {}
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (manualOrg[String(ep.id)]) {
|
||||
const key = epIdToKey.get(ep.id)
|
||||
if (key) organizedEpKeys.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none'
|
||||
if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete'
|
||||
return 'partial'
|
||||
}
|
||||
@@ -113,13 +113,15 @@ function buildFileMap(
|
||||
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const paths: any[] = ep.paths ?? []
|
||||
let matchedPath: string | null = null
|
||||
let matchedPath : string | null = null
|
||||
let matchedPathObj: any = null
|
||||
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') {
|
||||
if (!matchedPath) matchedPath = p.replace(/\\/g, '/')
|
||||
} else if (p?.infohash?.toLowerCase() === h) {
|
||||
matchedPath = p.path.replace(/\\/g, '/')
|
||||
matchedPath = p.path.replace(/\\/g, '/')
|
||||
matchedPathObj = p
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -127,8 +129,12 @@ function buildFileMap(
|
||||
if (!matchedPath) continue
|
||||
const filename = matchedPath.split('/').pop() ?? matchedPath
|
||||
|
||||
const fmtName = ep.formatted_name?.trim()
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
// Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
|
||||
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
|
||||
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
|
||||
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
|
||||
const fmtName = rawFmt?.trim()
|
||||
? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: null
|
||||
|
||||
if (!nfoSupport && !fmtName) {
|
||||
@@ -137,8 +143,8 @@ function buildFileMap(
|
||||
|
||||
const srcExt = path.extname(filename)
|
||||
const resolvedName = nfoSupport
|
||||
? (ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
? (rawNfo ?? rawOriginal ?? filename)
|
||||
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
|
||||
const destName = swapExtension(resolvedName, srcExt)
|
||||
|
||||
map.set(filename, {
|
||||
@@ -247,6 +253,26 @@ function seasonFolder(n: number): string {
|
||||
return n === 0 ? 'Specials' : `Saison ${n}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un fichier avec retry en cas d'EBUSY (Windows : fichier encore ouvert par qBit/autre).
|
||||
* 5 tentatives × 500 ms = 2,5 s max avant de remonter l'erreur.
|
||||
*/
|
||||
async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void> {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
await fsp.unlink(filePath)
|
||||
return
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'EBUSY' && attempt < maxAttempts - 1) {
|
||||
warn(`EBUSY sur "${path.basename(filePath)}" — retry ${attempt + 1}/${maxAttempts - 1}`)
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryHardlink(src: string, dest: string): boolean {
|
||||
try { fs.linkSync(src, dest); return true }
|
||||
catch (err) {
|
||||
@@ -264,7 +290,13 @@ function sanitizeDirName(name: string): string {
|
||||
}
|
||||
|
||||
// ─── Organise un torrent ──────────────────────────────────────
|
||||
async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) {
|
||||
async function organizeTorrent(
|
||||
hash : string,
|
||||
name : string,
|
||||
savePath : string,
|
||||
seriesData : any[],
|
||||
completedFileNames: Set<string> | null = null,
|
||||
) {
|
||||
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
|
||||
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
|
||||
|
||||
@@ -289,20 +321,29 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
const ep = torrent._episode
|
||||
const season = torrent._season
|
||||
const paths: any[] = ep.paths ?? []
|
||||
let filePath: string | null = null
|
||||
let filePath : string | null = null
|
||||
let matchedPathObj: any = null
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
|
||||
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break }
|
||||
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) {
|
||||
filePath = p.path.replace(/\\/g, '/')
|
||||
matchedPathObj = p
|
||||
break
|
||||
}
|
||||
}
|
||||
const filename = filePath?.split('/').pop() ?? name
|
||||
const fmtName = ep.formatted_name?.trim()
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
// Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
|
||||
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
|
||||
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
|
||||
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
|
||||
const fmtName = rawFmt?.trim()
|
||||
? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: null
|
||||
|
||||
const srcExt = path.extname(filename)
|
||||
const resolvedName = nfoSupport
|
||||
? (ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
? (rawNfo ?? rawOriginal ?? filename)
|
||||
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
|
||||
const destName = swapExtension(resolvedName, srcExt)
|
||||
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||
@@ -310,6 +351,12 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
|
||||
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
|
||||
|
||||
// Vérification de complétion pour épisode unique
|
||||
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
|
||||
debug(`Fichier incomplet (skip) : ${filename}`)
|
||||
return { ...result, total: 1, skipped: 1 }
|
||||
}
|
||||
|
||||
const candidates = filePath ? [
|
||||
path.join(savePath, filePath),
|
||||
path.join(completePath, filePath),
|
||||
@@ -341,7 +388,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
episode: ep.episode_number, episode_id: ep.id,
|
||||
src_filename: filename, dest_filename: destName, dest_path: dest,
|
||||
})
|
||||
await fsp.unlink(src)
|
||||
await unlinkWithRetry(src)
|
||||
log(`${destName} → Saison ${season.season_number}`)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
}
|
||||
@@ -370,6 +417,14 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) {
|
||||
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
|
||||
|
||||
// Vérification de complétion : si on a les infos de progression par fichier,
|
||||
// on skip les fichiers pas encore à 100% (ex: 1% téléchargé avant priorité appliquée).
|
||||
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
|
||||
debug(`Fichier incomplet (skip) : ${filename}`)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
path.join(savePath, fullPath),
|
||||
path.join(completePath, fullPath),
|
||||
@@ -380,8 +435,10 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
||||
|
||||
if (!src) {
|
||||
error(`Fichier source introuvable : ${filename}`)
|
||||
result.errors.push({ file: filename, error: 'Source introuvable' })
|
||||
// Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0).
|
||||
// On skip silencieusement — pas une erreur.
|
||||
debug(`Fichier non disponible (skip) : ${filename}`)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -416,7 +473,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
episode: episode_number, episode_id,
|
||||
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
|
||||
})
|
||||
await fsp.unlink(src)
|
||||
await unlinkWithRetry(src)
|
||||
}
|
||||
result.done++
|
||||
debug(`${nfo_filename} → Saison ${season_number}`)
|
||||
@@ -462,7 +519,29 @@ parentPort?.on('message', async (msg: any) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData)
|
||||
// Construire l'ensemble des fichiers 100% complets à partir des infos de progression.
|
||||
// On ne filtre QUE si le client fournit des noms de fichiers (qBittorrent, Transmission).
|
||||
// Si aucun fichier n'a de nom (rtorrent…), on passe null → pas de filtrage.
|
||||
let completedFileNames: Set<string> | null = null
|
||||
if (Array.isArray(t.files) && t.files.length > 0) {
|
||||
// Vérifier qu'au moins un fichier a un nom non vide
|
||||
const hasNames = t.files.some((f: any) => f.name && String(f.name).trim())
|
||||
if (hasNames) {
|
||||
completedFileNames = new Set(
|
||||
t.files
|
||||
.filter((f: any) => (f.progress ?? 0) >= 1 && f.name && String(f.name).trim())
|
||||
.map((f: any) => {
|
||||
// f.name est un chemin relatif ex: "Pack S1/Episode1.mkv"
|
||||
// on ne garde que le basename pour comparer au filename du fileMap
|
||||
const parts = String(f.name).replace(/\\/g, '/').split('/')
|
||||
return (parts[parts.length - 1] ?? '').toLowerCase()
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
|
||||
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
|
||||
} catch (err) {
|
||||
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
|
||||
|
||||
@@ -100,10 +100,17 @@ export async function scanMediaPath(
|
||||
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
|
||||
if (!srcFilename) continue
|
||||
idx(srcFilename, hash)
|
||||
if (ep.nfo_filename) idx(ep.nfo_filename, hash, ep.nfo_filename)
|
||||
// nfo_filename et formatted_name du path en priorité sur ceux de l'épisode
|
||||
const pathNfo = p.nfo_filename ?? ep.nfo_filename
|
||||
if (pathNfo) idx(pathNfo, hash, pathNfo)
|
||||
const pathFmt = p.formatted_name ?? ep.formatted_name
|
||||
if (pathFmt?.trim()) {
|
||||
const fmtBase = pathFmt.replace(/[<>:"/\\|?*]/g, '').trim()
|
||||
idx(fmtBase, hash, fmtBase)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. original_filename, nfo_filename et formatted_name — indexés même si paths est vide
|
||||
// 2. original_filename, nfo_filename et formatted_name épisode — fallback
|
||||
const fallbackHash = ep.paths?.[0]?.infohash?.toLowerCase() ?? 'manual'
|
||||
if (ep.original_filename) idx(ep.original_filename, fallbackHash, ep.original_filename)
|
||||
if (ep.nfo_filename) idx(ep.nfo_filename, fallbackHash, ep.nfo_filename)
|
||||
@@ -160,10 +167,6 @@ export async function scanMediaPath(
|
||||
}
|
||||
|
||||
const { hash, srcFilename, episodeId, season, episode } = match
|
||||
// Le destFilename prend l'extension réelle du fichier sur le disque
|
||||
const realExt = path.extname(entry.name)
|
||||
const destBase = match.destFilename.replace(/\.[^.]+$/, '')
|
||||
const destFilename = destBase + realExt
|
||||
presentFiles.add(`${hash}:${episodeId}`)
|
||||
|
||||
if (organized[hash]?.[String(episodeId)]) continue
|
||||
@@ -175,7 +178,7 @@ export async function scanMediaPath(
|
||||
episode,
|
||||
episode_id : episodeId,
|
||||
src_filename : srcFilename,
|
||||
dest_filename: destFilename,
|
||||
dest_filename: entry.name, // vrai nom sur disque
|
||||
dest_path : full,
|
||||
}
|
||||
result.added++
|
||||
@@ -200,11 +203,28 @@ export async function scanMediaPath(
|
||||
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
||||
}
|
||||
|
||||
if (result.added > 0 || removed > 0) {
|
||||
// Désimport automatique des fichiers manquants sur le disque
|
||||
const { autoUnimportMissing } = readSettings() as any
|
||||
let autoRemoved = 0
|
||||
if (autoUnimportMissing) {
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
|
||||
const destPath = entry?.dest_path
|
||||
if (destPath && !fs.existsSync(destPath)) {
|
||||
delete (organized[hash] as any)[episodeId]
|
||||
autoRemoved++
|
||||
logger.info('organize', `Désimport auto : fichier introuvable "${destPath}"`)
|
||||
}
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
||||
}
|
||||
}
|
||||
|
||||
if (result.added > 0 || removed > 0 || autoRemoved > 0) {
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
|
||||
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -314,6 +334,184 @@ export async function autoOrganizeAll(
|
||||
worker.postMessage({ type: 'run', torrents, seriesData })
|
||||
}
|
||||
|
||||
// ── Migration des IDs épisodes ────────────────────────────────
|
||||
//
|
||||
// Les IDs d'épisodes, saisons et séries peuvent changer entre deux passages
|
||||
// du scraper. On utilise (infohash, season_number, episode_number) comme clé
|
||||
// stable pour retrouver le nouvel ID et mettre à jour organized.json.
|
||||
|
||||
export async function migrateOrganizedEpisodeIds(
|
||||
organizedPath: string,
|
||||
seriesData : any[]
|
||||
): Promise<{ updated: number; orphaned: number }> {
|
||||
|
||||
// 1. Construire les tables de lookup depuis le catalogue actuel
|
||||
// hashEpMap : infohash → Map<"S:E", new_episode_id>
|
||||
const hashEpMap = new Map<string, Map<string, number>>()
|
||||
const validIds = new Set<number>()
|
||||
|
||||
for (const sd of seriesData) {
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
validIds.add(ep.id)
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p !== 'object' || !p.infohash) continue
|
||||
const h = p.infohash.toLowerCase()
|
||||
const key = `${season.season_number}:${ep.episode_number}`
|
||||
if (!hashEpMap.has(h)) hashEpMap.set(h, new Map())
|
||||
hashEpMap.get(h)!.set(key, ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Charger organized.json
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch { return { updated: 0, orphaned: 0 } }
|
||||
|
||||
let updated = 0
|
||||
let orphaned = 0
|
||||
let changed = false
|
||||
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
if (hash === 'manual') continue
|
||||
const epMap = hashEpMap.get(hash.toLowerCase())
|
||||
|
||||
const newEpisodes: Record<string, any> = {}
|
||||
for (const [oldIdStr, entry] of Object.entries(episodes as Record<string, any>)) {
|
||||
const oldId = Number(oldIdStr)
|
||||
|
||||
if (validIds.has(oldId)) {
|
||||
// ID encore valide, aucun changement
|
||||
newEpisodes[oldIdStr] = entry
|
||||
continue
|
||||
}
|
||||
|
||||
// ID orphelin — chercher le nouvel ID via (hash, saison, épisode)
|
||||
const key = `${entry.season}:${entry.episode}`
|
||||
const newId = epMap?.get(key)
|
||||
|
||||
if (newId && newId !== oldId) {
|
||||
// Vérifier qu'on n'écrase pas une entrée déjà présente avec le nouvel ID
|
||||
if (newEpisodes[String(newId)] || (episodes as any)[String(newId)]) {
|
||||
// Le nouvel ID est déjà présent → l'ancien est un orphelin inutile, on le supprime simplement
|
||||
changed = true
|
||||
logger.info('organize', `Migration ID : ep ${oldId} orphelin supprimé (${newId} déjà présent) (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
|
||||
} else {
|
||||
newEpisodes[String(newId)] = { ...entry, episode_id: newId }
|
||||
updated++
|
||||
changed = true
|
||||
logger.info('organize', `Migration ID : ep ${oldId} → ${newId} (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
|
||||
}
|
||||
} else {
|
||||
// Introuvable — on conserve pour ne pas perdre l'entrée
|
||||
orphaned++
|
||||
newEpisodes[oldIdStr] = entry
|
||||
logger.warn('organize', `Migration ID : ep ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
|
||||
}
|
||||
}
|
||||
|
||||
organized[hash] = newEpisodes
|
||||
// Nettoyer les hash vides
|
||||
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`)
|
||||
} else {
|
||||
logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`)
|
||||
}
|
||||
|
||||
return { updated, orphaned }
|
||||
}
|
||||
|
||||
// ── Sync des noms de fichiers après mise à jour du scraper ────
|
||||
|
||||
export async function syncFilenameChanges(
|
||||
seriesData : any[],
|
||||
organizedPath: string,
|
||||
): Promise<{ renamed: number; errors: number }> {
|
||||
const { nfoSupport } = readSettings()
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch { return { renamed: 0, errors: 0 } }
|
||||
|
||||
let renamed = 0
|
||||
let errors = 0
|
||||
let changed = false
|
||||
|
||||
for (const sd of seriesData) {
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
let orgEntry: any = null
|
||||
let orgHash: string | null = null
|
||||
for (const [hash, eps] of Object.entries(organized)) {
|
||||
if ((eps as any)[String(ep.id)]) {
|
||||
orgEntry = (eps as any)[String(ep.id)]
|
||||
orgHash = hash
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!orgEntry || !orgHash) continue
|
||||
|
||||
const currentName = orgEntry.dest_path
|
||||
? path.basename(orgEntry.dest_path)
|
||||
: orgEntry.dest_filename
|
||||
const srcExt = path.extname(currentName)
|
||||
// Priorité aux valeurs du path entry correspondant au hash organisé
|
||||
const matchedPathEntry = (ep.paths ?? []).find((p: any) =>
|
||||
typeof p === 'object' && p.infohash?.toLowerCase() === orgHash
|
||||
)
|
||||
const rawFmt = matchedPathEntry?.formatted_name ?? ep.formatted_name
|
||||
const rawNfo = matchedPathEntry?.nfo_filename ?? ep.nfo_filename
|
||||
const expectedName: string = nfoSupport
|
||||
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
|
||||
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
|
||||
|
||||
if (expectedName === currentName) continue
|
||||
|
||||
const oldPath = orgEntry.dest_path
|
||||
if (!oldPath || !fs.existsSync(oldPath)) continue
|
||||
|
||||
const newPath = path.join(path.dirname(oldPath), expectedName)
|
||||
try {
|
||||
if (fs.existsSync(newPath)) {
|
||||
logger.warn('organize', `Sync rename : "${expectedName}" existe déjà — skip`)
|
||||
continue
|
||||
}
|
||||
fs.renameSync(oldPath, newPath)
|
||||
;(organized[orgHash] as any)[String(ep.id)] = {
|
||||
...orgEntry,
|
||||
dest_filename: expectedName,
|
||||
dest_path : newPath,
|
||||
at : new Date().toISOString(),
|
||||
}
|
||||
renamed++
|
||||
changed = true
|
||||
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
|
||||
} catch (err) {
|
||||
errors++
|
||||
logger.error('organize', `Sync rename échoué pour ep ${ep.id} : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
|
||||
if (renamed > 0 || errors > 0)
|
||||
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
|
||||
|
||||
return { renamed, errors }
|
||||
}
|
||||
|
||||
// ── Import manuel ─────────────────────────────────────────────
|
||||
|
||||
export async function organizeTorrent(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { dispatchDownload, dispatchList, dispatchRemove } from '../torrent-clients/index.js'
|
||||
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
|
||||
import { extractTorrentsFromSerieData, buildResolvedEpisodes } from '../lib/serie-helpers.js'
|
||||
import { recentOrganized } from '../lib/notifs.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/download', requireAuth, async (req, res) => {
|
||||
// Préférer le fichier .torrent au magnet : métadonnées immédiates, pas de dépendance DHT/trackers
|
||||
const torrent_url: string | null = req.body.torrent_url ?? null
|
||||
const magnet : string | null = req.body.magnet ?? null
|
||||
const url = torrent_url ?? magnet
|
||||
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
|
||||
const file_index: number | null = req.body.file_index != null ? Number(req.body.file_index) : null
|
||||
const file_path : string | null = req.body.file_path ?? null
|
||||
const infohash : string | null = req.body.infohash ?? null
|
||||
logger.info('api', `Téléchargement demandé via ${torrent_url ? '.torrent' : 'magnet'}${file_path ? ` (${file_path})` : ''}`)
|
||||
const results = await dispatchDownload(url, { file_index, file_path, infohash, magnet })
|
||||
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
|
||||
})
|
||||
|
||||
router.delete('/torrent/:hash', requireAuth, async (req, res) => {
|
||||
const hash = String(req.params.hash)
|
||||
const deleteFiles = req.query.deleteFiles === 'true'
|
||||
if (!hash) { res.status(400).json({ error: 'hash requis' }); return }
|
||||
try {
|
||||
const result = await dispatchRemove(hash, deleteFiles)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
logger.error('api', `Suppression torrent échouée : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/downloads', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const infohashMap = await readInfohashMap()
|
||||
const torrents = await dispatchList(category ?? 'fankai', infohashMap)
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const enriched = await Promise.all(torrents.map(async (t: any) => {
|
||||
const orgFiles = organized[t.hash] ?? {}
|
||||
let totalFiles = 1
|
||||
try {
|
||||
const availableIds = await readAvailable()
|
||||
for (const id of availableIds) {
|
||||
const sd = await readSerieData(id)
|
||||
if (!sd) continue
|
||||
const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
|
||||
if (match) { totalFiles = buildResolvedEpisodes(sd, match.infohash, match.season_number).length || 1; break }
|
||||
}
|
||||
} catch {}
|
||||
const doneFiles = Object.keys(orgFiles).length
|
||||
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
|
||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [] }
|
||||
}))
|
||||
res.json(enriched)
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,451 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { getGitlabTitle } from '../gitlab-map.js'
|
||||
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js'
|
||||
import { resolveEpNaming, computeExpectedName } from '../lib/serie-helpers.js'
|
||||
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
|
||||
import { dispatchRemove } from '../torrent-clients/index.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
|
||||
function loadOrganizedJson(): Record<string, Record<string, any>> {
|
||||
try { if (fs.existsSync(ORGANIZED_PATH)) return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
|
||||
return {}
|
||||
}
|
||||
|
||||
function saveOrganizedJson(data: Record<string, Record<string, any>>) {
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
// ── Import manuel ──────────────────────────────────────────────
|
||||
router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
const { serie_id, items } = req.body
|
||||
if (!serie_id || !Array.isArray(items) || items.length === 0) {
|
||||
res.status(400).json({ error: 'serie_id et items requis' }); return
|
||||
}
|
||||
const { mediaPath, organizeMode, nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serie_id))
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return }
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
const episodeIndex = new Map<number, { ep: any; season: any }>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
episodeIndex.set(ep.id, { ep, season })
|
||||
}
|
||||
}
|
||||
const done: number[] = []
|
||||
const errors: { file: string; error: string }[] = []
|
||||
for (const item of items) {
|
||||
const { file_path, episode_id, hash } = item
|
||||
if (!file_path || !episode_id) { errors.push({ file: file_path ?? '?', error: 'Paramètres manquants' }); continue }
|
||||
const found = episodeIndex.get(Number(episode_id))
|
||||
if (!found) { errors.push({ file: file_path, error: `Épisode ${episode_id} introuvable` }); continue }
|
||||
const { ep, season } = found
|
||||
const srcFilename = path.basename(file_path)
|
||||
const srcExt = path.extname(srcFilename)
|
||||
const { formatted_name: rawFmt, nfo_filename: rawNfo, original_filename: rawOrig } = resolveEpNaming(ep, hash)
|
||||
const fmtName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null
|
||||
const resolvedName = nfoSupport
|
||||
? (rawNfo ?? rawOrig ?? srcFilename)
|
||||
: (fmtName ?? rawNfo ?? rawOrig ?? srcFilename)
|
||||
const resolvedExt = path.extname(resolvedName)
|
||||
const destFilename = resolvedExt && resolvedExt !== srcExt
|
||||
? resolvedName.slice(0, -resolvedExt.length) + srcExt
|
||||
: resolvedName
|
||||
const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}`
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
|
||||
const destPath = path.join(destDir, destFilename)
|
||||
try {
|
||||
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (file_path !== destPath) {
|
||||
const serieRootPath = path.join(mediaPath, serieTitle)
|
||||
const isInSeriePath = file_path.startsWith(serieRootPath + path.sep) || file_path.startsWith(serieRootPath + '/')
|
||||
if (isInSeriePath && !fs.existsSync(destPath)) {
|
||||
fs.renameSync(file_path, destPath)
|
||||
logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
|
||||
} else if (!isInSeriePath && !fs.existsSync(destPath)) {
|
||||
if (organizeMode === 'hardlink') {
|
||||
try { fs.linkSync(file_path, destPath) }
|
||||
catch { await fs.promises.copyFile(file_path, destPath) }
|
||||
} else if (organizeMode === 'move') {
|
||||
try { fs.renameSync(file_path, destPath) }
|
||||
catch { await fs.promises.copyFile(file_path, destPath); await fs.promises.unlink(file_path) }
|
||||
} else {
|
||||
await fs.promises.copyFile(file_path, destPath)
|
||||
}
|
||||
logger.info('api', `Import manuel : "${srcFilename}" → "${destPath}"`)
|
||||
}
|
||||
} else {
|
||||
logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`)
|
||||
}
|
||||
const torrentHash = String(hash || '').toLowerCase() || 'manual'
|
||||
if (!organized[torrentHash]) organized[torrentHash] = {}
|
||||
organized[torrentHash][String(episode_id)] = {
|
||||
at: new Date().toISOString(), season: season.season_number,
|
||||
episode: ep.episode_number, episode_id: ep.id,
|
||||
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
|
||||
}
|
||||
done.push(episode_id)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
errors.push({ file: srcFilename, error: msg })
|
||||
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
|
||||
}
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
|
||||
if (nfoSupport && done.length > 0) {
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
;(async () => {
|
||||
try {
|
||||
let files: any[] = [], page = 1
|
||||
while (true) {
|
||||
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
|
||||
if (!Array.isArray(batch) || batch.length === 0) break
|
||||
files.push(...batch); if (batch.length < 100) break; page++
|
||||
}
|
||||
for (const entry of files.filter((f: any) => f.type === 'blob')) {
|
||||
const rel = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
||||
const dest = path.join(mediaPath, serieTitle, rel)
|
||||
if (fs.existsSync(dest)) continue
|
||||
const raw = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${rel.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!raw.ok) continue
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
||||
fs.writeFileSync(dest, Buffer.from(await raw.arrayBuffer()))
|
||||
}
|
||||
logger.info('api', `NFO téléchargés pour "${gitlabTitle}" (import manuel)`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Échec téléchargement NFO pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
res.json({ ok: true, done: done.length, errors })
|
||||
})
|
||||
|
||||
// ── Récap organisés d'une série ────────────────────────────────
|
||||
router.get('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
const serieId = Number(req.params.serieId)
|
||||
try {
|
||||
const sd = await readSerieData(serieId)
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
const organized = loadOrganizedJson()
|
||||
const { nfoSupport } = readSettings()
|
||||
const result: Record<string, any> = {}
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
let entry: any = null
|
||||
let entryHash = 'manual'
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p !== 'object' || !p.infohash) continue
|
||||
const h = p.infohash.toLowerCase()
|
||||
const e = (organized[h] ?? {})[String(ep.id)]
|
||||
if (e) { entry = e; entryHash = h; break }
|
||||
}
|
||||
if (!entry && organized['manual']?.[String(ep.id)]) {
|
||||
entry = organized['manual'][String(ep.id)]
|
||||
}
|
||||
if (!entry) continue
|
||||
|
||||
const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport)
|
||||
result[String(ep.id)] = { ...entry, hash: entryHash, needs_rename: needsRename }
|
||||
}
|
||||
}
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Rename épisode ─────────────────────────────────────────────
|
||||
router.post('/rename-episode', requireAuth, async (req, res) => {
|
||||
const { serie_id, episode_id, torrent_hash } = req.body
|
||||
if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return }
|
||||
const { nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serie_id))
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
let foundEp: any = null
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (ep.id === Number(episode_id)) { foundEp = { ep, season }; break }
|
||||
}
|
||||
if (foundEp) break
|
||||
}
|
||||
if (!foundEp) { res.status(404).json({ error: 'Épisode introuvable' }); return }
|
||||
const { ep, season } = foundEp
|
||||
const hash = torrent_hash?.toLowerCase() ?? 'manual'
|
||||
let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
|
||||
let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
|
||||
if (!orgEntry) {
|
||||
for (const [h, eps] of Object.entries(organized)) {
|
||||
if (eps[String(episode_id)]) { orgEntry = eps[String(episode_id)]; entryHash = h; break }
|
||||
}
|
||||
}
|
||||
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
|
||||
const { currentName, expectedName: newName, needsRename } = computeExpectedName(ep, orgEntry, entryHash, nfoSupport)
|
||||
if (!needsRename) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); return }
|
||||
const oldPath = orgEntry.dest_path
|
||||
const newPath = path.join(path.dirname(oldPath), newName)
|
||||
try {
|
||||
if (!fs.existsSync(oldPath)) throw new Error('Fichier source introuvable sur le disque')
|
||||
if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
|
||||
fs.renameSync(oldPath, newPath)
|
||||
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
|
||||
saveOrganizedJson(organized)
|
||||
logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
|
||||
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('api', `Rename échoué pour ep ${episode_id} : ${msg}`)
|
||||
res.status(500).json({ error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Désimport série complète ───────────────────────────────────
|
||||
router.delete('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
const serieId = String(req.params.serieId)
|
||||
const deleteFile = req.query.deleteFile === 'true'
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serieId))
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
const episodeIds = new Set<string>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id))
|
||||
}
|
||||
let removed = 0
|
||||
const errors: string[] = []
|
||||
const emptyHashes: string[] = []
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const epId of Object.keys(episodes)) {
|
||||
if (!episodeIds.has(epId)) continue
|
||||
const entry = episodes[epId]
|
||||
if (deleteFile && entry?.dest_path && fs.existsSync(entry.dest_path)) {
|
||||
try { fs.unlinkSync(entry.dest_path) } catch { errors.push(entry.dest_path) }
|
||||
}
|
||||
delete organized[hash][epId]
|
||||
removed++
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
if (deleteFile) {
|
||||
for (const hash of emptyHashes) {
|
||||
if (hash === 'manual') continue
|
||||
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
|
||||
}
|
||||
}
|
||||
logger.info('api', `Désimport série ${serieId} — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
|
||||
res.json({ ok: true, removed, errors })
|
||||
})
|
||||
|
||||
// ── Désimport saison ───────────────────────────────────────────
|
||||
router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, res) => {
|
||||
const serieId = String(req.params.serieId)
|
||||
const seasonId = Number(req.params.seasonId)
|
||||
const deleteFile = req.query.deleteFile === 'true'
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serieId))
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
const season = sd.seasons?.find((s: any) => s.id === seasonId)
|
||||
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
|
||||
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? [])
|
||||
let removed = 0
|
||||
const errors: string[] = []
|
||||
const emptyHashes: string[] = []
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const epId of Object.keys(episodes)) {
|
||||
if (!episodeIds.has(epId)) continue
|
||||
const entry = episodes[epId]
|
||||
if (deleteFile && entry?.dest_path && fs.existsSync(entry.dest_path)) {
|
||||
try { fs.unlinkSync(entry.dest_path) } catch { errors.push(entry.dest_path) }
|
||||
}
|
||||
delete organized[hash][epId]
|
||||
removed++
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
if (deleteFile) {
|
||||
for (const hash of emptyHashes) {
|
||||
if (hash === 'manual') continue
|
||||
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
|
||||
}
|
||||
}
|
||||
logger.info('api', `Désimport saison ${seasonId} (série ${serieId}) — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
|
||||
res.json({ ok: true, removed, errors })
|
||||
})
|
||||
|
||||
// ── Désimport épisode ──────────────────────────────────────────
|
||||
router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
|
||||
const episodeId = String(req.params.episodeId)
|
||||
const deleteFile = req.query.deleteFile === 'true'
|
||||
const organized = loadOrganizedJson()
|
||||
let foundHash: string | null = null
|
||||
let foundEntry: any = null
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
if (episodes[episodeId]) { foundHash = hash; foundEntry = episodes[episodeId]; break }
|
||||
}
|
||||
if (!foundHash || !foundEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
|
||||
try {
|
||||
if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) {
|
||||
fs.unlinkSync(foundEntry.dest_path)
|
||||
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`)
|
||||
}
|
||||
delete organized[foundHash][episodeId]
|
||||
const torrentEmpty = Object.keys(organized[foundHash]).length === 0
|
||||
if (torrentEmpty) delete organized[foundHash]
|
||||
saveOrganizedJson(organized)
|
||||
// Si plus aucun épisode de ce torrent n'est importé → retirer le torrent du client
|
||||
if (deleteFile && torrentEmpty && foundHash !== 'manual') {
|
||||
dispatchRemove(foundHash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${foundHash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
|
||||
}
|
||||
logger.info('api', `Désimport ep ${episodeId}${deleteFile && torrentEmpty ? ` + torrent ${foundHash.slice(0, 8)}… retiré du client` : ''}`)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('api', `Désimport échoué : ${msg}`)
|
||||
res.status(500).json({ error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Récap global des imports ───────────────────────────────────
|
||||
router.get('/organized-summary', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result: any[] = []
|
||||
for (const sd of seriesData) {
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
const episodes: any[] = []
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
let orgEntry: any = null
|
||||
let orgHash: string | null = null
|
||||
// Même stratégie que organized/:serieId : on cherche via ep.paths d'abord
|
||||
// pour garantir le bon hash → bon formatted_name dans resolveEpNaming
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p !== 'object' || !p.infohash) continue
|
||||
const h = p.infohash.toLowerCase()
|
||||
const e = (organized[h] ?? {})[String(ep.id)]
|
||||
if (e) { orgEntry = e; orgHash = h; break }
|
||||
}
|
||||
if (!orgEntry && organized['manual']?.[String(ep.id)]) {
|
||||
orgEntry = organized['manual'][String(ep.id)]; orgHash = 'manual'
|
||||
}
|
||||
if (!orgEntry) continue
|
||||
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
|
||||
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
|
||||
episodes.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, title: ep.title, current_name: currentName, expected_name: expectedName, dest_path: orgEntry.dest_path, torrent_hash: orgHash, needs_rename: needsRename, file_exists: fileExists })
|
||||
}
|
||||
}
|
||||
if (episodes.length > 0) {
|
||||
result.push({ serie_id: sd.id, serie_title: rawTitle, serie_title_clean: serieTitle, total: episodes.length, needs_rename: episodes.filter(e => e.needs_rename).length, episodes })
|
||||
}
|
||||
}
|
||||
res.json({ series: result, nfo_support: nfoSupport })
|
||||
} catch (err) {
|
||||
logger.error('api', `organized-summary échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Rename en masse ────────────────────────────────────────────
|
||||
router.post('/rename-all', requireAuth, async (req, res) => {
|
||||
const { serie_id } = req.body
|
||||
const { nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const done: number[] = []
|
||||
const errors: { episode_id: number; error: string }[] = []
|
||||
for (const sd of seriesData) {
|
||||
if (serie_id && sd.id !== Number(serie_id)) continue
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
let orgEntry: any = null
|
||||
let orgHash: string | null = null
|
||||
for (const [hash, eps] of Object.entries(organized)) {
|
||||
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
|
||||
}
|
||||
if (!orgEntry || !orgHash) continue
|
||||
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
|
||||
if (!needsRename) continue
|
||||
const oldPath = orgEntry.dest_path
|
||||
const newPath = path.join(path.dirname(oldPath), expectedName)
|
||||
try {
|
||||
if (!fs.existsSync(oldPath)) {
|
||||
const msg = `Fichier introuvable : "${oldPath}"`
|
||||
logger.error('api', `Rename masse ep ${ep.id} — ${msg}`)
|
||||
errors.push({ episode_id: ep.id, error: msg }); continue
|
||||
}
|
||||
if (fs.existsSync(newPath)) {
|
||||
const msg = `Fichier existant : "${expectedName}"`
|
||||
logger.warn('api', `Rename masse ep ${ep.id} — ${msg}`)
|
||||
errors.push({ episode_id: ep.id, error: msg }); continue
|
||||
}
|
||||
fs.renameSync(oldPath, newPath)
|
||||
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
|
||||
done.push(ep.id)
|
||||
logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('api', `Rename masse ep ${ep.id} — exception : ${msg}`)
|
||||
errors.push({ episode_id: ep.id, error: msg })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
res.json({ ok: true, done: done.length, errors })
|
||||
})
|
||||
|
||||
// ── Purge NFO / images des dossiers série ─────────────────────
|
||||
router.post('/purge-nfo', requireAuth, (req, res) => {
|
||||
const { mediaPath } = readSettings()
|
||||
if (!mediaPath || !fs.existsSync(mediaPath)) {
|
||||
res.status(400).json({ error: 'Chemin médiathèque non configuré ou introuvable' }); return
|
||||
}
|
||||
|
||||
const NFO_EXTS = new Set(['.nfo', '.png', '.jpg', '.jpeg', '.tbn', '.xml'])
|
||||
let deleted = 0
|
||||
const errors: string[] = []
|
||||
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(full)
|
||||
} else if (entry.isFile() && NFO_EXTS.has(path.extname(entry.name).toLowerCase())) {
|
||||
try {
|
||||
fs.unlinkSync(full)
|
||||
deleted++
|
||||
logger.debug('api', `Purge NFO : supprimé "${full}"`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('api', `Purge NFO : impossible de supprimer "${full}" : ${msg}`)
|
||||
errors.push(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(mediaPath)
|
||||
logger.info('api', `Purge NFO — ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
|
||||
res.json({ ok: true, deleted, errors })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { checkNfoUpdates, recentNfoUpdates } from '../lib/nfo.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/nfo-updates/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentNfoUpdates)
|
||||
})
|
||||
|
||||
router.post('/nfo-updates/check', requireAuth, async (_req, res) => {
|
||||
logger.info('nfo-update', 'Vérification manuelle des MAJ NFO lancée')
|
||||
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif manuelle échouée : ${err instanceof Error ? err.message : err}`))
|
||||
res.json({ ok: true, message: 'Vérification lancée en arrière-plan' })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
|
||||
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
|
||||
import { recentOrganized, pushNotif } from '../lib/notifs.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import path from 'path'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
||||
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error('api', `Migration IDs échouée : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/organize/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentOrganized)
|
||||
})
|
||||
|
||||
router.post('/organize/recent/clear', requireAuth, (_req, res) => {
|
||||
recentOrganized.length = 0
|
||||
logger.info('api', 'Historique des imports effacé')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.post('/organize', requireAuth, async (req, res) => {
|
||||
const { hash, name, save_path } = req.body
|
||||
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await organizeTorrent(hash, name, save_path, seriesData)
|
||||
if (result.done > 0 || result.errors.length > 0) {
|
||||
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
||||
}
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
logger.error('api', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Router } from 'express'
|
||||
import { Agent, fetch as undiciFetch } from 'undici'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const PLEX_TV_API = 'https://plex.tv/api/v2'
|
||||
|
||||
// ── Fetch avec bypass SSL pour les serveurs locaux ────────────────────────────
|
||||
function getServerFetch(serverUrl: string) {
|
||||
return (url: string, opts: any = {}) =>
|
||||
undiciFetch(url, {
|
||||
...opts,
|
||||
dispatcher: new Agent({ connect: { rejectUnauthorized: false } }),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Fetch vers plex.tv (certificat valide, pas besoin de bypass) ──────────────
|
||||
async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Accept' : 'application/json',
|
||||
'X-Plex-Client-Identifier': 'fankarr',
|
||||
'X-Plex-Product' : 'FanKarr',
|
||||
'X-Plex-Version' : '1.0',
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error(`Plex ${res.status}: ${await res.text()}`)
|
||||
const text = await res.text()
|
||||
return text ? JSON.parse(text) : {}
|
||||
}
|
||||
|
||||
function mapServers(resources: any[]) {
|
||||
return (Array.isArray(resources) ? resources : [])
|
||||
.filter((r: any) => r.product === 'Plex Media Server')
|
||||
.map((r: any) => ({
|
||||
name : r.name,
|
||||
owned : r.owned,
|
||||
connections: (r.connections ?? []).map((c: any) => ({ uri: c.uri, local: c.local, relay: c.relay })),
|
||||
}))
|
||||
}
|
||||
|
||||
// POST /api/plex/connect — auth plex.tv + liste serveurs
|
||||
router.post('/plex/connect', requireAuth, async (req, res) => {
|
||||
const { username, password, code } = req.body
|
||||
if (!username || !password) { res.status(400).json({ error: 'Email et mot de passe requis' }); return }
|
||||
try {
|
||||
const params = new URLSearchParams({ login: username, password })
|
||||
if (code) params.set('verificationCode', code)
|
||||
const authData = await plexFetch(`${PLEX_TV_API}/users/signin`, {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body : params.toString(),
|
||||
})
|
||||
const token = authData.authToken ?? authData.user?.authToken
|
||||
if (!token) throw new Error('Token non reçu')
|
||||
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
|
||||
const servers = mapServers(resources)
|
||||
logger.info('plex', `Auth réussie pour ${username} — ${servers.length} serveur(s)`)
|
||||
res.json({ ok: true, token, servers })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
if (msg.includes('401')) {
|
||||
res.status(401).json({ error: '2FA requis ou identifiants incorrects', requires2FA: true })
|
||||
} else {
|
||||
logger.error('plex', `Auth échouée : ${msg}`)
|
||||
res.status(401).json({ error: 'Authentification échouée — vérifiez vos identifiants' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/plex/oauth/start — démarre le flow OAuth
|
||||
router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const data = await plexFetch(`${PLEX_TV_API}/pins`, {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body : new URLSearchParams({ strong: 'true', 'X-Plex-Client-Identifier': 'fankarr' }).toString(),
|
||||
})
|
||||
const pinId = data.id
|
||||
const pinCode = data.code
|
||||
if (!pinId || !pinCode) throw new Error('Pin non reçu')
|
||||
const authUrl = `https://app.plex.tv/auth#?clientID=fankarr&code=${pinCode}&context%5Bdevice%5D%5Bproduct%5D=FanKarr&forwardUrl=https%3A%2F%2Fplex.tv`
|
||||
logger.info('plex', `OAuth démarré — pinId ${pinId}`)
|
||||
res.json({ ok: true, pinId, pinCode, authUrl })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur'
|
||||
logger.error('plex', `OAuth start échoué : ${msg}`)
|
||||
res.status(500).json({ error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/plex/oauth/poll/:pinId — vérifie si l'OAuth est complété
|
||||
router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
|
||||
const pinId = String(req.params.pinId)
|
||||
try {
|
||||
const data = await plexFetch(`${PLEX_TV_API}/pins/${pinId}?X-Plex-Client-Identifier=fankarr`)
|
||||
const token = data.authToken
|
||||
if (!token) { res.json({ ok: false, pending: true }); return }
|
||||
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
|
||||
const servers = mapServers(resources)
|
||||
logger.info('plex', `OAuth complété — ${servers.length} serveur(s)`)
|
||||
res.json({ ok: true, pending: false, token, servers })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur'
|
||||
res.status(500).json({ error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/plex/setup — enregistre l'agent Fankai + crée la bibliothèque
|
||||
router.post('/plex/setup', requireAuth, async (req, res) => {
|
||||
const { token, serverUrl, libraryName, libraryPath } = req.body
|
||||
if (!token || !serverUrl || !libraryName || !libraryPath) {
|
||||
res.status(400).json({ error: 'token, serverUrl, libraryName, libraryPath requis' }); return
|
||||
}
|
||||
|
||||
// Fetch avec SSL bypass pour les appels vers le serveur Plex local
|
||||
const serverFetch = getServerFetch(serverUrl)
|
||||
|
||||
const headers: Record<string, string> = { 'X-Plex-Token': token, 'Accept': 'application/json' }
|
||||
const TARGET_AGENT_URI = 'https://metadata.fankai.fr/plex'
|
||||
const steps: { step: string; ok: boolean; message: string }[] = []
|
||||
let agentIdentifier = 'tv.plex.agents.custom.fankai'
|
||||
let groupId: string | null = null
|
||||
let agentSetupOk = false
|
||||
|
||||
// ── Étape 1 : Enregistrer le provider Fankai ─────────────
|
||||
try {
|
||||
const providersRes = await serverFetch(`${serverUrl}/media/providers/metadata`, { headers })
|
||||
if (providersRes.ok) {
|
||||
const data = await providersRes.json() as any
|
||||
const providers: any[] = data?.MediaContainer?.MetadataAgentProvider ?? []
|
||||
let provider = providers.find((p: any) => p.uri === TARGET_AGENT_URI)
|
||||
if (!provider) {
|
||||
const r = await serverFetch(`${serverUrl}/media/providers/metadata?uri=${encodeURIComponent(TARGET_AGENT_URI)}`, { method: 'POST', headers })
|
||||
if (r.ok) provider = ((await r.json()) as any)?.MediaContainer?.MetadataAgentProvider?.[0]
|
||||
}
|
||||
if (provider) {
|
||||
agentIdentifier = provider.identifier ?? agentIdentifier
|
||||
steps.push({ step: 'provider', ok: true, message: `Provider enregistré (${agentIdentifier})` })
|
||||
}
|
||||
const groupsRes = await serverFetch(`${serverUrl}/media/providers/metadata/group`, { headers })
|
||||
if (groupsRes.ok) {
|
||||
const gdata = await groupsRes.json() as any
|
||||
const groups: any[] = gdata?.MediaContainer?.MetadataAgentProviderGroup ?? []
|
||||
let group = groups.find((g: any) => g.primaryIdentifier === agentIdentifier)
|
||||
if (!group) {
|
||||
const r = await serverFetch(
|
||||
`${serverUrl}/media/providers/metadata/group?title=Fankai&primaryIdentifier=${encodeURIComponent(agentIdentifier)}`,
|
||||
{ method: 'POST', headers },
|
||||
)
|
||||
if (r.ok) group = ((await r.json()) as any)?.MediaContainer?.MetadataAgentProviderGroup?.[0]
|
||||
}
|
||||
if (group) {
|
||||
groupId = String(group.id ?? '')
|
||||
steps.push({ step: 'group', ok: true, message: `Groupe d'agents créé (ID: ${groupId})` })
|
||||
agentSetupOk = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const status = providersRes.status
|
||||
steps.push({ step: 'agent', ok: false, message: `Agent non configuré (HTTP ${status} — Plex < 1.43 ?) — setup manuel requis` })
|
||||
logger.warn('plex', `Setup agent Fankai — HTTP ${status} depuis ${serverUrl}`)
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur'
|
||||
steps.push({ step: 'agent', ok: false, message: `Agent non configuré — setup manuel requis` })
|
||||
logger.warn('plex', `Setup agent Fankai échoué : ${msg}`)
|
||||
}
|
||||
|
||||
// ── Étape 2 : Créer la bibliothèque ──────────────────────
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
type : 'show',
|
||||
name : libraryName,
|
||||
agent : agentIdentifier,
|
||||
scanner : 'Plex TV Series',
|
||||
language: 'fr-FR',
|
||||
location: libraryPath,
|
||||
})
|
||||
if (groupId) params.set('metadataAgentProviderGroupId', groupId)
|
||||
const libRes = await serverFetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers })
|
||||
if (!libRes.ok) throw new Error(`HTTP ${libRes.status}: ${await libRes.text()}`)
|
||||
steps.push({ step: 'library', ok: true, message: `Bibliothèque "${libraryName}" créée` })
|
||||
logger.info('plex', `Bibliothèque "${libraryName}" créée sur ${serverUrl}`)
|
||||
res.json({ ok: true, agentSetupOk, steps, manualSetup: !agentSetupOk })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur'
|
||||
steps.push({ step: 'library', ok: false, message: `Création bibliothèque échouée : ${msg}` })
|
||||
logger.error('plex', `Création bibliothèque échouée : ${msg}`)
|
||||
res.status(500).json({ ok: false, steps, error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { dispatchList } from '../torrent-clients/index.js'
|
||||
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
|
||||
import {
|
||||
fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode,
|
||||
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
|
||||
deduplicateEpisodes,
|
||||
} from '../lib/serie-helpers.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/series', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()])
|
||||
const availableSet = new Set<number>(availableIds)
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const activeTorrents = new Set<string>()
|
||||
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
|
||||
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
|
||||
const serieDataMap = new Map<number, any>()
|
||||
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
|
||||
res.json({ series: seriesRaw.map((serie: any) => {
|
||||
const serieData = serieDataMap.get(serie.id) ?? null
|
||||
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false
|
||||
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) }
|
||||
})})
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const [serieRaw, seasonsData, serieData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`), readSerieData(id)])
|
||||
const serie = { ...normalizeSerie(serieRaw), wiki: serieData?.wiki ?? null }
|
||||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
||||
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
|
||||
return { ...normalizeSeason(season), episodes }
|
||||
}))
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
const episodeTorrentMap : Record<number, any[]> = {}
|
||||
const seasonTorrentMapBySn : Record<number, any[]> = {}
|
||||
const integraleTorrents : any[] = []
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
|
||||
const manualOrg = organized['manual'] ?? {}
|
||||
if (serieData) {
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (serieData) {
|
||||
// Lookup rapide hash → torrent pack (intégrale ou saison)
|
||||
const torrentByHash = new Map<string, { torrent_url: string; magnet: string; type: string; raw: string; fankai: boolean; torrent_name: string | null }>()
|
||||
for (const t of (serieData.torrents ?? []))
|
||||
if (t.infohash) torrentByHash.set(t.infohash.toLowerCase(), { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title ?? '', fankai: t.fankai ?? true, torrent_name: t.torrent_name ?? null })
|
||||
for (const sd_s of (serieData.seasons ?? []))
|
||||
for (const t of (sd_s.torrents ?? []))
|
||||
if (t.infohash) torrentByHash.set(t.infohash.toLowerCase(), { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title ?? '', fankai: t.fankai ?? true, torrent_name: t.torrent_name ?? null })
|
||||
|
||||
for (const t of (serieData.torrents ?? [])) {
|
||||
integraleTorrents.push({ ...t, raw: t.title })
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of resolved) {
|
||||
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
||||
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const t of (season.torrents ?? [])) {
|
||||
if (!seasonTorrentMapBySn[season.season_number]) seasonTorrentMapBySn[season.season_number] = []
|
||||
seasonTorrentMapBySn[season.season_number].push({ torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, type: 'pack_saison', raw: t.title, torrent_name: t.torrent_name ?? null, manual: t.manual ?? false })
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of resolved) {
|
||||
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
||||
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of (ep.torrents ?? [])) {
|
||||
if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = []
|
||||
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
episodeTorrentMap[ep.id].push({ torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, type: 'episode', raw: t.title, torrent_name: t.torrent_name ?? null, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null, formatted_name: pathEntry?.formatted_name ?? ep.formatted_name ?? null })
|
||||
availableEpisodeIds.add(ep.id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
const isOrg = orgFiles[String(ep.id)] !== undefined
|
||||
if (!isOrg) {
|
||||
const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
const filename = match ? match.path.split('/').pop() : null
|
||||
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
|
||||
} else {
|
||||
organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
// Fallback : si l'épisode n'a pas de torrent individuel,
|
||||
// on crée un torrent synthétique depuis ep.paths (fichier dans un pack).
|
||||
// On ajoute TOUTES les entrées valides (un pack par path unique).
|
||||
if (!episodeTorrentMap[ep.id] || episodeTorrentMap[ep.id].length === 0) {
|
||||
const seenPackHashes = new Set<string>()
|
||||
for (const pathEntry of (ep.paths ?? [])) {
|
||||
if (typeof pathEntry !== 'object' || !pathEntry.infohash) continue
|
||||
const hashKey = pathEntry.infohash.toLowerCase()
|
||||
if (seenPackHashes.has(hashKey)) continue
|
||||
const pack = torrentByHash.get(hashKey)
|
||||
if (!pack) continue
|
||||
seenPackHashes.add(hashKey)
|
||||
if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = []
|
||||
episodeTorrentMap[ep.id].push({ torrent_url: pack.torrent_url, magnet: pack.magnet, infohash: hashKey, type: pack.type, raw: pack.raw, torrent_name: pack.torrent_name ?? null, manual: false, fankai: pack.fankai, file_index: pathEntry.file_index ?? null, file_path: pathEntry.path ?? null, formatted_name: pathEntry.formatted_name ?? ep.formatted_name ?? null })
|
||||
availableEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
|
||||
const rawEps = season.episodes.map((ep: any) => {
|
||||
const epTorrents = episodeTorrentMap[ep.id] ?? []
|
||||
const epTorrent = epTorrents[0] ?? null
|
||||
let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null
|
||||
if (fankai === null && serieData) {
|
||||
outer: for (const sd_season of serieData.seasons ?? []) {
|
||||
for (const sdEp of sd_season.episodes ?? []) {
|
||||
if (sdEp.id !== ep.id) continue
|
||||
for (const p of sdEp.paths ?? []) {
|
||||
const ih = typeof p === 'object' ? p.infohash?.toLowerCase() : null
|
||||
if (!ih) continue
|
||||
for (const t of serieData.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } }
|
||||
for (const s of serieData.seasons ?? []) { for (const t of s.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolvedFankai = fankai ?? true
|
||||
return {
|
||||
...ep,
|
||||
available: availableEpisodeIds.has(ep.id),
|
||||
torrent: epTorrent ? { ...epTorrent, fankai: resolvedFankai } : null,
|
||||
torrents: epTorrents.map((t: any) => ({ ...t, fankai: resolvedFankai ?? t.fankai ?? true })),
|
||||
fankai,
|
||||
organized: organizedEpisodeIds.has(ep.id),
|
||||
}
|
||||
})
|
||||
// Dédoublonnage : plusieurs variantes (x264/x265…) du même episode_number
|
||||
// sont fusionnées en un seul épisode avec la liste de torrents combinée
|
||||
const eps = deduplicateEpisodes(rawEps)
|
||||
const total = eps.filter((e: any) => e.available).length
|
||||
const orgCount = eps.filter((e: any) => e.organized).length
|
||||
const epTotal = eps.length
|
||||
const orgState = orgCount === 0
|
||||
? 'none'
|
||||
: orgCount >= epTotal
|
||||
? 'complete'
|
||||
: total > 0 && orgCount >= total
|
||||
? 'complete'
|
||||
: 'partial'
|
||||
const seasonTorrents = seasonTorrentMapBySn[season.season_number] ?? []
|
||||
return {
|
||||
...season,
|
||||
torrent: seasonTorrents[0] ?? null,
|
||||
torrents: seasonTorrents,
|
||||
organized_state: orgState,
|
||||
organized_count: orgCount,
|
||||
episodes: eps,
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) })
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { readSettings, writeSettings } from '../settings.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/settings', requireAuth, (_req, res) => {
|
||||
res.json(readSettings())
|
||||
})
|
||||
|
||||
router.post('/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing } = req.body
|
||||
const patch: Record<string, any> = {}
|
||||
if (mediaPath !== undefined) patch.mediaPath = mediaPath
|
||||
if (completePath !== undefined) patch.completePath = completePath
|
||||
if (organizeMode !== undefined) patch.organizeMode = organizeMode
|
||||
if (category !== undefined) patch.category = category
|
||||
if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport
|
||||
if (autoImport !== undefined) patch.autoImport = autoImport
|
||||
if (devMode !== undefined) patch.devMode = devMode
|
||||
if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
|
||||
if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing
|
||||
const updated = writeSettings(patch)
|
||||
logger.info('api', 'Paramètres mis à jour')
|
||||
res.json(updated)
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { systemInfo } from '../system.js'
|
||||
import { scanMediaPath, syncFilenameChanges } from '../organize.js'
|
||||
import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js'
|
||||
import { cacheSize } from '../lib/github-cache.js'
|
||||
import { recentOrganized } from '../lib/notifs.js'
|
||||
import { workerRunning } from '../organize.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ── Logs ───────────────────────────────────────────────────────
|
||||
router.get('/logs', requireAuth, (req, res) => {
|
||||
const limit = Number(req.query.limit) || 100
|
||||
const level = (req.query.level as string) || 'all'
|
||||
const source = (req.query.source as string) || undefined
|
||||
res.json({ entries: readLogs({ limit, level: level as any, source }), size: logsFileSize() })
|
||||
})
|
||||
|
||||
router.post('/logs/clear', requireAuth, (_req, res) => {
|
||||
clearLogs()
|
||||
logger.info('api', 'Logs effacés')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── Système ────────────────────────────────────────────────────
|
||||
router.get('/system/info', systemInfo)
|
||||
|
||||
router.get('/system', requireAuth, (_req, res) => {
|
||||
res.json({ isDocker: fs.existsSync('/.dockerenv') })
|
||||
})
|
||||
|
||||
router.get('/version', (_req, res) => {
|
||||
try {
|
||||
const versionPath = path.join(BASE_DIR, 'version.txt')
|
||||
const version = fs.existsSync(versionPath) ? fs.readFileSync(versionPath, 'utf-8').trim() : 'dev'
|
||||
res.json({ version })
|
||||
} catch {
|
||||
res.json({ version: 'dev' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Parcours fichiers ──────────────────────────────────────────
|
||||
router.get('/browse', requireAuth, (req, res) => {
|
||||
const isWindows = process.platform === 'win32'
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
|
||||
// ── Racine virtuelle Windows : liste les lecteurs disponibles ──
|
||||
if (isWindows && dirPath === '/') {
|
||||
const drives: string[] = []
|
||||
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
||||
const d = `${letter}:\\`
|
||||
try { fs.readdirSync(d); drives.push(d) } catch {}
|
||||
}
|
||||
res.json({ path: '/', parent: null, dirs: drives, drivesRoot: true })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b))
|
||||
|
||||
// Sur Windows, remonter à '/' quand on est à la racine d'un lecteur (ex: C:\)
|
||||
let parent: string | null
|
||||
if (dirPath === '/') {
|
||||
parent = null
|
||||
} else if (isWindows && /^[A-Za-z]:\\?$/.test(dirPath)) {
|
||||
parent = '/'
|
||||
} else {
|
||||
const up = path.dirname(dirPath)
|
||||
parent = up === dirPath ? null : up // dirname de C:\ retourne C:\ → null
|
||||
}
|
||||
|
||||
res.json({ path: dirPath, parent, dirs })
|
||||
} catch (err) {
|
||||
logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/browse-files', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
|
||||
try {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
logger.info('api', `Dossier créé : ${dirPath}`)
|
||||
}
|
||||
const files: { name: string; path: string; size: number }[] = []
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
|
||||
catch { return }
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(full)
|
||||
} else if (entry.isFile() && VIDEO_EXTS.has(path.extname(entry.name).toLowerCase())) {
|
||||
try {
|
||||
const stat = fs.statSync(full)
|
||||
files.push({ name: entry.name, path: full, size: stat.size })
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(dirPath)
|
||||
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||
res.json({ path: dirPath, files })
|
||||
} catch (err) {
|
||||
logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Catalogue ──────────────────────────────────────────────────
|
||||
router.get('/torrents/status', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const available = await readAvailable()
|
||||
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
|
||||
} catch { res.json({ exists: false, count: 0, empty: true }) }
|
||||
})
|
||||
|
||||
router.post('/update', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
cacheClear()
|
||||
logger.info('api', 'Cache GitHub vidé — rechargement forcé')
|
||||
const availableIds = await readAvailable()
|
||||
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
|
||||
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
|
||||
res.json({ ok: true, count: availableIds.length })
|
||||
;(async () => {
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
|
||||
if (renamed > 0)
|
||||
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Sync noms après mise à jour échoué : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
})()
|
||||
} catch (err) {
|
||||
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/scan', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { mediaPath } = readSettings()
|
||||
logger.info('api', 'Scan médiathèque manuel lancé')
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error('api', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Debug ──────────────────────────────────────────────────────
|
||||
router.get('/debug/stats', requireAuth, (req, res) => {
|
||||
const { devMode } = readSettings()
|
||||
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
|
||||
const mem = process.memoryUsage()
|
||||
const uptimeS = Math.floor(process.uptime())
|
||||
const h = Math.floor(uptimeS / 3600)
|
||||
const m = Math.floor((uptimeS % 3600) / 60)
|
||||
const s = uptimeS % 60
|
||||
let organizedCount = 0
|
||||
try {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath)) {
|
||||
const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
organizedCount = Object.values(org).reduce((acc: number, episodes: any) => acc + Object.keys(episodes).length, 0)
|
||||
}
|
||||
} catch {}
|
||||
res.json({
|
||||
memory : { heapUsed: Math.round(mem.heapUsed / 1024 / 1024), heapTotal: Math.round(mem.heapTotal / 1024 / 1024), rss: Math.round(mem.rss / 1024 / 1024) },
|
||||
cache : { entries: cacheSize(), ttlHours: 6 },
|
||||
uptime : `${h}h ${m}m ${s}s`,
|
||||
uptimeSeconds: uptimeS,
|
||||
worker : { running: workerRunning },
|
||||
organized: { trackedFiles: organizedCount },
|
||||
requests : { notifs: recentOrganized.length },
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import {
|
||||
getDriver, getAvailableClients,
|
||||
listClients, addClient, removeClient, getClient,
|
||||
sanitizeClient, updateClient,
|
||||
} from '../torrent-clients/index.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/torrent-clients/available', requireAuth, (_req, res) => {
|
||||
res.json(getAvailableClients())
|
||||
})
|
||||
|
||||
router.get('/torrent-clients', requireAuth, (_req, res) => {
|
||||
res.json(listClients().map((c: any) => sanitizeClient(c)))
|
||||
})
|
||||
|
||||
router.post('/torrent-clients', requireAuth, (req, res) => {
|
||||
const { name, type, config } = req.body
|
||||
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
res.json(sanitizeClient(addClient(name, type, config)))
|
||||
})
|
||||
|
||||
router.delete('/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
const ok = removeClient(String(req.params.uuid))
|
||||
if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.put('/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
const { name, type, config } = req.body
|
||||
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
const oldClient = getClient(String(req.params.uuid))
|
||||
if (oldClient) {
|
||||
const driver = getDriver(type)
|
||||
if (driver) {
|
||||
for (const field of driver.definition.fields) {
|
||||
if (field.type === 'password' && config[field.key] === '••••••••') {
|
||||
config[field.key] = oldClient.config[field.key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const updated = updateClient(String(req.params.uuid), name, type, config)
|
||||
if (!updated) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
res.json(sanitizeClient(updated))
|
||||
})
|
||||
|
||||
router.post('/torrent-clients/test-config', requireAuth, async (req, res) => {
|
||||
const { type, config, uuid } = req.body
|
||||
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
|
||||
const driver = getDriver(type)
|
||||
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
if (uuid) {
|
||||
const oldClient = getClient(uuid)
|
||||
if (oldClient) {
|
||||
for (const field of driver.definition.fields) {
|
||||
if (field.type === 'password' && config[field.key] === '••••••••') {
|
||||
config[field.key] = oldClient.config[field.key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json(await driver.test(config))
|
||||
})
|
||||
|
||||
router.post('/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
|
||||
const client = getClient(String(req.params.uuid))
|
||||
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
|
||||
res.json(await driver.test(client.config))
|
||||
})
|
||||
|
||||
router.get('/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) => {
|
||||
const client = getClient(String(req.params.uuid))
|
||||
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
|
||||
res.json(await driver.healthcheck(client.config))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -34,6 +34,13 @@ export interface SavedClient {
|
||||
config: Record<string, string | number>
|
||||
}
|
||||
|
||||
export interface TorrentFileProgress {
|
||||
index : number
|
||||
name ?: string // chemin relatif dans le torrent (ex: "Pack S1/Episode1.mkv") — optionnel selon le client
|
||||
progress: number // 0–1
|
||||
priority: number // 0 = do not download, 1 = normal, 6 = high, 7 = max
|
||||
}
|
||||
|
||||
export interface TorrentInfo {
|
||||
hash : string
|
||||
name : string
|
||||
@@ -48,15 +55,24 @@ export interface TorrentInfo {
|
||||
eta : number
|
||||
save_path : string
|
||||
category : string
|
||||
files ?: TorrentFileProgress[]
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
file_index?: number | null
|
||||
file_path ?: string | null
|
||||
infohash ?: string | null // hash direct depuis les données API (plus fiable que parser le magnet)
|
||||
magnet ?: string | null // fallback pour extraire le hash si infohash absent
|
||||
}
|
||||
|
||||
export interface TorrentClientDriver {
|
||||
definition : TorrentClientDefinition
|
||||
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
|
||||
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
|
||||
add : (config: Record<string, string | number>, url: string) => Promise<void>
|
||||
add : (config: Record<string, string | number>, url: string, options?: DownloadOptions) => Promise<void>
|
||||
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
|
||||
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
|
||||
getFiles ?: (config: Record<string, string | number>, hash: string) => Promise<TorrentFileProgress[]>
|
||||
}
|
||||
|
||||
// ─── Registry ─────────────────────────────────────────────────────────────────
|
||||
@@ -208,7 +224,7 @@ function remapSavePath(savePath: string, remotePath: string, localPath: string):
|
||||
|
||||
// ─── Dispatch download ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
|
||||
export async function dispatchDownload(url: string, options?: DownloadOptions): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
|
||||
const clients = loadClients()
|
||||
|
||||
if (clients.length === 0) {
|
||||
@@ -226,7 +242,7 @@ export async function dispatchDownload(url: string): Promise<{ uuid: string; nam
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await driver.add(client.config, url)
|
||||
await driver.add(client.config, url, options)
|
||||
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: true })
|
||||
} catch (err) {
|
||||
@@ -328,4 +344,27 @@ export async function dispatchList(
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Dispatch file progress ────────────────────────────────────────────────────
|
||||
// Retourne la liste des fichiers d'un torrent avec leur progression (0–1).
|
||||
// clientUuid optionnel : si fourni, interroge uniquement ce client.
|
||||
|
||||
export async function dispatchGetFiles(
|
||||
hash : string,
|
||||
clientUuid?: string,
|
||||
): Promise<TorrentFileProgress[]> {
|
||||
const clients = clientUuid
|
||||
? loadClients().filter(c => c.uuid === clientUuid)
|
||||
: loadClients()
|
||||
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver?.getFiles) continue
|
||||
try {
|
||||
const files = await driver.getFiles(client.config, hash)
|
||||
if (files.length > 0) return files
|
||||
} catch {}
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
/**
|
||||
* qBittorrent Driver
|
||||
* Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication)
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
function mapState(state: string): TorrentInfo['state'] {
|
||||
if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading'
|
||||
if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding'
|
||||
if (['pausedDL', 'pausedUP'].includes(state)) return 'paused'
|
||||
if (['pausedDL', 'pausedUP', 'stoppedDL', 'stoppedUP'].includes(state)) return 'paused'
|
||||
if (['checkingDL', 'checkingUP', 'checkingResumeData'].includes(state)) return 'checking'
|
||||
if (state === 'error' || state === 'missingFiles') return 'error'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
// Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID
|
||||
async function qbAuth(config: Record<string, string | number>): Promise<Record<string, string>> {
|
||||
if (config.apiKey) {
|
||||
return { Authorization: `Bearer ${config.apiKey}` }
|
||||
}
|
||||
const cookie = await qbLogin(config)
|
||||
return { Cookie: cookie }
|
||||
}
|
||||
|
||||
async function qbLogin(config: Record<string, string | number>): Promise<string> {
|
||||
const form = new URLSearchParams()
|
||||
form.append('username', String(config.username ?? ''))
|
||||
@@ -26,12 +36,119 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
|
||||
if (!res.ok) throw new Error(`Login échoué : ${text.trim() || res.status}`)
|
||||
|
||||
const cookie = res.headers.get('set-cookie') ?? ''
|
||||
const sid = cookie.match(/SID=([^;]+)/)?.[1]
|
||||
if (!sid) throw new Error('Cookie SID introuvable')
|
||||
return sid
|
||||
const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0].trim()
|
||||
if (!cookie) throw new Error('Cookie de session introuvable')
|
||||
return cookie
|
||||
}
|
||||
|
||||
async function qbTorrentExists(config: Record<string, string | number>, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, {
|
||||
headers: authHeaders,
|
||||
})
|
||||
if (!res.ok) return false
|
||||
const list = await res.json()
|
||||
return Array.isArray(list) && list.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
|
||||
* applique les priorités, puis reprend le torrent.
|
||||
*/
|
||||
async function qbApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
resume : boolean,
|
||||
): Promise<void> {
|
||||
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
|
||||
try {
|
||||
const authH = await qbAuth(config)
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) continue
|
||||
const files: any[] = await res.json()
|
||||
if (!Array.isArray(files) || files.length === 0) continue
|
||||
|
||||
if (fileIndex >= files.length) {
|
||||
throw new Error(`Index fichier ${fileIndex} hors limites (torrent a ${files.length} fichiers)`)
|
||||
}
|
||||
|
||||
// Si tous les fichiers sont à priorité normale (état initial du torrent),
|
||||
// on applique le filtrage complet : on désactive tout sauf la cible.
|
||||
// Si certains sont déjà à 0 (filtrage déjà effectué pour un épisode précédent),
|
||||
// on se contente d'activer la nouvelle cible sans toucher aux autres.
|
||||
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
|
||||
|
||||
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
|
||||
if (isInitialState) {
|
||||
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
|
||||
if (unwanted.length > 0) {
|
||||
const body = new URLSearchParams({ hash, id: unwanted.join('|'), priority: '0' })
|
||||
const r = await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers })
|
||||
if (!r.ok) { logger.warn('qbittorrent', `filePrio désactivation échouée (${r.status}) — retry`); continue }
|
||||
}
|
||||
}
|
||||
|
||||
// Activer le fichier cible (dans tous les cas)
|
||||
const r2 = await fetch(`${config.url}/api/v2/torrents/filePrio`, {
|
||||
method: 'POST',
|
||||
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
|
||||
headers,
|
||||
})
|
||||
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
|
||||
|
||||
// Vérification : le fichier cible est bien à priorité > 0
|
||||
const verif = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
|
||||
if (verif.ok) {
|
||||
const verifiedFiles: any[] = await verif.json()
|
||||
const target = verifiedFiles[fileIndex]
|
||||
if (!target || (target.priority ?? 0) === 0) {
|
||||
logger.warn('qbittorrent', `Fichier ${fileIndex} toujours à priorité 0 après application — retry`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Reprendre (torrent mis en pause via paused/stopped à l'ajout)
|
||||
// On appelle les deux endpoints : resume (qBit ≤ 4.x) et start (qBit ≥ 5.0)
|
||||
if (resume) {
|
||||
const resumeBody = new URLSearchParams({ hashes: hash })
|
||||
await Promise.allSettled([
|
||||
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeBody, headers }),
|
||||
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeBody, headers }),
|
||||
])
|
||||
}
|
||||
|
||||
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné${resume ? ' + reprise' : ''} pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('hors limites')) throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
|
||||
if (resume) {
|
||||
try {
|
||||
const authH = await qbAuth(config)
|
||||
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
const body = new URLSearchParams({ hashes: hash })
|
||||
await Promise.allSettled([
|
||||
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body, headers }),
|
||||
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body, headers }),
|
||||
])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
|
||||
}
|
||||
|
||||
const QB: TorrentClientDriver = {
|
||||
@@ -40,8 +157,9 @@ const QB: TorrentClientDriver = {
|
||||
label : 'qBittorrent',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'apiKey', label: 'Clé API (≥5.2.0)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||
@@ -51,7 +169,9 @@ const QB: TorrentClientDriver = {
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
await qbLogin(config)
|
||||
const authH = await qbAuth(config)
|
||||
const res = await fetch(`${config.url}/api/v2/app/version`, { headers: authH })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
@@ -63,9 +183,9 @@ const QB: TorrentClientDriver = {
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const sid = await qbLogin(config)
|
||||
const authH = await qbAuth(config)
|
||||
const res = await fetch(`${config.url}/api/v2/app/version`, {
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) return { online: false }
|
||||
const version = (await res.text()).trim()
|
||||
@@ -78,60 +198,186 @@ const QB: TorrentClientDriver = {
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const sid = await qbLogin(config)
|
||||
const authH = await qbAuth(config)
|
||||
const params = new URLSearchParams()
|
||||
if (category) params.set('category', category)
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
|
||||
const data: any[] = await res.json()
|
||||
return data.map(t => ({
|
||||
hash : t.hash,
|
||||
name : t.name,
|
||||
state : mapState(t.state),
|
||||
progress : Math.round(t.progress * 100),
|
||||
size : t.size,
|
||||
downloaded: t.downloaded,
|
||||
uploaded : t.uploaded ?? 0,
|
||||
ratio : Math.round((t.ratio ?? 0) * 100) / 100,
|
||||
speed : t.dlspeed,
|
||||
upspeed : t.upspeed ?? 0,
|
||||
eta : t.eta ?? -1,
|
||||
save_path : t.save_path,
|
||||
category : t.category ?? '',
|
||||
} satisfies TorrentInfo))
|
||||
|
||||
// Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
|
||||
const fileMap = new Map<string, any[]>()
|
||||
await Promise.all(data.map(async (t: any) => {
|
||||
try {
|
||||
const r = await fetch(`${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (r.ok) fileMap.set(t.hash, await r.json())
|
||||
} catch {}
|
||||
}))
|
||||
|
||||
return data.map(t => {
|
||||
const fileInfo = fileMap.get(t.hash) ?? []
|
||||
return {
|
||||
hash : t.hash,
|
||||
name : t.name,
|
||||
state : mapState(t.state),
|
||||
progress : Math.round(t.progress * 100),
|
||||
size : t.size,
|
||||
downloaded: t.downloaded,
|
||||
uploaded : t.uploaded ?? 0,
|
||||
ratio : Math.round((t.ratio ?? 0) * 100) / 100,
|
||||
speed : t.dlspeed,
|
||||
upspeed : t.upspeed ?? 0,
|
||||
eta : t.eta ?? -1,
|
||||
save_path : t.save_path,
|
||||
category : t.category ?? '',
|
||||
files : fileInfo.length > 0
|
||||
? fileInfo.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
name : String(f.name ?? ''),
|
||||
progress: f.progress ?? 0, // 0–1
|
||||
priority: f.priority ?? 1,
|
||||
}))
|
||||
: undefined,
|
||||
} satisfies TorrentInfo
|
||||
})
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
const sid = await qbLogin(config)
|
||||
async getFiles(config, hash) {
|
||||
const authH = await qbAuth(config)
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data: any[] = await res.json()
|
||||
return data.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
name : String(f.name ?? ''),
|
||||
progress: f.progress ?? 0,
|
||||
priority: f.priority ?? 1,
|
||||
}))
|
||||
},
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const authH = await qbAuth(config)
|
||||
|
||||
// Priorité : infohash direct (depuis les données API) > parsing du magnet/URL
|
||||
const hash = (options?.infohash?.toLowerCase() ?? null)
|
||||
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|
||||
|| options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|
||||
|| null
|
||||
|
||||
if (options?.file_index != null) {
|
||||
if (hash) {
|
||||
const exists = await qbTorrentExists(config, authH, hash)
|
||||
if (exists) {
|
||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
|
||||
qbApplyFilePriority(config, hash, options.file_index, false).catch(err =>
|
||||
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot des hashes avant ajout (pour retrouver le nouveau si hash inconnu)
|
||||
let knownHashes: Set<string> = new Set()
|
||||
if (!hash) {
|
||||
try {
|
||||
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: authH })
|
||||
if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Stratégie d'ajout selon le type de lien :
|
||||
// .torrent URL → FanKarr télécharge le fichier et l'uploade directement à qBit
|
||||
// (qBit n'a pas forcément accès à internet / nyaa.si depuis son réseau Docker)
|
||||
// magnet link → passage de l'URL brute, ajout normal sans pause
|
||||
// (les métadonnées arrivent via les pairs, la pause bloquerait tout)
|
||||
const isMagnet = url.startsWith('magnet:')
|
||||
const form = new FormData()
|
||||
if (config.category) form.append('category', String(config.category))
|
||||
if (config.savePath) form.append('savepath', String(config.savePath))
|
||||
|
||||
if (isMagnet) {
|
||||
form.append('urls', url)
|
||||
} else {
|
||||
// FanKarr télécharge le .torrent et l'envoie directement à qBit
|
||||
logger.debug('qbittorrent', `Téléchargement .torrent via FanKarr : ${url.slice(0, 120)}`)
|
||||
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } })
|
||||
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent (${torrentRes.status}) : ${url}`)
|
||||
const torrentBytes = await torrentRes.arrayBuffer()
|
||||
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent')
|
||||
// Pause : métadonnées déjà dans le fichier → liste de fichiers disponible immédiatement
|
||||
form.append('paused', 'true') // qBit < 5.0
|
||||
form.append('stopped', 'true') // qBit ≥ 5.0
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: form, headers: authH,
|
||||
})
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||
|
||||
if (hash) {
|
||||
logger.info('qbittorrent', `Torrent ajouté (${isMagnet ? 'magnet, actif' : 'en pause'}) — sélection fichier ${options.file_index} en attente des métadonnées`)
|
||||
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
|
||||
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
} else {
|
||||
// Hash inconnu (pas d'infohash, pas de magnet) → retrouve le nouveau torrent par diff de liste
|
||||
logger.info('qbittorrent', `Torrent ajouté sans hash connu — recherche dans la liste (sélection fichier ${options.file_index})`)
|
||||
const fileIndex = options.file_index
|
||||
;(async () => {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const ah = await qbAuth(config)
|
||||
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: ah })
|
||||
if (!lr.ok) continue
|
||||
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
|
||||
if (!newT) continue
|
||||
const resolvedHash = String(newT.hash).toLowerCase()
|
||||
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
|
||||
await qbApplyFilePriority(config, resolvedHash, fileIndex, true)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
logger.warn('qbittorrent', `Impossible de résoudre le hash pour appliquer la priorité fichier ${fileIndex}`)
|
||||
})().catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Pas de sélection de fichier → ajout normal
|
||||
const form = new FormData()
|
||||
form.append('urls', url)
|
||||
if (config.category) form.append('category', String(config.category))
|
||||
if (config.savePath) form.append('savepath', String(config.savePath))
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
method : 'POST',
|
||||
body : form,
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: form, headers: authH,
|
||||
})
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||
logger.info('qbittorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
|
||||
logger.info('qbittorrent', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const sid = await qbLogin(config)
|
||||
const authH = await qbAuth(config)
|
||||
const form = new FormData()
|
||||
form.append('hashes', hash)
|
||||
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
|
||||
method : 'POST',
|
||||
body : form,
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
|
||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default QB
|
||||
export default QB
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Real-Debrid Driver
|
||||
*
|
||||
* Real-Debrid est un service debrid cloud : les torrents téléchargent sur leurs serveurs,
|
||||
* puis les fichiers sont accessibles via des liens directs (HTTPS).
|
||||
*
|
||||
* Workflow FanKarr :
|
||||
* 1. add() → POST /torrents/addMagnet puis selectFiles si file_index fourni
|
||||
* 2. list() → GET /torrents (filtre par status=downloaded pour state='seeding')
|
||||
* 3. remove() → DELETE /torrents/delete/{id}
|
||||
*
|
||||
* Le save_path de chaque torrent est construit à partir du localPath configuré
|
||||
* (dossier où l'utilisateur télécharge ses fichiers RD via FUSE, WebDAV ou autre).
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
|
||||
|
||||
function rdHeaders(config: Record<string, string | number>) {
|
||||
return {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
}
|
||||
|
||||
async function rdFetch(
|
||||
config : Record<string, string | number>,
|
||||
method : 'GET' | 'POST' | 'DELETE',
|
||||
path : string,
|
||||
body ?: URLSearchParams,
|
||||
): Promise<any> {
|
||||
const res = await fetch(`${RD_BASE}${path}`, {
|
||||
method,
|
||||
headers: rdHeaders(config),
|
||||
body : body,
|
||||
})
|
||||
if (res.status === 204) return null
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`Real-Debrid HTTP ${res.status} : ${text}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function mapStatus(status: string): TorrentInfo['state'] {
|
||||
switch (status) {
|
||||
case 'downloaded': return 'seeding'
|
||||
case 'downloading':
|
||||
case 'queued':
|
||||
case 'compressing':
|
||||
case 'uploading':
|
||||
case 'magnet_conversion': return 'downloading'
|
||||
case 'waiting_files_selection': return 'paused'
|
||||
case 'magnet_error':
|
||||
case 'error':
|
||||
case 'virus':
|
||||
case 'dead': return 'error'
|
||||
default: return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const rdDriver: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'real-debrid',
|
||||
label : 'Real-Debrid',
|
||||
fields: [
|
||||
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
|
||||
{ key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/real-debrid', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
const user = await rdFetch(config, 'GET', '/user')
|
||||
logger.info('real-debrid', `Connexion réussie — utilisateur : ${user.username}`)
|
||||
return { ok: true, message: `Connecté en tant que ${user.username}` }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.warn('real-debrid', `Test de connexion échoué : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const user = await rdFetch(config, 'GET', '/user')
|
||||
return { online: true, version: `RD — ${user.username}` }
|
||||
} catch {
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const isMagnet = url.startsWith('magnet:')
|
||||
const hash = (options?.infohash?.toLowerCase() ?? null)
|
||||
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|
||||
|| null
|
||||
|
||||
// Vérifier si le torrent est déjà présent sur RD
|
||||
if (hash) {
|
||||
try {
|
||||
const list: any[] = await rdFetch(config, 'GET', '/torrents')
|
||||
const existing = list.find((t: any) => t.hash?.toLowerCase() === hash)
|
||||
if (existing) {
|
||||
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur RD`)
|
||||
if (options?.file_index != null && existing.status === 'waiting_files_selection') {
|
||||
const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`)
|
||||
.then((info: any) => info.files ?? [])
|
||||
const fileId = files[options.file_index]?.id
|
||||
if (fileId) {
|
||||
await rdFetch(config, 'POST', `/torrents/selectFiles/${existing.id}`,
|
||||
new URLSearchParams({ files: String(fileId) }))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let torrentId: string
|
||||
|
||||
if (isMagnet) {
|
||||
const data = await rdFetch(config, 'POST', '/torrents/addMagnet',
|
||||
new URLSearchParams({ magnet: url }))
|
||||
torrentId = data.id
|
||||
logger.info('real-debrid', `Magnet ajouté → ID ${torrentId}`)
|
||||
} else {
|
||||
// URL de fichier .torrent : télécharger puis uploader
|
||||
const torrentRes = await fetch(url)
|
||||
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`)
|
||||
const torrentBuf = await torrentRes.arrayBuffer()
|
||||
const formData = new FormData()
|
||||
formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
|
||||
const uploadRes = await fetch(`${RD_BASE}/torrents/addTorrent`, {
|
||||
method : 'PUT',
|
||||
headers: { Authorization: `Bearer ${config.apiKey}` },
|
||||
body : formData,
|
||||
})
|
||||
if (!uploadRes.ok) throw new Error(`Ajout .torrent échoué : ${uploadRes.status}`)
|
||||
const data = await uploadRes.json()
|
||||
torrentId = data.id
|
||||
logger.info('real-debrid', `.torrent uploadé → ID ${torrentId}`)
|
||||
}
|
||||
|
||||
// Sélection des fichiers
|
||||
if (options?.file_index != null) {
|
||||
// Attendre que la liste des fichiers soit disponible (état waiting_files_selection)
|
||||
let files: any[] = []
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
|
||||
if (info.status === 'waiting_files_selection' && Array.isArray(info.files) && info.files.length > 0) {
|
||||
files = info.files
|
||||
break
|
||||
}
|
||||
if (info.status === 'downloading' || info.status === 'downloaded') break
|
||||
} catch {}
|
||||
}
|
||||
if (files.length > 0 && options.file_index < files.length) {
|
||||
const fileId = files[options.file_index]?.id
|
||||
if (fileId) {
|
||||
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
|
||||
new URLSearchParams({ files: String(fileId) }))
|
||||
logger.info('real-debrid', `Fichier ${options.file_index} sélectionné sur torrent RD ${torrentId}`)
|
||||
}
|
||||
} else if (files.length > 0) {
|
||||
// Sélectionner tous les fichiers si index hors limites
|
||||
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
|
||||
new URLSearchParams({ files: 'all' }))
|
||||
}
|
||||
} else {
|
||||
// Pas de sélection spécifique : tous les fichiers
|
||||
// Attendre waiting_files_selection pour envoyer "all"
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
|
||||
if (info.status === 'waiting_files_selection') {
|
||||
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
|
||||
new URLSearchParams({ files: 'all' }))
|
||||
break
|
||||
}
|
||||
if (info.status !== 'magnet_conversion') break
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, _category?) {
|
||||
const torrents: any[] = await rdFetch(config, 'GET', '/torrents')
|
||||
const localBase = String(config.localPath ?? '').replace(/\/+$/, '')
|
||||
|
||||
return torrents.map((t: any): TorrentInfo => {
|
||||
const progress = typeof t.progress === 'number' ? t.progress / 100 : 0
|
||||
// Construire un save_path "virtuel" à partir du localPath configuré + nom du torrent
|
||||
const save_path = localBase ? `${localBase}/${t.filename ?? t.id}` : ''
|
||||
|
||||
// Les liens sont les fichiers téléchargés (disponibles si status=downloaded)
|
||||
const files: TorrentFileProgress[] | undefined =
|
||||
Array.isArray(t.files) && t.files.length > 0
|
||||
? t.files.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
name : String(f.path ?? f.name ?? ''),
|
||||
progress: f.selected ? progress : 0,
|
||||
}))
|
||||
: undefined
|
||||
|
||||
return {
|
||||
hash : String(t.hash ?? t.id ?? '').toLowerCase(),
|
||||
name : String(t.filename ?? t.id ?? ''),
|
||||
state : mapStatus(t.status ?? ''),
|
||||
progress : Math.round(progress * 100),
|
||||
size : typeof t.bytes === 'number' ? t.bytes : 0,
|
||||
downloaded: typeof t.bytes === 'number' ? Math.round(t.bytes * progress) : 0,
|
||||
uploaded : 0,
|
||||
ratio : 0,
|
||||
speed : typeof t.speed === 'number' ? t.speed : 0,
|
||||
upspeed : 0,
|
||||
eta : -1,
|
||||
save_path,
|
||||
category : '',
|
||||
files,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async remove(config, hash, _deleteFiles?) {
|
||||
// Retrouver l'ID RD depuis le hash
|
||||
const list: any[] = await rdFetch(config, 'GET', '/torrents')
|
||||
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
|
||||
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur Real-Debrid`)
|
||||
await rdFetch(config, 'DELETE', `/torrents/delete/${target.id}`)
|
||||
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… supprimé de Real-Debrid`)
|
||||
},
|
||||
|
||||
async getFiles(config, hash) {
|
||||
const list: any[] = await rdFetch(config, 'GET', '/torrents')
|
||||
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
|
||||
if (!target) return []
|
||||
const info = await rdFetch(config, 'GET', `/torrents/info/${target.id}`)
|
||||
const overallProgress = typeof info.progress === 'number' ? info.progress / 100 : 0
|
||||
if (!Array.isArray(info.files)) return []
|
||||
return info.files.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
name : String(f.path ?? f.name ?? ''),
|
||||
progress: f.selected ? overallProgress : 0,
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
export default rdDriver
|
||||
@@ -5,7 +5,7 @@
|
||||
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── XML-RPC helpers ──────────────────────────────────────────
|
||||
@@ -118,6 +118,56 @@ async function d_multicall(
|
||||
return Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
async function rtTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
|
||||
try {
|
||||
const rows = await d_multicall(config, 'main', ['d.hash'])
|
||||
return rows.some(r => String(r[0]).toLowerCase() === hash.toLowerCase())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend les métadonnées via polling rapide (300 ms), applique les priorités.
|
||||
* Le torrent est ajouté avec load.start — il fetch les métadonnées activement via DHT/peers.
|
||||
* Pendant le fetch (état "métadonnées en cours"), aucun byte de fichier n'est téléchargé.
|
||||
* On set les priorités dès que les fichiers sont disponibles, avant que le download réel commence.
|
||||
*/
|
||||
async function rtApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
// 100 tentatives × 300 ms = 30 s max
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
try {
|
||||
// Récupérer taille ET priorité courante de chaque fichier
|
||||
const files: any[] = await rpcCall(config, 'f.multicall', [hash, '', 'f.size_bytes=', 'f.priority='])
|
||||
const fileCount = Array.isArray(files) ? files.length : 0
|
||||
if (fileCount === 0) continue
|
||||
|
||||
// Si tous les fichiers sont à priorité normale (état initial),
|
||||
// on filtre tout sauf la cible. Sinon on active juste la cible.
|
||||
const isInitialState = files.every((f: any) => Number(f[1]) > 0)
|
||||
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
if (i === fileIndex) {
|
||||
await rpcCall(config, 'f.priority.set', [hash, i, 1])
|
||||
} else if (isInitialState) {
|
||||
await rpcCall(config, 'f.priority.set', [hash, i, 0])
|
||||
}
|
||||
// Si pas initial state : on ne touche pas aux autres fichiers
|
||||
}
|
||||
|
||||
logger.info('rtorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
|
||||
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
|
||||
}
|
||||
|
||||
// ─── State mapping ────────────────────────────────────────────
|
||||
// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1)
|
||||
// + is_open (0/1)
|
||||
@@ -189,17 +239,29 @@ const RT: TorrentClientDriver = {
|
||||
|
||||
const rows = await d_multicall(config, 'main', methods)
|
||||
|
||||
// Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
|
||||
const fileMap = new Map<string, any[]>()
|
||||
await Promise.all(rows.map(async (r: any) => {
|
||||
const hash = String(r[0]).toUpperCase()
|
||||
try {
|
||||
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path='])
|
||||
if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files)
|
||||
} catch {}
|
||||
}))
|
||||
|
||||
return rows
|
||||
.map(r => {
|
||||
const [hash, name, isOpen, isActive, isChecking, isComplete,
|
||||
size, downloaded, uploadedTotal, ratioRaw,
|
||||
dlSpeed, ulSpeed, leftBytes, directory, label] = r
|
||||
|
||||
const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0
|
||||
const eta = Number(dlSpeed) > 0 && Number(leftBytes) > 0 ? Math.round(Number(leftBytes) / Number(dlSpeed)) : -1
|
||||
const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0
|
||||
const eta = Number(dlSpeed) > 0 && Number(leftBytes) > 0 ? Math.round(Number(leftBytes) / Number(dlSpeed)) : -1
|
||||
const hashLower = String(hash).toLowerCase()
|
||||
const fileInfo = fileMap.get(hashLower) ?? []
|
||||
|
||||
return {
|
||||
hash : String(hash).toLowerCase(),
|
||||
hash : hashLower,
|
||||
name : String(name),
|
||||
state : mapState(Number(isOpen), Number(isActive), Number(isChecking), Number(isComplete)),
|
||||
progress,
|
||||
@@ -212,6 +274,14 @@ const RT: TorrentClientDriver = {
|
||||
eta,
|
||||
save_path : String(directory),
|
||||
category : String(label ?? ''),
|
||||
files : fileInfo.length > 0
|
||||
? fileInfo.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
name : String(f[2] ?? ''),
|
||||
progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0,
|
||||
priority: 1,
|
||||
}))
|
||||
: undefined,
|
||||
} satisfies TorrentInfo
|
||||
})
|
||||
.filter(t => {
|
||||
@@ -220,20 +290,44 @@ const RT: TorrentClientDriver = {
|
||||
})
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
// load_start charge et démarre le torrent
|
||||
const method = url.startsWith('magnet:') ? 'load.start' : 'load.start'
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
const hash = hashMatch?.[1]?.toUpperCase() ?? null
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
const exists = await rtTorrentExists(config, hash)
|
||||
|
||||
if (exists) {
|
||||
// Torrent déjà présent → mettre à jour la priorité directement (async OK)
|
||||
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
|
||||
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Nouveau torrent → load.start (fetch métadonnées via DHT/peers actif)
|
||||
// Pendant le fetch des métadonnées, aucun byte de fichier n'est téléchargé.
|
||||
// On poll à 300 ms et on set les priorités dès que les fichiers sont disponibles.
|
||||
const args: any[] = ['', url]
|
||||
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
|
||||
if (config.category) args.push(`d.custom1.set=${config.category}`)
|
||||
await rpcCall(config, 'load.start', args)
|
||||
|
||||
logger.info('rtorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
|
||||
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Pas de sélection de fichier → ajout et démarrage normal
|
||||
const args: any[] = ['', url]
|
||||
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
|
||||
if (config.category) args.push(`d.custom1.set=${config.category}`)
|
||||
await rpcCall(config, 'load.start', args)
|
||||
|
||||
if (config.savePath) {
|
||||
args.push(`d.directory.set="${config.savePath}"`)
|
||||
}
|
||||
|
||||
if (config.category) {
|
||||
args.push(`d.custom1.set=${config.category}`)
|
||||
}
|
||||
|
||||
await rpcCall(config, method, args)
|
||||
logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
|
||||
},
|
||||
|
||||
@@ -250,4 +344,4 @@ const RT: TorrentClientDriver = {
|
||||
},
|
||||
}
|
||||
|
||||
export default RT
|
||||
export default RT
|
||||
@@ -2,7 +2,7 @@
|
||||
* Synology Download Station Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// Synology Download Station task status codes
|
||||
@@ -130,7 +130,8 @@ const DS: TorrentClientDriver = {
|
||||
})
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
if (options?.file_index != null) logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
|
||||
const sid = await dsLogin(config)
|
||||
|
||||
const extra: Record<string, string> = { uri: url }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Transmission Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
function mapState(status: number): TorrentInfo['state'] {
|
||||
@@ -52,6 +52,44 @@ async function trRequest(
|
||||
return { ...data, sessionId }
|
||||
}
|
||||
|
||||
async function trGetIdByHash(config: Record<string, string | number>, hash: string): Promise<number | null> {
|
||||
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
|
||||
const torrents = data.arguments?.torrents ?? []
|
||||
return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend que les métadonnées soient disponibles (polling 500 ms),
|
||||
* puis applique les priorités : fichier cible voulu, tous les autres ignorés.
|
||||
*/
|
||||
async function trApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const id = await trGetIdByHash(config, hash)
|
||||
if (id == null) continue
|
||||
|
||||
const data = await trRequest(config, 'torrent-get', { ids: [id], fields: ['files'] })
|
||||
const files = data.arguments?.torrents?.[0]?.files
|
||||
if (!Array.isArray(files) || files.length === 0) continue
|
||||
|
||||
const unwanted = files.map((_: any, i: number) => i).filter((i: number) => i !== fileIndex)
|
||||
await trRequest(config, 'torrent-set', {
|
||||
ids : [id],
|
||||
'files-wanted' : [fileIndex],
|
||||
'files-unwanted' : unwanted,
|
||||
})
|
||||
logger.info('transmission', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
|
||||
}
|
||||
|
||||
const TR: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'transmission',
|
||||
@@ -96,6 +134,7 @@ const TR: TorrentClientDriver = {
|
||||
'hashString', 'name', 'status', 'percentDone', 'totalSize',
|
||||
'downloadedEver', 'uploadedEver', 'uploadRatio',
|
||||
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
|
||||
'files', // bytesCompleted + length par fichier (fileStats n'a pas 'length')
|
||||
]
|
||||
const data = await trRequest(config, 'torrent-get', { fields })
|
||||
const torrents: any[] = data.arguments?.torrents ?? []
|
||||
@@ -119,22 +158,41 @@ const TR: TorrentClientDriver = {
|
||||
eta : t.eta ?? -1,
|
||||
save_path : t.downloadDir,
|
||||
category : t.labels?.[0] ?? '',
|
||||
files : Array.isArray(t.files) && t.files.length > 0
|
||||
? t.files.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
name : String(f.name ?? ''),
|
||||
progress: f.length > 0 ? f.bytesCompleted / f.length : 0,
|
||||
}))
|
||||
: undefined,
|
||||
} satisfies TorrentInfo))
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
const hash = hashMatch?.[1]?.toLowerCase() ?? null
|
||||
|
||||
const args: Record<string, unknown> = { filename: url }
|
||||
if (config.savePath) args['download-dir'] = String(config.savePath)
|
||||
if (config.category) args['labels'] = [String(config.category)]
|
||||
|
||||
if (config.savePath) {
|
||||
args['download-dir'] = String(config.savePath)
|
||||
const res = await trRequest(config, 'torrent-add', args)
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
const isDuplicate = !!res.arguments?.['torrent-duplicate']
|
||||
if (isDuplicate) {
|
||||
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
|
||||
} else {
|
||||
logger.info('transmission', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
|
||||
}
|
||||
trApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('transmission', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (config.category) {
|
||||
args['labels'] = [String(config.category)]
|
||||
}
|
||||
|
||||
await trRequest(config, 'torrent-add', args)
|
||||
logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
|
||||
logger.info('transmission', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
@@ -148,4 +206,4 @@ const TR: TorrentClientDriver = {
|
||||
},
|
||||
}
|
||||
|
||||
export default TR
|
||||
export default TR
|
||||
@@ -2,7 +2,7 @@
|
||||
* uTorrent Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// uTorrent status flags (bitmask) :
|
||||
@@ -21,6 +21,56 @@ 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> {
|
||||
try {
|
||||
const session = await utGetSession(config)
|
||||
const data = await utRequest(config, { action: 'getfiles', hash: hash.toUpperCase() }, session)
|
||||
const files = data?.files?.[1]
|
||||
return Array.isArray(files) && files.length > 0
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend que les métadonnées soient disponibles (polling), puis applique les priorités :
|
||||
* tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal).
|
||||
*/
|
||||
async function utApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
const HASH = hash.toUpperCase()
|
||||
|
||||
// 100 tentatives × 500 ms = 50 s max
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const session = await utGetSession(config)
|
||||
const data = await utRequest(config, { action: 'getfiles', hash: HASH }, session)
|
||||
const files = data?.files?.[1] as any[][] | undefined
|
||||
if (!Array.isArray(files) || files.length === 0) continue
|
||||
|
||||
// Passer tous les fichiers à priorité 0 (skip)
|
||||
const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' })
|
||||
for (let i = 0; i < files.length; i++) skipQs.append('f', String(i))
|
||||
await fetch(`${config.url}/gui/?${skipQs}`, {
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
|
||||
// Activer le fichier cible (priorité 2 = normal)
|
||||
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
|
||||
await fetch(`${config.url}/gui/?${selectQs}`, {
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
|
||||
logger.info('utorrent', `Fichier ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`)
|
||||
}
|
||||
|
||||
async function utGetSession(config: Record<string, string | number>): Promise<UTSession> {
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
const headers : Record<string, string> = {
|
||||
@@ -141,44 +191,50 @@ const UT: TorrentClientDriver = {
|
||||
})
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
const hash = hashMatch?.[1]?.toLowerCase() ?? null
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
const exists = await utTorrentExists(config, hash)
|
||||
if (exists) {
|
||||
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
|
||||
utApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const session = await utGetSession(config)
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
|
||||
const form = new FormData()
|
||||
form.append('torrent_url', url)
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
token : session.token,
|
||||
action: 'add-url',
|
||||
s : url,
|
||||
})
|
||||
|
||||
if (config.savePath) {
|
||||
qs.set('download_dir', '1')
|
||||
qs.set('path', String(config.savePath))
|
||||
}
|
||||
const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url })
|
||||
if (config.savePath) qs.set('path', String(config.savePath))
|
||||
|
||||
const res = await fetch(`${config.url}/gui/?${qs}`, {
|
||||
method : 'GET',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Cookie' : session.cookie,
|
||||
},
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`)
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
logger.info('utorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
|
||||
utApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
} else {
|
||||
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
|
||||
}
|
||||
|
||||
// Appliquer le label/catégorie si défini
|
||||
if (config.category) {
|
||||
// On ne peut pas set le label au moment de l'ajout, il faut attendre
|
||||
// que le torrent soit chargé — on le fait via setprops après un délai
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const s2 = await utGetSession(config)
|
||||
const data = await utRequest(config, { list: '1' }, s2)
|
||||
const torrents: any[][] = data?.torrents ?? []
|
||||
// Trouver le torrent le plus récemment ajouté sans label
|
||||
const target = torrents.find(t => !t[11])
|
||||
if (target) {
|
||||
await utRequest(config, {
|
||||
@@ -191,8 +247,6 @@ const UT: TorrentClientDriver = {
|
||||
} catch {}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<!-- Chemin éditable -->
|
||||
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
|
||||
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" title="Retour à la racine">
|
||||
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" :title="drivesRoot ? 'Lecteurs' : 'Retour à la racine'">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
@@ -85,12 +85,13 @@ import { ref, watch } from 'vue'
|
||||
const props = defineProps<{ initialPath?: string }>()
|
||||
const emit = defineEmits<{ select: [path: string]; cancel: [] }>()
|
||||
|
||||
const current = ref(props.initialPath || '/')
|
||||
const inputPath = ref(current.value)
|
||||
const dirs = ref<string[]>([])
|
||||
const parent = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const current = ref(props.initialPath || '/')
|
||||
const inputPath = ref(current.value)
|
||||
const dirs = ref<string[]>([])
|
||||
const parent = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const drivesRoot = ref(false) // true quand la liste affiche les lecteurs Windows
|
||||
|
||||
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
|
||||
function selectCurrent() {
|
||||
@@ -98,8 +99,10 @@ function selectCurrent() {
|
||||
}
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
// Lecteur Windows complet (ex: "C:\") → chemin absolu, pas de jointure
|
||||
if (/^[A-Za-z]:\\$/.test(name)) return name
|
||||
if (base === '/') return `/${name}`
|
||||
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base
|
||||
const cleanBase = base.replace(/[\\/]+$/, '')
|
||||
return `${cleanBase}/${name}`
|
||||
}
|
||||
|
||||
@@ -114,10 +117,11 @@ async function navigateTo(p: string) {
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
current.value = data.path
|
||||
inputPath.value = data.path
|
||||
parent.value = data.parent
|
||||
dirs.value = data.dirs
|
||||
current.value = data.path
|
||||
inputPath.value = data.path
|
||||
parent.value = data.parent
|
||||
dirs.value = data.dirs
|
||||
drivesRoot.value = data.drivesRoot ?? false
|
||||
} catch {
|
||||
error.value = 'Impossible de lire ce dossier'
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Barre search + tri + colonnes -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
<input
|
||||
:value="search"
|
||||
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
type="text" placeholder="Rechercher..."
|
||||
class="settings-input pl-9 w-full py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tri -->
|
||||
<div class="relative shrink-0" ref="sortRef">
|
||||
<button @click="sortOpen = !sortOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="activeSort !== 'none' ? 'border-accent text-accent' : ''">
|
||||
<ArrowUpDown :size="14" />
|
||||
<span class="hidden md:inline text-xs">Trier</span>
|
||||
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
</button>
|
||||
<div v-if="sortOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl">
|
||||
<button v-for="s in sortOptions" :key="s.value" @click="handleSort(s.value)"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
|
||||
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'">
|
||||
{{ s.label }}
|
||||
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
|
||||
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colonnes -->
|
||||
<div class="relative shrink-0" ref="colRef">
|
||||
<button @click="colOpen = !colOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="colOpen ? 'border-accent text-accent' : ''">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<span class="hidden md:inline text-xs">Colonnes</span>
|
||||
</button>
|
||||
<div v-if="colOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-52 flex flex-col gap-0.5 shadow-xl">
|
||||
<p class="text-[10px] text-muted px-3 py-1">Colonnes affichées</p>
|
||||
<label v-for="col in columnOptions" :key="col.key" class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs cursor-pointer hover:bg-hover transition-colors">
|
||||
<input type="checkbox" :checked="columns[col.key]" @change="emit('toggle-col', col.key)" class="accent-accent" />
|
||||
{{ col.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglets + filtres -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
|
||||
<button @click="emit('update:activeTab', 'active')" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'active' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
|
||||
En cours
|
||||
<span v-if="activeCount > 0" class="ml-1.5 text-[10px] text-accent">{{ activeCount }}</span>
|
||||
</button>
|
||||
<button @click="emit('update:activeTab', 'done')" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'done' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
|
||||
Terminés
|
||||
<span v-if="doneCount > 0" class="ml-1.5 text-[10px] text-accent">{{ doneCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'done'" class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-xs text-muted">Seeds uniquement</span>
|
||||
<button @click="emit('update:seedingOnly', !seedingOnly)" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="seedingOnly ? 'bg-green-500' : 'bg-border'">
|
||||
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="seedingOnly ? 'translate-x-[18px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-xs text-muted">Masquer importés</span>
|
||||
<button @click="emit('update:hideImported', !hideImported)" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="hideImported ? 'bg-accent' : 'bg-border'">
|
||||
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Search, ArrowUpDown } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
const props = defineProps<{
|
||||
search : string
|
||||
activeSort : string
|
||||
sortDir : 'asc' | 'desc'
|
||||
activeTab : 'active' | 'done'
|
||||
seedingOnly : boolean
|
||||
hideImported: boolean
|
||||
columns : Record<string, boolean>
|
||||
activeCount : number
|
||||
doneCount : number
|
||||
columnOptions: { key: string; label: string }[]
|
||||
sortOptions : { label: string; value: string }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:search' : [value: string]
|
||||
'update:activeTab' : [value: 'active' | 'done']
|
||||
'update:seedingOnly': [value: boolean]
|
||||
'update:hideImported': [value: boolean]
|
||||
'sort' : [value: string]
|
||||
'toggle-col' : [key: string]
|
||||
}>()
|
||||
|
||||
const sortOpen = ref(false)
|
||||
const colOpen = ref(false)
|
||||
const sortRef = ref<HTMLElement | null>(null)
|
||||
const colRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(sortRef, () => { sortOpen.value = false })
|
||||
onClickOutside(colRef, () => { colOpen.value = false })
|
||||
|
||||
function handleSort(val: string) {
|
||||
emit('sort', val)
|
||||
sortOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
|
||||
<!-- Nom + état -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium truncate">{{ torrent.name }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(torrent.state).class">{{ stateBadge(torrent.state).label }}</span>
|
||||
<span v-if="columns.client" class="text-xs text-muted">{{ torrent.client_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-semibold text-primary">{{ clampedProgress }}%</p>
|
||||
<p v-if="columns.size" class="text-xs text-muted mt-0.5">{{ formatSize(torrent.downloaded) }} / {{ formatSize(torrent.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div class="h-1 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500"
|
||||
:class="torrent.state === 'seeding' || torrent.state === 'unknown' ? 'bg-green-500' : torrent.state === 'error' ? 'bg-red-500' : torrent.state === 'paused' ? 'bg-muted' : 'bg-accent'"
|
||||
:style="{ width: `${clampedProgress}%` }" />
|
||||
</div>
|
||||
|
||||
<!-- Infos + actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 text-xs text-muted">
|
||||
<span v-if="torrent.state === 'downloading'">↓ {{ formatSpeed(torrent.speed) }}</span>
|
||||
<span v-if="torrent.state === 'downloading' && torrent.eta > 0">ETA {{ formatEta(torrent.eta) }}</span>
|
||||
<template v-if="torrent.state === 'seeding'">
|
||||
<span class="text-green-500">Complété</span>
|
||||
<span v-if="columns.ratio" class="font-mono" :class="torrent.ratio >= 1 ? 'text-green-400' : torrent.ratio >= 0.5 ? 'text-yellow-500' : 'text-muted'">R {{ torrent.ratio?.toFixed(2) ?? '0.00' }}</span>
|
||||
<span v-if="columns.uploaded">↑ {{ formatSize(torrent.uploaded ?? 0) }}</span>
|
||||
<span v-if="columns.upspeed && torrent.upspeed > 0">{{ formatSpeed(torrent.upspeed) }}</span>
|
||||
</template>
|
||||
<span v-if="torrent.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<!-- Actions terminés -->
|
||||
<template v-if="torrent.state === 'seeding' || torrent.state === 'unknown'">
|
||||
<!-- Erreurs -->
|
||||
<div v-if="torrent.errorFiles?.length > 0" class="relative group/err">
|
||||
<span class="text-xs px-2 py-0.5 rounded border border-red-500/40 text-red-400 cursor-default">{{ torrent.errorFiles.length }} erreur{{ torrent.errorFiles.length > 1 ? 's' : '' }}</span>
|
||||
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
|
||||
<div class="bg-card border border-red-500/30 rounded-lg p-3 text-xs shadow-xl">
|
||||
<p class="text-red-400 mb-2 font-medium">Fichiers en erreur</p>
|
||||
<div v-for="e in torrent.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
|
||||
<p class="text-primary truncate">{{ e.file }}</p>
|
||||
<p class="text-muted">{{ e.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badge import -->
|
||||
<span class="text-xs px-2 py-0.5 rounded border"
|
||||
:class="torrent.organizeState === 'done' ? 'border-green-500/40 text-green-500' : torrent.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' : 'border-border text-muted'">
|
||||
{{ torrent.organizeState === 'done'
|
||||
? `Importé (${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total})`
|
||||
: torrent.organizeState === 'partial'
|
||||
? `En cours (${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total})`
|
||||
: 'Non importé' }}
|
||||
</span>
|
||||
|
||||
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing" class="btn-secondary text-xs py-1">
|
||||
{{ importing ? '...' : 'Importer' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Bouton supprimer -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="emit('toggle-confirm', torrent.hash)"
|
||||
:disabled="deleting"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg border border-border text-muted hover:border-red-500/40 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showConfirmDelete" class="absolute bottom-full right-0 mb-2 bg-card border border-border rounded-xl p-3 z-10 w-52 shadow-xl">
|
||||
<p class="text-xs text-primary font-medium mb-2">
|
||||
{{ tab === 'active' ? 'Annuler ce téléchargement ?' : 'Supprimer ce torrent ?' }}
|
||||
</p>
|
||||
<label class="flex items-center gap-2 text-xs text-muted mb-3 cursor-pointer">
|
||||
<input type="checkbox" v-model="withFiles" class="accent-red-500" />
|
||||
Supprimer aussi les fichiers
|
||||
</label>
|
||||
<div class="flex gap-1.5">
|
||||
<button @click="emit('delete', torrent, withFiles)" :disabled="deleting" class="flex-1 text-xs py-1 px-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-colors">
|
||||
{{ deleting ? '...' : 'Confirmer' }}
|
||||
</button>
|
||||
<button @click="emit('toggle-confirm', null)" class="flex-1 text-xs py-1 px-2 rounded-lg border border-border text-muted hover:text-primary transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
torrent : any
|
||||
tab : 'active' | 'done'
|
||||
columns : Record<string, boolean>
|
||||
importing : boolean
|
||||
deleting : boolean
|
||||
showConfirmDelete: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
import : [torrent: any]
|
||||
delete : [torrent: any, withFiles: boolean]
|
||||
'toggle-confirm': [hash: string | null]
|
||||
}>()
|
||||
|
||||
const withFiles = ref(false)
|
||||
|
||||
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.torrent.progress ?? 0)))
|
||||
|
||||
function stateBadge(state: string) {
|
||||
return ({
|
||||
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
|
||||
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
|
||||
paused : { label: 'En pause', class: 'border-border text-muted' },
|
||||
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
|
||||
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
} as Record<string, any>)[state] ?? { label: 'Inconnu', class: 'border-border text-muted' }
|
||||
}
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
function formatSpeed(bps: number) { return `${formatSize(bps)}/s` }
|
||||
function formatEta(seconds: number): string {
|
||||
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
|
||||
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
|
||||
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
|
||||
return `${s}s`
|
||||
}
|
||||
</script>
|
||||
@@ -73,7 +73,9 @@ defineProps<{ items: NavItem[] }>()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const currentVersion = ref('dev')
|
||||
const currentVersion = ref(
|
||||
import.meta.env.DEV ? 'dev' : ''
|
||||
)
|
||||
const latestVersion = ref('')
|
||||
const latestReleaseUrl = ref('https://github.com/masutayunikon/FanKarr/releases/latest')
|
||||
const updateAvailable = ref(false)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div v-if="serie.fanart_image" class="absolute inset-0 h-64 overflow-hidden pointer-events-none">
|
||||
<img
|
||||
:src="serie.fanart_image"
|
||||
class="w-full h-full object-cover opacity-15"
|
||||
@error="(e) => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-main" />
|
||||
</div>
|
||||
|
||||
<div class="relative px-4 md:px-8 pt-6 pb-10 flex items-start gap-6 min-h-[200px]">
|
||||
<!-- Poster -->
|
||||
<div class="shrink-0 w-32 aspect-[2/3] rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
|
||||
<img v-if="serie.poster_image" :src="serie.poster_image" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full aspect-[2/3] bg-card flex items-center justify-center text-muted">
|
||||
<Tv :size="28" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Infos -->
|
||||
<div class="flex flex-col gap-3 justify-end min-w-0">
|
||||
<RouterLink to="/series" class="inline-flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-fit">
|
||||
<ArrowLeft :size="13" />
|
||||
Séries
|
||||
</RouterLink>
|
||||
|
||||
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ serie.title }}</h1>
|
||||
|
||||
<!-- Métadonnées -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="serie.year" class="text-sm text-muted">{{ serie.year }}</span>
|
||||
<span
|
||||
v-if="serie.status"
|
||||
class="text-xs px-2 py-0.5 rounded-full border"
|
||||
:class="serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/15 text-green-400 border-green-500/20'
|
||||
: 'bg-hover text-muted border-border'"
|
||||
>{{ serie.status }}</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="serie.plot"
|
||||
@click="expanded = !expanded"
|
||||
class="text-sm text-secondary leading-relaxed max-w-2xl cursor-pointer select-none transition-all"
|
||||
:class="expanded ? '' : 'line-clamp-3'"
|
||||
>
|
||||
{{ serie.plot }}
|
||||
</p>
|
||||
|
||||
<!-- Lien wiki -->
|
||||
<a
|
||||
v-if="serie.wiki"
|
||||
:href="serie.wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors w-fit"
|
||||
>
|
||||
<ExternalLink :size="12" />
|
||||
Wiki
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowLeft, Tv, ExternalLink } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{ serie: any }>()
|
||||
const expanded = ref(false)
|
||||
</script>
|
||||
@@ -0,0 +1,672 @@
|
||||
<template>
|
||||
<div class="bg-card border border-border rounded-xl">
|
||||
<!-- Header saison -->
|
||||
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="text-muted hover:text-primary transition-colors" @click="emit('toggle', season.id)">
|
||||
<ChevronUp :size="15" class="transition-transform duration-200" :class="collapsed ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-primary">
|
||||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted font-normal ml-1">
|
||||
— {{ season.title }}
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
|
||||
<template v-if="availableCount > 0"> · {{ availableCount }}/{{ season.episodes.length }} dispo</template>
|
||||
<span
|
||||
v-if="season.organized_state !== 'none'"
|
||||
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px]"
|
||||
:class="season.organized_state === 'complete' ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-500'"
|
||||
>
|
||||
{{ season.organized_state === 'complete'
|
||||
? `✓ ${season.organized_count} importé${season.organized_count > 1 ? 's' : ''}`
|
||||
: `${season.organized_count}/${availableCount || season.episodes.length} importés` }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Boutons saison -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Corbeille saison -->
|
||||
<button
|
||||
v-if="season.organized_count > 0"
|
||||
@click.stop="openUnimportSeasonModal"
|
||||
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20"
|
||||
title="Désimporter la saison"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
<!-- Pas de pack saison : bouton "Saison" (dropdown si plusieurs packs via intégrales) -->
|
||||
<template v-if="season.torrents.length === 0 && hasDownloadable">
|
||||
<div v-if="uniquePackOptions.length > 1" class="relative" @click.stop>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
|
||||
@click="seasonMenuOpen = !seasonMenuOpen"
|
||||
>
|
||||
<component :is="downloadIcon" />
|
||||
Saison ▾
|
||||
</button>
|
||||
<div v-if="seasonMenuOpen" class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-56 shadow-xl flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="opt in uniquePackOptions"
|
||||
:key="opt.infohash"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
|
||||
@click="emit('downloadSeason', season, opt.infohash); seasonMenuOpen = false"
|
||||
>
|
||||
<component :is="downloadIcon" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
@click="emit('downloadSeason', season)"
|
||||
:disabled="downloadingSeason"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border bg-accent-muted text-accent border-accent/20 hover:bg-accent/20 transition"
|
||||
>
|
||||
<component :is="downloadingSeason ? loaderIcon : downloadIcon" />
|
||||
{{ downloadingSeason ? 'Envoi…' : 'Saison' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Pack saison unique -->
|
||||
<button
|
||||
v-if="season.torrents.length === 1"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
|
||||
:class="canDownloadSeason
|
||||
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
|
||||
: 'text-muted border-border cursor-not-allowed opacity-50'"
|
||||
:disabled="!canDownloadSeason"
|
||||
@click="canDownloadSeason && emit('download', `season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
|
||||
>
|
||||
<component :is="seasonBtnIcon" />
|
||||
{{ seasonBtnLabel }}
|
||||
</button>
|
||||
|
||||
<!-- Dropdown packs saison multiples -->
|
||||
<div v-if="season.torrents.length > 1" class="relative" @click.stop>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
|
||||
@click="seasonMenuOpen = !seasonMenuOpen"
|
||||
>
|
||||
<component :is="downloadIcon" />
|
||||
Saison ▾
|
||||
</button>
|
||||
<div v-if="seasonMenuOpen" class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="(t, i) in season.torrents"
|
||||
:key="i"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
|
||||
:class="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
:disabled="isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)"
|
||||
@click="!isDownloaded(`season-${season.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `season-${season.id}-${i}`, t.torrent_url, t.magnet), seasonMenuOpen = false)"
|
||||
>
|
||||
<component :is="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||||
{{ groupLabels(season.torrents)[i] }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progression saison -->
|
||||
<div v-if="seasonProgress && seasonProgress.progress < 100" class="px-5 pb-3 -mt-1">
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${seasonProgress.progress}%` }" />
|
||||
</div>
|
||||
<p class="text-[11px] text-muted mt-1">{{ seasonProgress.progress }}%</p>
|
||||
</div>
|
||||
|
||||
<!-- Épisodes -->
|
||||
<div v-if="!collapsed" class="divide-y divide-border/50">
|
||||
<div
|
||||
v-for="ep in season.episodes"
|
||||
:key="ep.id"
|
||||
class="px-5 py-3 hover:bg-hover/50 transition-colors last:rounded-b-xl"
|
||||
:class="{ 'opacity-40': !ep.available && !ep.organized }"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Thumbnail -->
|
||||
<div class="shrink-0 w-20 aspect-video rounded-md overflow-hidden bg-shell hidden sm:flex items-center justify-center text-muted text-xs font-mono">
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
|
||||
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Infos épisode -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted shrink-0 font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
|
||||
</span>
|
||||
<span class="text-sm text-primary truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||
<span
|
||||
v-if="ep.fankai === false || ep.torrent?.fankai === false"
|
||||
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||||
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
|
||||
>Hors Fankai</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
<span v-if="ep.aired" class="text-xs text-muted">{{ formatDate(ep.aired) }}</span>
|
||||
<span v-if="ep.duration" class="text-xs text-muted">{{ formatDuration(ep.duration) }}</span>
|
||||
<!-- Badge langue -->
|
||||
<span
|
||||
v-if="epLang(ep) === 'MULTI'"
|
||||
title="Audio japonais + français"
|
||||
class="shrink-0 inline-flex items-center gap-1 text-[10px] font-semibold leading-none px-1.5 py-0.5 rounded border bg-blue-500/10 border-blue-500/20 text-blue-300 select-none tracking-wide"
|
||||
>
|
||||
<!-- JP flag -->
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#fff"/><circle cx="7" cy="5" r="3" fill="#BC002D"/></svg>
|
||||
<!-- FR flag -->
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#ED2939"/><rect width="9.33" height="10" fill="#fff"/><rect width="4.67" height="10" fill="#002395"/></svg>
|
||||
MULTI
|
||||
</span>
|
||||
<span
|
||||
v-else-if="epLang(ep) === 'VOSTFR'"
|
||||
title="Audio japonais · Sous-titres français"
|
||||
class="shrink-0 inline-flex items-center gap-1 text-[10px] font-semibold leading-none px-1.5 py-0.5 rounded border bg-amber-500/10 border-amber-500/20 text-amber-300 select-none tracking-wide"
|
||||
>
|
||||
<!-- JP flag -->
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#fff"/><circle cx="7" cy="5" r="3" fill="#BC002D"/></svg>
|
||||
<!-- FR flag -->
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#ED2939"/><rect width="9.33" height="10" fill="#fff"/><rect width="4.67" height="10" fill="#002395"/></svg>
|
||||
VOSTFR
|
||||
</span>
|
||||
<!-- Badge épisodes -->
|
||||
<span
|
||||
v-if="parseEpChap(ep.plot).episodes"
|
||||
class="shrink-0 text-[10px] font-medium leading-none px-1.5 py-0.5 rounded border bg-violet-500/10 border-violet-500/20 text-violet-300 select-none"
|
||||
:title="`Plage d'épisodes couverts`"
|
||||
>{{ parseEpChap(ep.plot).episodes }}</span>
|
||||
<!-- Badge chapitres -->
|
||||
<span
|
||||
v-if="parseEpChap(ep.plot).chapters"
|
||||
class="shrink-0 text-[10px] font-medium leading-none px-1.5 py-0.5 rounded border bg-teal-500/10 border-teal-500/20 text-teal-300 select-none"
|
||||
:title="`Plage de chapitres manga couverts`"
|
||||
>{{ parseEpChap(ep.plot).chapters }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton téléchargement épisode (uniquement si non organisé) -->
|
||||
<div v-if="!ep.organized" class="relative shrink-0" @click.stop>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
|
||||
:class="epBtnClass(ep)"
|
||||
:disabled="epState(ep) !== 'idle'"
|
||||
@click="handleEpBtnClick(ep)"
|
||||
>
|
||||
<component :is="epStateIcon(ep)" />
|
||||
</button>
|
||||
<!-- Dropdown quand plusieurs options de torrent -->
|
||||
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1"
|
||||
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="(t, i) in ep.torrents"
|
||||
:key="i"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
|
||||
:class="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
:disabled="isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)"
|
||||
@click="!isDownloaded(`ep-${ep.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `ep-${ep.id}-${i}`, t.torrent_url, t.magnet, t.file_index ?? null, t.file_path ?? null, t.infohash ?? null), epOptionsOpen = null)"
|
||||
>
|
||||
<component :is="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||||
{{ groupLabels(ep.torrents)[i] }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions épisode importé -->
|
||||
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
|
||||
<!-- Badge rename cliquable -->
|
||||
<button
|
||||
v-if="epNeedsRename(ep)"
|
||||
@click.stop="emit('renameEpisode', ep, season)"
|
||||
:disabled="epActionLoading[ep.id]"
|
||||
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-yellow-500/10 text-yellow-400 border-yellow-500/20 hover:bg-yellow-500/20 transition-colors"
|
||||
:title="`Actuel : ${organizedByEpisode[String(ep.id)]?.dest_filename}\nAttendu : ${epExpectedName(ep)}`"
|
||||
>Rename</button>
|
||||
<!-- Corbeille épisode -->
|
||||
<button
|
||||
@click.stop="openUnimportModal(ep, season)"
|
||||
:disabled="epActionLoading[ep.id]"
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
title="Désimporter l'épisode"
|
||||
>
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Chevron synopsis (à droite, visible si l'épisode a un plot) -->
|
||||
<button
|
||||
v-if="ep.plot"
|
||||
@click.stop="togglePlot(ep.id)"
|
||||
class="w-6 h-6 flex items-center justify-center rounded transition-colors shrink-0"
|
||||
:class="plotOpen === ep.id ? 'text-accent' : 'text-muted hover:text-primary'"
|
||||
title="Voir le synopsis"
|
||||
>
|
||||
<ChevronUp :size="14" class="transition-transform duration-200" :class="plotOpen === ep.id ? '' : 'rotate-180'" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Barre progression épisode -->
|
||||
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="mt-1.5 sm:pl-[92px]">
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${epProgress(ep)!.progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Synopsis -->
|
||||
<div v-if="plotOpen === ep.id && ep.plot" class="mt-2 sm:pl-[92px]">
|
||||
<p class="text-xs text-muted leading-relaxed" style="white-space: pre-line">{{ parseEpChap(ep.plot).cleanPlot }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal désimport épisode -->
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportModal = false">
|
||||
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
|
||||
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter l'épisode</h3>
|
||||
<p class="text-xs text-muted mb-4">L'épisode sera retiré de la bibliothèque.</p>
|
||||
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteFileOnUnimport" class="accent-red-500" />
|
||||
<span class="text-xs text-muted">Supprimer le fichier du disque</span>
|
||||
</label>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="unimportModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
|
||||
<button @click="confirmUnimport" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
|
||||
{{ deleteFileOnUnimport ? 'Supprimer' : 'Désimporter' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Modal désimport saison -->
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportSeasonModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportSeasonModal = false">
|
||||
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
|
||||
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter la saison</h3>
|
||||
<p class="text-xs text-muted mb-4">Tous les épisodes importés de cette saison seront retirés de la bibliothèque.</p>
|
||||
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteSeasonFiles" class="accent-red-500" />
|
||||
<span class="text-xs text-muted">Supprimer les fichiers du disque</span>
|
||||
</label>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="unimportSeasonModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
|
||||
<button @click="confirmUnimportSeason" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
|
||||
{{ deleteSeasonFiles ? 'Supprimer' : 'Désimporter' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
|
||||
import { ChevronUp, Download, Loader, Check, X, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[] }
|
||||
|
||||
const props = defineProps<{
|
||||
season : any
|
||||
collapsed : boolean
|
||||
activeTorrents : ActiveTorrent[]
|
||||
downloading : string[]
|
||||
downloaded : string[]
|
||||
organizedByEpisode: Record<string, any>
|
||||
epActionLoading : Record<number, boolean>
|
||||
downloadingSeason ?: boolean
|
||||
nfoSupport ?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle : [id: number]
|
||||
download : [key: string, url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null]
|
||||
downloadSeason : [season: any, packHash?: string]
|
||||
renameEpisode : [ep: any, season: any]
|
||||
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
|
||||
unimportSeason : [season: any, deleteFiles: boolean]
|
||||
}>()
|
||||
|
||||
const epOptionsOpen = ref<number | null>(null)
|
||||
const plotOpen = ref<number | null>(null)
|
||||
const seasonMenuOpen = ref(false)
|
||||
const unimportModal = ref(false)
|
||||
const deleteFileOnUnimport = ref(false)
|
||||
const unimportEpTarget = ref<{ ep: any; season: any } | null>(null)
|
||||
const unimportSeasonModal = ref(false)
|
||||
const deleteSeasonFiles = ref(false)
|
||||
const downloadIcon = h(Download, { size: 12 })
|
||||
const loaderIcon = h(Loader, { size: 12, class: 'animate-spin' })
|
||||
const checkIcon = h(Check, { size: 12 })
|
||||
|
||||
// ── Helpers hash / progress ────────────────────────────────────
|
||||
function extractHash(torrent: any): string | null {
|
||||
// Préférer l'infohash direct (présent même si le magnet est absent)
|
||||
if (torrent?.infohash) return torrent.infohash.toLowerCase()
|
||||
if (!torrent?.magnet) return null
|
||||
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
return m ? m[1].toLowerCase() : null
|
||||
}
|
||||
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
|
||||
if (!hash) return null
|
||||
return props.activeTorrents.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
|
||||
}
|
||||
function isDownloading(key: string) { return props.downloading.includes(key) }
|
||||
function isDownloaded(key: string) { return props.downloaded.includes(key) }
|
||||
function isAlreadyQueued(torrent: any): boolean {
|
||||
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas :
|
||||
// le pack peut être actif sans que CE fichier spécifique soit en téléchargement.
|
||||
// On s'appuie uniquement sur downloading/downloaded pour ces cas.
|
||||
if (torrent?.file_index != null) return false
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||||
}
|
||||
|
||||
// ── Computed saison ────────────────────────────────────────────
|
||||
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
|
||||
const seasonProgress = computed(() => {
|
||||
if (props.season.torrents && props.season.torrents.length > 1) {
|
||||
for (const t of props.season.torrents) {
|
||||
const prog = torrentProgress(extractHash(t))
|
||||
if (prog) return prog
|
||||
}
|
||||
return null
|
||||
}
|
||||
return torrentProgress(extractHash(props.season.torrent))
|
||||
})
|
||||
const hasDownloadable = computed(() =>
|
||||
props.season.episodes.some((ep: any) =>
|
||||
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)
|
||||
)
|
||||
)
|
||||
const canDownloadSeason = computed(() =>
|
||||
!!props.season.torrent && !isDownloading(`season-${props.season.id}`) && !isDownloaded(`season-${props.season.id}`) && !isAlreadyQueued(props.season.torrent) && props.season.organized_state !== 'complete'
|
||||
)
|
||||
const seasonBtnLabel = computed(() => {
|
||||
if (props.season.organized_state === 'complete') return 'Importé'
|
||||
if (isAlreadyQueued(props.season.torrent)) return 'Déjà ajouté'
|
||||
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyé'
|
||||
return 'Saison entière'
|
||||
})
|
||||
const seasonBtnIcon = computed(() => {
|
||||
if (props.season.organized_state === 'complete' || isDownloaded(`season-${props.season.id}`) || isAlreadyQueued(props.season.torrent)) return h(Check, { size: 12 })
|
||||
if (isDownloading(`season-${props.season.id}`)) return h(Loader, { size: 12, class: 'animate-spin' })
|
||||
if (!props.season.torrent) return h(X, { size: 12 })
|
||||
return h(Download, { size: 12 })
|
||||
})
|
||||
|
||||
// ── État épisode ───────────────────────────────────────────────
|
||||
|
||||
// Vérifie si n'importe quelle clé de téléchargement pour cet épisode est marquée done/downloading
|
||||
// (couvre ep-{id} pour torrent unique ET ep-{id}-{i} pour multi-torrent)
|
||||
function epAnyDownloaded(ep: any): boolean {
|
||||
if (isDownloaded(`ep-${ep.id}`)) return true
|
||||
const count = ep.torrents?.length ?? 0
|
||||
for (let i = 0; i < count; i++) { if (isDownloaded(`ep-${ep.id}-${i}`)) return true }
|
||||
return false
|
||||
}
|
||||
function epAnyDownloading(ep: any): boolean {
|
||||
if (isDownloading(`ep-${ep.id}`)) return true
|
||||
const count = ep.torrents?.length ?? 0
|
||||
for (let i = 0; i < count; i++) { if (isDownloading(`ep-${ep.id}-${i}`)) return true }
|
||||
return false
|
||||
}
|
||||
|
||||
function epProgress(ep: any): ActiveTorrent | null {
|
||||
if (ep.organized) return null
|
||||
|
||||
// Chercher parmi toutes les options de torrent, pas seulement ep.torrent (= torrents[0])
|
||||
const candidates: Array<{ torrent: any; key0: string; keyI: string }> = []
|
||||
if (ep.torrents?.length > 0) {
|
||||
ep.torrents.forEach((t: any, i: number) => candidates.push({ torrent: t, key0: `ep-${ep.id}`, keyI: `ep-${ep.id}-${i}` }))
|
||||
} else if (ep.torrent) {
|
||||
candidates.push({ torrent: ep.torrent, key0: `ep-${ep.id}`, keyI: `ep-${ep.id}` })
|
||||
}
|
||||
|
||||
for (const { torrent: t, key0, keyI } of candidates) {
|
||||
const active = torrentProgress(extractHash(t))
|
||||
if (!active) continue
|
||||
|
||||
if (t.file_index != null) {
|
||||
const sessionDl = isDownloaded(key0) || isDownloaded(keyI)
|
||||
if (active.files && active.files.length > 0) {
|
||||
const file = active.files.find(f => f.index === t.file_index)
|
||||
// Avec données par fichier on se fie à la progression réelle du fichier précis.
|
||||
// Si le fichier est à 100% sans session active → déjà téléchargé hors session
|
||||
// (ex : désimporté puis re-consulté) → ne pas afficher le spinner.
|
||||
if (file != null && file.priority !== 0 && (sessionDl || (file.progress > 0 && file.progress < 1))) {
|
||||
return { ...active, progress: Math.round(file.progress * 100) }
|
||||
}
|
||||
if (!sessionDl) continue
|
||||
} else {
|
||||
// Pas de données par fichier : ignorer si pas de session active
|
||||
if (!sessionDl) continue
|
||||
return { ...active, progress: active.state === 'seeding' ? 100 : active.progress }
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function handleEpBtnClick(ep: any) {
|
||||
if (ep.organized) return
|
||||
if (ep.torrents && ep.torrents.length > 1) {
|
||||
// Plusieurs options → toggle dropdown
|
||||
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
|
||||
return
|
||||
}
|
||||
// Option unique → téléchargement direct
|
||||
if (ep.torrent && !isAlreadyQueued(ep.torrent)) {
|
||||
emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet, ep.torrent.file_index ?? null, ep.torrent.file_path ?? null, ep.torrent.infohash ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
|
||||
// Vert uniquement quand importé dans la bibliothèque
|
||||
if (ep.organized) return 'done'
|
||||
// Spinner si : envoyé au client, déjà en cours dans le client, ou fichier en cours/terminé
|
||||
if (epAnyDownloaded(ep) || epAnyDownloading(ep) || isAlreadyQueued(ep.torrent)) return 'loading'
|
||||
const prog = epProgress(ep)
|
||||
if (prog) return 'loading'
|
||||
if (!ep.torrent || !ep.available) return 'unavailable'
|
||||
return 'idle'
|
||||
}
|
||||
function epStateIcon(ep: any) {
|
||||
const state = epState(ep)
|
||||
if (state === 'done') return h(Check, { size: 14 })
|
||||
if (state === 'loading') return h(Loader, { size: 14, class: 'animate-spin' })
|
||||
if (state === 'unavailable') return h(X, { size: 14 })
|
||||
return h(Download, { size: 14 })
|
||||
}
|
||||
function epBtnClass(ep: any): string {
|
||||
const state = epState(ep)
|
||||
if (state === 'done') return 'border-green-500/30 text-green-500 bg-green-500/10 cursor-default'
|
||||
if (state === 'loading') return 'border-accent/30 text-accent bg-accent-muted cursor-default'
|
||||
if (state === 'unavailable') return 'border-border text-muted opacity-40 cursor-not-allowed'
|
||||
return 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'
|
||||
}
|
||||
|
||||
// ── Rename helpers ─────────────────────────────────────────────
|
||||
function epExpectedName(ep: any): string {
|
||||
const entry = props.organizedByEpisode[String(ep.id)]
|
||||
if (!entry) return ''
|
||||
const srcExt = entry.dest_filename ? '.' + entry.dest_filename.split('.').pop() : '.mkv'
|
||||
if (props.nfoSupport) {
|
||||
return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename
|
||||
} else {
|
||||
// Préférer le formatted_name du torrent qui correspond au fichier importé.
|
||||
// On essaie de matcher par hash (si l'entrée organized l'expose), sinon on
|
||||
// cherche le premier torrent qui a un formatted_name.
|
||||
const usedHash = entry.hash?.toLowerCase() ?? null
|
||||
const matchedName = usedHash
|
||||
? (ep.torrents ?? []).find((t: any) => t.infohash === usedHash)?.formatted_name
|
||||
: null
|
||||
const name = matchedName
|
||||
?? (ep.torrents ?? []).find((t: any) => t.formatted_name)?.formatted_name
|
||||
?? ep.formatted_name
|
||||
return name?.trim() ? name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : entry.dest_filename
|
||||
}
|
||||
}
|
||||
function epNeedsRename(ep: any): boolean {
|
||||
const entry = props.organizedByEpisode[String(ep.id)]
|
||||
if (!entry) return false
|
||||
return entry.needs_rename === true
|
||||
}
|
||||
|
||||
// ── Extraction épisodes / chapitres depuis le synopsis ─────────
|
||||
// Cache léger pour éviter de recalculer pour chaque accès template (badges + synopsis)
|
||||
const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>()
|
||||
/**
|
||||
* Extrait les plages d'épisodes et chapitres depuis un synopsis.
|
||||
* Ex: "...épisodes 628 à 634 soit les chapitres 700 à 706."
|
||||
* → { episodes: "628–634", chapters: "700–706", cleanPlot: "..." }
|
||||
* La phrase (ou ligne) contenant ces infos est retirée du synopsis affiché.
|
||||
*/
|
||||
function _parseEpChap(plot: string | null | undefined): {
|
||||
episodes: string | null
|
||||
chapters: string | null
|
||||
cleanPlot: string
|
||||
} {
|
||||
const text = plot ?? ''
|
||||
|
||||
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
||||
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
||||
|
||||
const episodes = epMatch
|
||||
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`)
|
||||
: null
|
||||
const chapters = chapMatch
|
||||
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
|
||||
: null
|
||||
|
||||
// Retirer les lignes contenant les références d'épisodes ou chapitres
|
||||
let cleanPlot = text
|
||||
if (episodes || chapters) {
|
||||
cleanPlot = text
|
||||
.split(/\r?\n/)
|
||||
.filter(line => !/[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+/i.test(line))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
return { episodes, chapters, cleanPlot }
|
||||
}
|
||||
function parseEpChap(plot: string | null | undefined) {
|
||||
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
|
||||
const result = _parseEpChap(plot)
|
||||
_epChapCache.set(plot, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Détection langue ───────────────────────────────────────────
|
||||
function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
|
||||
const sources: string[] = [
|
||||
ep.formatted_name ?? '',
|
||||
ep.torrent?.torrent_name ?? '',
|
||||
ep.torrent?.raw ?? '',
|
||||
...((ep.torrents ?? []) as any[]).map((t: any) => `${t.torrent_name ?? ''} ${t.raw ?? ''}`),
|
||||
]
|
||||
for (const s of sources) {
|
||||
if (!s) continue
|
||||
if (/\bMULTI\b/i.test(s)) return 'MULTI'
|
||||
if (/\bVOSTFR\b/i.test(s)) return 'VOSTFR'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Labels de groupe ───────────────────────────────────────────
|
||||
// Si tous les torrent_name sont présents ET distincts → on les utilise.
|
||||
// Sinon → titre Nyaa brut (raw), plus informatif quand les noms se ressemblent.
|
||||
function labelFromRaw(raw: string, index: number): string {
|
||||
if (!raw) return `Option ${index + 1}`
|
||||
return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
|
||||
}
|
||||
|
||||
function groupLabels(torrents: any[]): string[] {
|
||||
const names = torrents.map((t: any) => t.torrent_name ?? null)
|
||||
const allPresent = names.every(n => n !== null && String(n).trim() !== '')
|
||||
const allUnique = allPresent && new Set(names).size === names.length
|
||||
if (allUnique) return names as string[]
|
||||
// Fallback : titre Nyaa brut
|
||||
return torrents.map((t: any, i: number) => labelFromRaw(t.raw ?? '', i))
|
||||
}
|
||||
|
||||
// Packs parents distincts couvrant cette saison (quand pas de pack saison explicite)
|
||||
// Permet le dropdown "Saison ▾" quand une saison est couverte par plusieurs intégrales
|
||||
const uniquePackOptions = computed(() => {
|
||||
if (props.season.torrents?.length) return []
|
||||
const seen = new Set<string>()
|
||||
const raw: { infohash: string; torrent: any }[] = []
|
||||
for (const ep of props.season.episodes) {
|
||||
if (!ep.available) continue
|
||||
for (const t of (ep.torrents ?? [])) {
|
||||
const hash = t.infohash ?? extractHash(t)
|
||||
if (!hash || seen.has(hash)) continue
|
||||
seen.add(hash)
|
||||
raw.push({ infohash: hash, torrent: t })
|
||||
}
|
||||
}
|
||||
if (raw.length <= 1) return []
|
||||
const labels = groupLabels(raw.map(r => r.torrent))
|
||||
return raw.map((r, i) => ({ infohash: r.infohash, label: labels[i] }))
|
||||
})
|
||||
|
||||
function formatDate(d: string): string {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) return m > 0 ? `${h}h ${m}min` : `${h}h`
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
function togglePlot(epId: number) {
|
||||
plotOpen.value = plotOpen.value === epId ? null : epId
|
||||
}
|
||||
|
||||
function openUnimportModal(ep: any, season: any) {
|
||||
unimportEpTarget.value = { ep, season }
|
||||
deleteFileOnUnimport.value = false
|
||||
unimportModal.value = true
|
||||
}
|
||||
|
||||
function confirmUnimport() {
|
||||
if (!unimportEpTarget.value) return
|
||||
emit('unimportEpisode', unimportEpTarget.value.ep, unimportEpTarget.value.season, deleteFileOnUnimport.value)
|
||||
unimportModal.value = false
|
||||
unimportEpTarget.value = null
|
||||
}
|
||||
|
||||
function openUnimportSeasonModal() {
|
||||
deleteSeasonFiles.value = false
|
||||
unimportSeasonModal.value = true
|
||||
}
|
||||
|
||||
function confirmUnimportSeason() {
|
||||
emit('unimportSeason', props.season, deleteSeasonFiles.value)
|
||||
unimportSeasonModal.value = false
|
||||
}
|
||||
|
||||
function closeAllMenus() {
|
||||
epOptionsOpen.value = null
|
||||
seasonMenuOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', closeAllMenus))
|
||||
onUnmounted(() => document.removeEventListener('click', closeAllMenus))
|
||||
|
||||
defineExpose({ closeEpMenu: closeAllMenus })
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Toolbar -->
|
||||
<div class="shrink-0 px-4 md:px-6 py-3 border-b border-border flex flex-col gap-2">
|
||||
|
||||
<!-- Ligne 1 : recherche + filtres -->
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
<input
|
||||
:value="search"
|
||||
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
placeholder="Rechercher…"
|
||||
class="settings-input pl-9 w-full py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="relative shrink-0" ref="filterRef">
|
||||
<button
|
||||
@click="filterOpen = !filterOpen"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 relative"
|
||||
:class="hasActiveFilter ? 'border-accent text-accent' : ''"
|
||||
title="Filtres"
|
||||
>
|
||||
<SlidersHorizontal :size="15" />
|
||||
<span class="hidden md:inline text-xs">Filtres</span>
|
||||
<ChevronDown class="hidden md:inline transition-transform" :size="12" :class="filterOpen ? 'rotate-180' : ''" />
|
||||
<span v-if="hasActiveFilter" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="filterOpen"
|
||||
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-3 z-20 w-52 flex flex-col gap-3 shadow-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs text-muted mb-1.5">Disponibilité</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="f in filtersDisponibilite" :key="f.value"
|
||||
@click="emit('update:activeFilter', f.value)"
|
||||
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
|
||||
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
{{ f.label }}
|
||||
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-border pt-3">
|
||||
<p class="text-xs text-muted mb-1.5">État import</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="f in filtersImport" :key="f.value"
|
||||
@click="emit('update:activeFilter', f.value)"
|
||||
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
|
||||
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full shrink-0" :style="{ background: f.color }" />
|
||||
{{ f.label }}
|
||||
</div>
|
||||
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="hasActiveFilter"
|
||||
@click="emit('update:activeFilter', 'all')"
|
||||
class="text-xs text-muted hover:text-primary transition-colors text-left pt-1 border-t border-border"
|
||||
>
|
||||
Effacer les filtres
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 2 : tri + taille + count -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
@click="emit('update:activeSort', s.value)"
|
||||
class="px-2.5 py-1 text-xs rounded-lg border transition-colors"
|
||||
:class="activeSort === s.value
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 border-l border-border pl-2 ml-auto">
|
||||
<button
|
||||
v-for="(_, size) in posterSizes" :key="size"
|
||||
@click="emit('update:posterSize', size)"
|
||||
class="w-7 h-7 text-[10px] rounded-md border transition-colors flex items-center justify-center"
|
||||
:class="posterSize === size
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-muted">{{ count }} série{{ count > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Légende barres -->
|
||||
<div class="shrink-0 px-4 md:px-6 py-2 flex items-center gap-4 border-b border-border">
|
||||
<div v-for="l in legend" :key="l.label" class="flex items-center gap-1.5">
|
||||
<span class="w-3 h-1.5 rounded-full" :style="{ background: l.color }" />
|
||||
<span class="text-[11px] text-muted">{{ l.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
defineProps<{
|
||||
search : string
|
||||
activeFilter : string
|
||||
activeSort : string
|
||||
posterSize : string
|
||||
posterSizes : Record<string, string>
|
||||
count : number
|
||||
hasActiveFilter : boolean
|
||||
filtersDisponibilite: { label: string; value: string }[]
|
||||
filtersImport : { label: string; value: string; color: string }[]
|
||||
sortOptions : { label: string; value: string }[]
|
||||
legend : { label: string; color: string }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:search' : [value: string]
|
||||
'update:activeFilter': [value: string]
|
||||
'update:activeSort' : [value: string]
|
||||
'update:posterSize' : [value: string]
|
||||
}>()
|
||||
|
||||
const filterOpen = ref(false)
|
||||
const filterRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(filterRef, () => { filterOpen.value = false })
|
||||
</script>
|
||||
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="emit('close')">
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-lg flex flex-col" style="max-height: 85vh">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-primary">Configuration Plex</h3>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ plexStep === 'auth' ? 'Connectez votre compte Plex' :
|
||||
plexStep === 'server' ? 'Choisissez votre serveur Plex' :
|
||||
plexStep === 'library' ? 'Créez la bibliothèque Fankai' :
|
||||
plexStep === 'done' ? 'Configuration terminée' :
|
||||
'Erreur' }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="emit('close')" class="text-muted hover:text-primary text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Étape 1 : Auth -->
|
||||
<div v-if="plexStep === 'auth'" class="flex flex-col gap-4 px-6 py-5">
|
||||
<div v-if="!plexAuthMethod" class="flex flex-col gap-3">
|
||||
<button
|
||||
@click="startOAuth" :disabled="plexLoading"
|
||||
class="flex items-center gap-3 p-4 rounded-lg border border-border hover:border-accent hover:bg-accent-muted transition-colors text-left"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#E5A50A" opacity=".2"/>
|
||||
<path d="M12 6v6l4 2" stroke="#E5A50A" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">{{ plexLoading ? 'Ouverture…' : 'Connexion via Plex' }}</p>
|
||||
<p class="text-xs text-muted">Google, Apple, ou compte Plex</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="plexAuthMethod = 'credentials'"
|
||||
class="flex items-center gap-3 p-4 rounded-lg border border-border hover:border-secondary transition-colors text-left"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-lg bg-hover flex items-center justify-center shrink-0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">Email / Mot de passe</p>
|
||||
<p class="text-xs text-muted">Connexion directe avec vos identifiants</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- OAuth en attente -->
|
||||
<div v-else-if="plexAuthMethod === 'oauth'" class="flex flex-col items-center gap-4 py-4 text-center">
|
||||
<div class="w-10 h-10 border-2 border-border border-t-accent rounded-full animate-spin" />
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">En attente de la connexion Plex…</p>
|
||||
<p class="text-xs text-muted mt-1">Connectez-vous sur la page Plex qui s'est ouverte dans votre navigateur</p>
|
||||
</div>
|
||||
<button @click="cancelOAuth" class="text-xs text-muted hover:text-primary transition-colors">Annuler</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire credentials -->
|
||||
<template v-else-if="plexAuthMethod === 'credentials'">
|
||||
<div>
|
||||
<label class="settings-label mb-1 block">Adresse e-mail Plex</label>
|
||||
<input v-model="plexForm.username" type="email" class="settings-input w-full" placeholder="email@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1 block">Mot de passe</label>
|
||||
<input v-model="plexForm.password" type="password" class="settings-input w-full" placeholder="••••••••" @keyup.enter="plexConnect" />
|
||||
</div>
|
||||
<div v-if="plexNeeds2FA">
|
||||
<label class="settings-label mb-1 block">Code 2FA</label>
|
||||
<input v-model="plexForm.code" type="text" class="settings-input w-full" placeholder="123456" maxlength="6" @keyup.enter="plexConnect" />
|
||||
</div>
|
||||
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="plexConnect" :disabled="plexLoading || !plexForm.username || !plexForm.password" class="btn-primary">
|
||||
{{ plexLoading ? 'Connexion…' : 'Se connecter' }}
|
||||
</button>
|
||||
<button @click="plexAuthMethod = null" class="btn-secondary">Retour</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="plexError && !plexAuthMethod" class="text-xs text-red-400">{{ plexError }}</p>
|
||||
<button v-if="!plexAuthMethod" @click="emit('close')" class="btn-secondary self-start">Annuler</button>
|
||||
</div>
|
||||
|
||||
<!-- Étape 2 : Choix serveur -->
|
||||
<div v-else-if="plexStep === 'server'" class="flex flex-col gap-3 px-6 py-5">
|
||||
<p class="text-xs text-muted">{{ plexServers.length }} serveur{{ plexServers.length > 1 ? 's' : '' }} trouvé{{ plexServers.length > 1 ? 's' : '' }}</p>
|
||||
<div
|
||||
v-for="(server, i) in plexServers" :key="i"
|
||||
@click="selectServer(server)"
|
||||
class="flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="plexSelectedServer === server ? 'border-accent bg-accent-muted' : 'border-border hover:border-secondary'"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">{{ server.name }}</p>
|
||||
<p class="text-xs text-muted">{{ server.owned ? 'Propriétaire' : 'Partagé' }} · {{ server.connections.length }} connexion{{ server.connections.length > 1 ? 's' : '' }}</p>
|
||||
</div>
|
||||
<svg v-if="plexSelectedServer === server" width="14" height="14" viewBox="0 0 24 24" stroke="#e5a50a" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<button @click="plexConnectServer" :disabled="!plexSelectedServer || plexLoading" class="btn-primary">
|
||||
{{ plexLoading ? 'Connexion…' : 'Continuer' }}
|
||||
</button>
|
||||
<button @click="plexStep = 'auth'" class="btn-secondary">Retour</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 3 : Bibliothèque -->
|
||||
<div v-else-if="plexStep === 'library'" class="flex flex-col gap-4 px-6 py-5">
|
||||
<div>
|
||||
<label class="settings-label mb-1 block">Nom de la bibliothèque</label>
|
||||
<input v-model="plexForm.libraryName" type="text" class="settings-input w-full" placeholder="Fankai" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1 block">Chemin de la médiathèque</label>
|
||||
<p class="text-xs text-muted mb-1.5">Chemin tel que vu par votre serveur Plex</p>
|
||||
<input v-model="plexForm.libraryPath" type="text" class="settings-input w-full font-mono" placeholder="/data/media/fankai" />
|
||||
</div>
|
||||
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="plexSetup" :disabled="plexLoading || !plexForm.libraryName || !plexForm.libraryPath" class="btn-primary">
|
||||
{{ plexLoading ? 'Création…' : 'Créer la bibliothèque' }}
|
||||
</button>
|
||||
<button @click="plexStep = 'server'" class="btn-secondary">Retour</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 4 : Résultat -->
|
||||
<div v-else-if="plexStep === 'done'" class="flex flex-col gap-4 px-6 py-5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="s in plexSteps" :key="s.step" class="flex items-center gap-2.5">
|
||||
<div class="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="s.ok ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'">
|
||||
<svg v-if="s.ok" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
|
||||
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2" fill="none"><line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/></svg>
|
||||
</div>
|
||||
<p class="text-xs" :class="s.ok ? 'text-primary' : 'text-red-400'">{{ s.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="plexManualSetup" class="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-3">
|
||||
<p class="text-xs text-yellow-400 font-medium mb-2">⚠ Configuration manuelle de l'agent requise (Plex < 1.43)</p>
|
||||
<ol class="text-xs text-muted space-y-1 list-decimal list-inside">
|
||||
<li>Rendez-vous sur la page <span class="text-primary">Metadata Agent</span> de Plex</li>
|
||||
<li>Dans <span class="text-primary">Metadata Provider</span>, ajoutez : <code class="bg-hover px-1 rounded">https://metadata.fankai.fr/plex</code></li>
|
||||
<li>Dans <span class="text-primary">Metadata Agent</span>, créez un agent avec ce provider</li>
|
||||
<li>Dans votre bibliothèque → ⋯ → <span class="text-primary">Manage Library → Edit → Advanced → Agent</span>, sélectionnez l'agent Fankai</li>
|
||||
<li>Rafraîchissez les métadonnées</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<button @click="emit('close')" class="btn-primary">Fermer</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{ mediaPath: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const plexStep = ref<'auth' | 'server' | 'library' | 'done'>('auth')
|
||||
const plexLoading = ref(false)
|
||||
const plexError = ref('')
|
||||
const plexNeeds2FA = ref(false)
|
||||
const plexToken = ref('')
|
||||
const plexServers = ref<any[]>([])
|
||||
const plexSelectedServer = ref<any>(null)
|
||||
const plexServerUrl = ref('')
|
||||
const plexManualSetup = ref(false)
|
||||
const plexSteps = ref<{ step: string; ok: boolean; message: string }[]>([])
|
||||
const plexAuthMethod = ref<'oauth' | 'credentials' | null>(null)
|
||||
const plexOAuthPinId = ref<string | null>(null)
|
||||
let plexOAuthTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const plexForm = ref({
|
||||
username : '',
|
||||
password : '',
|
||||
code : '',
|
||||
libraryName: 'Fankai',
|
||||
libraryPath: props.mediaPath,
|
||||
})
|
||||
|
||||
// ── OAuth ─────────────────────────────────────────────────────
|
||||
async function startOAuth() {
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/plex/oauth/start', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { plexError.value = data.error ?? 'Erreur OAuth'; return }
|
||||
plexOAuthPinId.value = String(data.pinId)
|
||||
plexAuthMethod.value = 'oauth'
|
||||
window.open(data.authUrl, '_blank')
|
||||
let attempts = 0
|
||||
plexOAuthTimer = setInterval(async () => {
|
||||
attempts++
|
||||
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai expiré — réessayez'; return }
|
||||
try {
|
||||
const r = await fetch(`/api/plex/oauth/poll/${plexOAuthPinId.value}`, { credentials: 'include' })
|
||||
const d = await r.json()
|
||||
if (d.ok && !d.pending) {
|
||||
cancelOAuth()
|
||||
plexToken.value = d.token
|
||||
plexServers.value = d.servers
|
||||
if (d.servers.length === 1) { plexSelectedServer.value = d.servers[0]; await plexConnectServer() }
|
||||
else plexStep.value = 'server'
|
||||
}
|
||||
} catch {}
|
||||
}, 2000)
|
||||
} catch {
|
||||
plexError.value = "Impossible de démarrer l'authentification"
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelOAuth() {
|
||||
if (plexOAuthTimer) { clearInterval(plexOAuthTimer); plexOAuthTimer = null }
|
||||
plexOAuthPinId.value = null
|
||||
if (plexAuthMethod.value === 'oauth') plexAuthMethod.value = null
|
||||
}
|
||||
|
||||
// ── Credentials ───────────────────────────────────────────────
|
||||
async function plexConnect() {
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/plex/connect', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ username: plexForm.value.username, password: plexForm.value.password, code: plexForm.value.code || undefined }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez votre code 2FA' }
|
||||
else plexError.value = data.error ?? 'Erreur de connexion'
|
||||
return
|
||||
}
|
||||
plexToken.value = data.token
|
||||
plexServers.value = data.servers
|
||||
if (data.servers.length === 1) { plexSelectedServer.value = data.servers[0]; await plexConnectServer() }
|
||||
else plexStep.value = 'server'
|
||||
} catch {
|
||||
plexError.value = 'Impossible de contacter le serveur'
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectServer(server: any) { plexSelectedServer.value = server }
|
||||
|
||||
async function plexConnectServer() {
|
||||
if (!plexSelectedServer.value) return
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const conns = plexSelectedServer.value.connections ?? []
|
||||
const conn = conns.find((c: any) => c.local && !c.relay) ?? conns.find((c: any) => !c.local && !c.relay) ?? conns.find((c: any) => c.relay)
|
||||
plexServerUrl.value = conn?.uri ?? ''
|
||||
if (!plexServerUrl.value) throw new Error('Aucune URL de connexion disponible')
|
||||
plexForm.value.libraryPath = props.mediaPath
|
||||
plexStep.value = 'library'
|
||||
} catch (err) {
|
||||
plexError.value = err instanceof Error ? err.message : 'Erreur'
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function plexSetup() {
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/plex/setup', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ token: plexToken.value, serverUrl: plexServerUrl.value, libraryName: plexForm.value.libraryName, libraryPath: plexForm.value.libraryPath }),
|
||||
})
|
||||
const data = await res.json()
|
||||
plexSteps.value = data.steps ?? []
|
||||
plexManualSetup.value = data.manualSetup ?? false
|
||||
plexStep.value = 'done'
|
||||
if (!data.ok) plexError.value = data.error ?? 'Erreur lors du setup'
|
||||
} catch {
|
||||
plexError.value = 'Impossible de contacter le serveur'
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => cancelOAuth())
|
||||
</script>
|
||||
@@ -16,7 +16,7 @@
|
||||
</Transition>
|
||||
|
||||
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-sidebar shrink-0">
|
||||
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-sidebar shrink-0 relative z-10">
|
||||
<button @click="mobileOpen = true" class="text-muted hover:text-primary transition-colors">
|
||||
<Menu :size="20" />
|
||||
</button>
|
||||
@@ -72,6 +72,7 @@ const navItems = computed<NavItem[]>(() => [
|
||||
children: [
|
||||
{ label: 'Clients de téléchargement', to: '/settings/download-client' },
|
||||
{ label: 'Gestion des médias', to: '/settings/media-management' },
|
||||
{ label: 'Management des séries', to: '/settings/import-management' },
|
||||
{ label: 'Catalogue Fankai', to: '/settings/catalogue' },
|
||||
{ label: 'Journaux', to: '/settings/logs' },
|
||||
{ label: 'Avancé', to: '/settings/advanced' },
|
||||
|
||||
@@ -63,6 +63,11 @@ const router = createRouter({
|
||||
name: 'settings-media',
|
||||
component: () => import('@/views/settings/MediaManagementView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'import-management',
|
||||
name: 'setting-import',
|
||||
component: () => import('@/views/settings/ImportManagementView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'catalogue',
|
||||
name: 'settings-catalogue',
|
||||
|
||||
@@ -100,12 +100,12 @@ export const useSeriesStore = defineStore('series', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function download(torrent_url: string | null, magnet: string | null): Promise<{ success: boolean; error?: string }> {
|
||||
async function download(torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await fetch('/api/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ torrent_url, magnet })
|
||||
body: JSON.stringify({ torrent_url, magnet, file_index: file_index ?? null, file_path: file_path ?? null, infohash: infohash ?? null })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) return { success: true }
|
||||
|
||||
@@ -7,27 +7,19 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-lg font-semibold text-primary">Activité</h1>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full transition-colors"
|
||||
:class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'"
|
||||
/>
|
||||
<span class="w-1.5 h-1.5 rounded-full transition-colors" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'" />
|
||||
<span class="text-xs text-muted">{{ polling ? 'En direct' : 'Pause' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="importAll" :disabled="importingAll" class="btn-primary">
|
||||
{{ importingAll ? '...' : 'Importer tout' }}
|
||||
</button>
|
||||
<button @click="importAll" :disabled="importingAll" class="btn-primary">{{ importingAll ? '...' : 'Importer tout' }}</button>
|
||||
<button @click="fetchTorrents" :disabled="loading" class="btn-secondary">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
|
||||
:class="loading ? 'animate-spin' : ''">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" :class="loading ? 'animate-spin' : ''">
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
|
||||
<polyline points="16 5 21 5 21 10"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="togglePolling" class="btn-secondary">
|
||||
{{ polling ? 'Pause' : 'Reprendre' }}
|
||||
</button>
|
||||
<button @click="togglePolling" class="btn-secondary">{{ polling ? 'Pause' : 'Reprendre' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,138 +31,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar : recherche + tri + colonnes -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Rechercher..."
|
||||
class="settings-input pl-9 w-full py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tri -->
|
||||
<div class="relative shrink-0" ref="sortRef">
|
||||
<button
|
||||
@click="sortOpen = !sortOpen"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
|
||||
:class="activeSort !== 'none' ? 'border-accent text-accent' : ''"
|
||||
>
|
||||
<ArrowUpDown :size="14" />
|
||||
<span class="hidden md:inline text-xs">Trier</span>
|
||||
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
</button>
|
||||
<div
|
||||
v-if="sortOpen"
|
||||
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
@click="setSort(s.value)"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
|
||||
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
{{ s.label }}
|
||||
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
|
||||
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colonnes -->
|
||||
<div class="relative shrink-0" ref="colRef">
|
||||
<button
|
||||
@click="colOpen = !colOpen"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
|
||||
:class="colOpen ? 'border-accent text-accent' : ''"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<span class="hidden md:inline text-xs">Colonnes</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="colOpen"
|
||||
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-52 flex flex-col gap-0.5 shadow-xl"
|
||||
>
|
||||
<p class="text-[10px] text-muted px-3 py-1">Colonnes affichées</p>
|
||||
<label
|
||||
v-for="col in columnOptions" :key="col.key"
|
||||
class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs cursor-pointer hover:bg-hover transition-colors"
|
||||
>
|
||||
<input type="checkbox" v-model="columns[col.key]" class="accent-accent" />
|
||||
{{ col.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglets + filtres -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
|
||||
<button
|
||||
@click="activeTab = 'active'"
|
||||
class="px-4 py-1.5 text-xs rounded-md transition-colors"
|
||||
:class="activeTab === 'active' ? 'bg-active text-primary' : 'text-muted hover:text-primary'"
|
||||
>
|
||||
En cours
|
||||
<span v-if="activeTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">
|
||||
{{ activeTorrents.length }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'done'"
|
||||
class="px-4 py-1.5 text-xs rounded-md transition-colors"
|
||||
:class="activeTab === 'done' ? 'bg-active text-primary' : 'text-muted hover:text-primary'"
|
||||
>
|
||||
Terminés
|
||||
<span v-if="doneTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">
|
||||
{{ doneTorrents.length }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'done'" class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-xs text-muted">Seeds uniquement</span>
|
||||
<button
|
||||
@click="seedingOnly = !seedingOnly"
|
||||
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200"
|
||||
:class="seedingOnly ? 'bg-green-500' : 'bg-border'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
|
||||
:class="seedingOnly ? 'translate-x-[18px]' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-xs text-muted">Masquer importés</span>
|
||||
<button
|
||||
@click="hideImported = !hideImported"
|
||||
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200"
|
||||
:class="hideImported ? 'bg-accent' : 'bg-border'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
|
||||
:class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Toolbar + Tabs -->
|
||||
<DownloadsToolbar
|
||||
v-model:search="search"
|
||||
v-model:active-tab="activeTab"
|
||||
v-model:seeding-only="seedingOnly"
|
||||
v-model:hide-imported="hideImported"
|
||||
:active-sort="activeSort"
|
||||
:sort-dir="sortDir"
|
||||
:columns="columns"
|
||||
:active-count="activeTorrents.length"
|
||||
:done-count="doneTorrents.length"
|
||||
:column-options="columnOptions"
|
||||
:sort-options="sortOptions"
|
||||
@sort="setSort"
|
||||
@toggle-col="(key) => (columns as any)[key] = !(columns as any)[key]"
|
||||
/>
|
||||
|
||||
<!-- Pas de clients -->
|
||||
<div v-if="noClients" class="settings-card flex flex-col items-center gap-3 py-16 text-center">
|
||||
<p class="text-sm text-muted">Aucun client torrent configuré</p>
|
||||
<RouterLink to="/settings/download-client" class="text-xs text-accent hover:underline">
|
||||
Configurer un client →
|
||||
</RouterLink>
|
||||
<RouterLink to="/settings/download-client" class="text-xs text-accent hover:underline">Configurer un client →</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Loading initial -->
|
||||
<!-- Loading -->
|
||||
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement...
|
||||
@@ -178,152 +62,25 @@
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="!loading && visibleTorrents.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
|
||||
<p class="text-sm text-muted">
|
||||
{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted">
|
||||
{{ activeTab === 'active'
|
||||
? 'Les torrents avec la catégorie « fankai » apparaîtront ici'
|
||||
: hideImported ? 'Tous les torrents terminés sont importés' : '' }}
|
||||
</p>
|
||||
<p class="text-sm text-muted">{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}</p>
|
||||
<p class="text-xs text-muted">{{ activeTab === 'active' ? 'Les torrents avec la catégorie « fankai » apparaîtront ici' : hideImported ? 'Tous les torrents terminés sont importés' : '' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Liste torrents -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-for="t in visibleTorrents" :key="t.hash" class="settings-card flex flex-col gap-3">
|
||||
|
||||
<!-- Nom + état + progression -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium truncate">{{ t.name }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(t.state).class">
|
||||
{{ stateBadge(t.state).label }}
|
||||
</span>
|
||||
<span v-if="columns.client" class="text-xs text-muted">{{ t.client_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-semibold text-primary">{{ clampedProgress(t) }}%</p>
|
||||
<p v-if="columns.size" class="text-xs text-muted mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div class="h-1 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="t.state === 'seeding' || t.state === 'unknown' ? 'bg-green-500' :
|
||||
t.state === 'error' ? 'bg-red-500' :
|
||||
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
|
||||
:style="{ width: `${clampedProgress(t)}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Infos bas + actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 text-xs text-muted">
|
||||
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
|
||||
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
|
||||
|
||||
<template v-if="t.state === 'seeding'">
|
||||
<span class="text-green-500">Complété</span>
|
||||
<span v-if="columns.ratio" class="font-mono"
|
||||
:class="t.ratio >= 1 ? 'text-green-400' : t.ratio >= 0.5 ? 'text-yellow-500' : 'text-muted'">
|
||||
R {{ t.ratio?.toFixed(2) ?? '0.00' }}
|
||||
</span>
|
||||
<span v-if="columns.uploaded">↑ {{ formatSize(t.uploaded ?? 0) }}</span>
|
||||
<span v-if="columns.upspeed && t.upspeed > 0">{{ formatSpeed(t.upspeed) }}</span>
|
||||
</template>
|
||||
|
||||
<span v-if="t.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
|
||||
</div>
|
||||
|
||||
<div v-if="t.state === 'seeding' || t.state === 'unknown'" class="flex items-center gap-2">
|
||||
|
||||
<!-- Erreurs fichiers -->
|
||||
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
|
||||
<span class="text-xs px-2 py-0.5 rounded border border-red-500/40 text-red-400 cursor-default">
|
||||
{{ t.errorFiles.length }} erreur{{ t.errorFiles.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
|
||||
<div class="bg-card border border-red-500/30 rounded-lg p-3 text-xs shadow-xl">
|
||||
<p class="text-red-400 mb-2 font-medium">Fichiers en erreur</p>
|
||||
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
|
||||
<p class="text-primary truncate">{{ e.file }}</p>
|
||||
<p class="text-muted">{{ e.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badge import -->
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded border"
|
||||
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
|
||||
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
|
||||
'border-border text-muted'"
|
||||
>
|
||||
{{ t.organizeState === 'done' ? `Importé (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
t.organizeState === 'partial' ? `En cours (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
'Non importé' }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="t.organizeState !== 'done'"
|
||||
@click="importTorrent(t)"
|
||||
:disabled="importing[t.hash]"
|
||||
class="btn-secondary text-xs py-1"
|
||||
>
|
||||
{{ importing[t.hash] ? '...' : 'Importer' }}
|
||||
</button>
|
||||
|
||||
<!-- Bouton supprimer -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="confirmDelete = confirmDelete === t.hash ? null : t.hash"
|
||||
:disabled="deleting[t.hash]"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg border border-border text-muted hover:border-red-500/40 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Popup confirmation -->
|
||||
<div
|
||||
v-if="confirmDelete === t.hash"
|
||||
class="absolute bottom-full right-0 mb-2 bg-card border border-border rounded-xl p-3 z-10 w-52 shadow-xl"
|
||||
>
|
||||
<p class="text-xs text-primary font-medium mb-2">Supprimer ce torrent ?</p>
|
||||
<label class="flex items-center gap-2 text-xs text-muted mb-3 cursor-pointer">
|
||||
<input type="checkbox" v-model="deleteWithFiles[t.hash]" class="accent-red-500" />
|
||||
Supprimer aussi les fichiers
|
||||
</label>
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
@click="deleteTorrent(t)"
|
||||
:disabled="deleting[t.hash]"
|
||||
class="flex-1 text-xs py-1 px-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
{{ deleting[t.hash] ? '...' : 'Confirmer' }}
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete = null"
|
||||
class="flex-1 text-xs py-1 px-2 rounded-lg border border-border text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<TorrentCard
|
||||
v-for="t in visibleTorrents"
|
||||
:key="t.hash"
|
||||
:torrent="t"
|
||||
:tab="activeTab"
|
||||
:columns="columns"
|
||||
:importing="!!importing[t.hash]"
|
||||
:deleting="!!deleting[t.hash]"
|
||||
:show-confirm-delete="confirmDelete === t.hash"
|
||||
@import="importTorrent"
|
||||
@delete="(torrent, withFiles) => deleteTorrent(torrent, withFiles)"
|
||||
@toggle-confirm="(hash) => { confirmDelete = hash }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -333,13 +90,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Search, ArrowUpDown } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import TorrentCard from '@/components/downloads/TorrentCard.vue'
|
||||
import DownloadsToolbar from '@/components/downloads/DownloadsToolbar.vue'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
const torrents = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const polling = ref(true)
|
||||
@@ -348,26 +104,14 @@ const importing = ref<Record<string, boolean>>({})
|
||||
const importingAll = ref(false)
|
||||
const confirmDelete = ref<string | null>(null)
|
||||
const deleting = ref<Record<string, boolean>>({})
|
||||
const deleteWithFiles = ref<Record<string, boolean>>({})
|
||||
const activeTab = ref<'active' | 'done'>('active')
|
||||
const hideImported = ref(false)
|
||||
const seedingOnly = ref(false)
|
||||
const search = ref('')
|
||||
const sortOpen = ref(false)
|
||||
const colOpen = ref(false)
|
||||
const activeSort = ref<'none' | 'name' | 'size' | 'progress' | 'state' | 'ratio'>('none')
|
||||
const sortDir = ref<'asc' | 'desc'>('asc')
|
||||
const sortRef = ref<HTMLElement | null>(null)
|
||||
const colRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const columns = ref({
|
||||
client : true,
|
||||
size : true,
|
||||
ratio : true,
|
||||
uploaded: true,
|
||||
upspeed : true,
|
||||
})
|
||||
|
||||
const columns = ref({ client: true, size: true, ratio: true, uploaded: true, upspeed: true })
|
||||
const columnOptions = [
|
||||
{ key: 'client', label: 'Client' },
|
||||
{ key: 'size', label: 'Taille / Téléchargé' },
|
||||
@@ -375,7 +119,6 @@ const columnOptions = [
|
||||
{ key: 'uploaded', label: 'Uploadé (total)' },
|
||||
{ key: 'upspeed', label: 'Vitesse upload' },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Nom', value: 'name' },
|
||||
{ label: 'Taille', value: 'size' },
|
||||
@@ -384,33 +127,24 @@ const sortOptions = [
|
||||
{ label: 'Ratio', value: 'ratio' },
|
||||
]
|
||||
|
||||
onClickOutside(sortRef, () => { sortOpen.value = false })
|
||||
onClickOutside(colRef, () => { colOpen.value = false })
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ─── Computed ─────────────────────────────────────────────────
|
||||
const activeTorrents = computed(() =>
|
||||
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
|
||||
)
|
||||
|
||||
const doneTorrents = computed(() =>
|
||||
torrents.value.filter(t => t.state === 'seeding' || t.state === 'unknown')
|
||||
)
|
||||
|
||||
const visibleTorrents = computed(() => {
|
||||
let list = activeTab.value === 'active' ? activeTorrents.value : doneTorrents.value
|
||||
|
||||
if (activeTab.value === 'done') {
|
||||
if (seedingOnly.value) list = list.filter(t => t.state === 'seeding')
|
||||
if (hideImported.value) list = list.filter(t => t.organizeState !== 'done')
|
||||
}
|
||||
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
list = list.filter(t => t.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
if (activeSort.value !== 'none') {
|
||||
const dir = sortDir.value === 'asc' ? 1 : -1
|
||||
list = [...list].sort((a, b) => {
|
||||
@@ -424,10 +158,8 @@ const visibleTorrents = computed(() => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
const stats = computed(() => [
|
||||
{ label: 'En cours', value: torrents.value.filter(t => t.state === 'downloading').length },
|
||||
{ label: 'Complétés', value: torrents.value.filter(t => t.state === 'seeding').length },
|
||||
@@ -435,53 +167,11 @@ const stats = computed(() => [
|
||||
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
|
||||
])
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
function setSort(val: typeof activeSort.value) {
|
||||
if (activeSort.value === val) {
|
||||
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
activeSort.value = val
|
||||
sortDir.value = 'asc'
|
||||
}
|
||||
sortOpen.value = false
|
||||
function setSort(val: string) {
|
||||
if (activeSort.value === val) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||
else { activeSort.value = val as typeof activeSort.value; sortDir.value = 'asc' }
|
||||
}
|
||||
|
||||
function clampedProgress(t: any): number {
|
||||
return Math.min(100, Math.max(0, t.progress ?? 0))
|
||||
}
|
||||
|
||||
function stateBadge(state: string) {
|
||||
const map: Record<string, { label: string; class: string }> = {
|
||||
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
|
||||
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
|
||||
paused : { label: 'En pause', class: 'border-border text-muted' },
|
||||
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
|
||||
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
}
|
||||
return map[state] ?? map.unknown
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
function formatSpeed(bps: number): string { return `${formatSize(bps)}/s` }
|
||||
|
||||
function formatEta(seconds: number): string {
|
||||
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
|
||||
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────
|
||||
async function fetchTorrents() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
@@ -489,97 +179,55 @@ async function fetchTorrents() {
|
||||
if (!res.ok) return
|
||||
torrents.value = await res.json()
|
||||
noClients.value = false
|
||||
} catch {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} catch {} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
fetchTorrents()
|
||||
pollInterval = setInterval(fetchTorrents, 60_000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
|
||||
}
|
||||
|
||||
function togglePolling() {
|
||||
polling.value = !polling.value
|
||||
polling.value ? startPolling() : stopPolling()
|
||||
}
|
||||
function startPolling() { fetchTorrents(); pollInterval = setInterval(fetchTorrents, 60_000) }
|
||||
function stopPolling() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null } }
|
||||
function togglePolling() { polling.value = !polling.value; polling.value ? startPolling() : stopPolling() }
|
||||
|
||||
async function importTorrent(torrent: any) {
|
||||
importing.value[torrent.hash] = true
|
||||
try {
|
||||
const res = await fetch('/api/organize', {
|
||||
method : 'POST',
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body : JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }),
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }),
|
||||
})
|
||||
if (res.ok) {
|
||||
toast(`${torrent.name} importé ✓`, 'success')
|
||||
await fetchTorrents()
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? "Erreur lors de l'import", 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
importing.value[torrent.hash] = false
|
||||
}
|
||||
if (res.ok) { toast(`${torrent.name} importé ✓`, 'success'); await fetchTorrents() }
|
||||
else { const { error } = await res.json(); toast(error ?? "Erreur lors de l'import", 'error') }
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { importing.value[torrent.hash] = false }
|
||||
}
|
||||
|
||||
async function importAll() {
|
||||
const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||
if (toImport.length === 0) { toast('Tous les torrents sont déjà importés', 'success'); return }
|
||||
|
||||
importingAll.value = true
|
||||
let done = 0, errors = 0
|
||||
|
||||
for (const t of toImport) {
|
||||
try {
|
||||
const res = await fetch('/api/organize', {
|
||||
method : 'POST',
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body : JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name }),
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name }),
|
||||
})
|
||||
res.ok ? done++ : errors++
|
||||
} catch { errors++ }
|
||||
}
|
||||
|
||||
importingAll.value = false
|
||||
await fetchTorrents()
|
||||
errors === 0
|
||||
? toast(`${done} torrent(s) importé(s) ✓`, 'success')
|
||||
: toast(`${done} OK, ${errors} erreur(s)`, 'error')
|
||||
errors === 0 ? toast(`${done} torrent(s) importé(s) ✓`, 'success') : toast(`${done} OK, ${errors} erreur(s)`, 'error')
|
||||
}
|
||||
|
||||
async function deleteTorrent(torrent: any) {
|
||||
async function deleteTorrent(torrent: any, withFiles: boolean) {
|
||||
deleting.value[torrent.hash] = true
|
||||
try {
|
||||
const withFiles = deleteWithFiles.value[torrent.hash] ?? false
|
||||
const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, {
|
||||
method : 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.ok) {
|
||||
toast(`"${torrent.name}" supprimé ✓`, 'success')
|
||||
confirmDelete.value = null
|
||||
await fetchTorrents()
|
||||
} else {
|
||||
toast('Erreur lors de la suppression', 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
deleting.value[torrent.hash] = false
|
||||
}
|
||||
const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) { toast(`"${torrent.name}" supprimé ✓`, 'success'); confirmDelete.value = null; await fetchTorrents() }
|
||||
else toast('Erreur lors de la suppression', 'error')
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { deleting.value[torrent.hash] = false }
|
||||
}
|
||||
|
||||
onMounted(startPolling)
|
||||
onUnmounted(stopPolling)
|
||||
</script>
|
||||
</script>
|
||||
@@ -14,241 +14,122 @@
|
||||
</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- Hero — poster, titre, métadonnées, synopsis -->
|
||||
<SerieHero :serie="data.serie" />
|
||||
|
||||
<!-- Hero avec fanart -->
|
||||
<div class="relative">
|
||||
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-64 overflow-hidden pointer-events-none">
|
||||
<img
|
||||
:src="data.serie.fanart_image"
|
||||
class="w-full h-full object-cover opacity-15"
|
||||
@error="(e) => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-main" />
|
||||
<!-- Barre d'actions -->
|
||||
<div class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
|
||||
|
||||
<!-- Gauche : gestion bibliothèque -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Import manuel -->
|
||||
<button
|
||||
@click="openManualImport"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent-muted text-accent border border-accent/20 hover:bg-accent/20 transition"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Import manuel
|
||||
</button>
|
||||
|
||||
<!-- Supprimer série -->
|
||||
<button
|
||||
v-if="Object.keys(organizedByEpisode).length > 0"
|
||||
@click="openUnimportSerieModal"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 transition"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>
|
||||
</svg>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative px-4 md:px-8 pt-6 pb-10 flex gap-6 min-h-[200px]">
|
||||
<!-- Poster -->
|
||||
<div class="shrink-0 w-32 rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
|
||||
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full aspect-[2/3] bg-card flex items-center justify-center text-muted">
|
||||
<Tv :size="28" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Droite : téléchargements -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Bouton unique "Tout télécharger" — dropdown si plusieurs intégrales -->
|
||||
<div v-if="hasSomethingToDownload" class="relative" @click.stop>
|
||||
<button
|
||||
:disabled="downloadingAll"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent text-white hover:bg-accent-hover transition"
|
||||
:class="downloadingAll ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
@click="data.torrents_integrale.length > 1 ? (downloadMenuOpen = !downloadMenuOpen) : downloadAll()"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" :class="downloadingAll ? 'animate-spin' : ''">
|
||||
<path v-if="!downloadingAll" d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline v-if="!downloadingAll" points="7 10 12 15 17 10"/><line v-if="!downloadingAll" x1="12" y1="15" x2="12" y2="3"/>
|
||||
<path v-else d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
|
||||
</svg>
|
||||
{{ downloadingAll ? 'Envoi…' : 'Tout télécharger' }}
|
||||
<span v-if="data.torrents_integrale.length > 1" class="opacity-70">▾</span>
|
||||
</button>
|
||||
|
||||
<!-- Infos -->
|
||||
<div class="flex flex-col gap-3 justify-end min-w-0">
|
||||
<!-- Retour -->
|
||||
<RouterLink to="/series" class="inline-flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-fit">
|
||||
<ArrowLeft :size="13" />
|
||||
Séries
|
||||
</RouterLink>
|
||||
|
||||
<!-- Titre + bouton import manuel -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ data.serie.title }}</h1>
|
||||
<button
|
||||
@click="openManualImport"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted hover:text-primary hover:border-secondary transition shrink-0"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Import manuel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="data.serie.year" class="text-sm text-muted">{{ data.serie.year }}</span>
|
||||
<span
|
||||
v-if="data.serie.status"
|
||||
class="text-xs px-2 py-0.5 rounded-full border"
|
||||
:class="data.serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/15 text-green-400 border-green-500/20'
|
||||
: 'bg-hover text-muted border-border'"
|
||||
>
|
||||
{{ data.serie.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="data.serie.plot" class="text-sm text-secondary leading-relaxed max-w-2xl line-clamp-3">
|
||||
{{ data.serie.plot }}
|
||||
</p>
|
||||
|
||||
<!-- Boutons intégrale -->
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
<!-- Dropdown choix d'intégrale -->
|
||||
<div v-if="downloadMenuOpen && data.torrents_integrale.length > 1"
|
||||
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-56 shadow-xl flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="(t, i) in data.torrents_integrale"
|
||||
:key="i"
|
||||
:title="t.raw"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
|
||||
:class="[
|
||||
t.label === 'Intégrale'
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'bg-accent-muted text-accent border border-accent/20 hover:bg-accent/20',
|
||||
(isDownloading(`integrale-${i}`) || isDownloaded(`integrale-${i}`) || isAlreadyQueued(t))
|
||||
? 'opacity-50 cursor-not-allowed' : ''
|
||||
]"
|
||||
:disabled="isDownloading(`integrale-${i}`) || isDownloaded(`integrale-${i}`) || isAlreadyQueued(t)"
|
||||
@click="download(`integrale-${i}`, t.torrent_url, t.magnet)"
|
||||
:disabled="isAlreadyQueued(t) && !hasUncoveredByIntegrale(i)"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
|
||||
:class="(isDownloaded(`integrale-${i}`) && !hasUncoveredByIntegrale(i)) ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
@click="downloadAll(i); downloadMenuOpen = false"
|
||||
>
|
||||
<EpStateIcon :state="epBtnState(`integrale-${i}`, t)" />
|
||||
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : isDownloaded(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
{{ integraleGroupLabels()[i] }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progression intégrale -->
|
||||
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
|
||||
<ProgressBar :progress="torrentProgress(extractHash(t))?.progress" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saisons -->
|
||||
<div class="px-4 md:px-8 pb-16 flex flex-col gap-4">
|
||||
<div
|
||||
<div class="px-4 md:px-8 pb-16 flex flex-col gap-4 pt-4">
|
||||
<SerieSeasonCard
|
||||
v-for="season in data.seasons"
|
||||
:key="season.id"
|
||||
class="bg-card border border-border rounded-xl overflow-hidden"
|
||||
>
|
||||
<!-- Header saison -->
|
||||
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="text-muted hover:text-primary transition-colors" @click="toggleSeason(season.id)">
|
||||
<ChevronUp
|
||||
:size="15"
|
||||
class="transition-transform duration-200"
|
||||
:class="collapsedSeasons.has(season.id) ? 'rotate-180' : ''"
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-primary">
|
||||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||||
<span
|
||||
v-if="season.title && season.title !== `Saison ${season.season_number}`"
|
||||
class="text-muted font-normal ml-1"
|
||||
>
|
||||
— {{ season.title }}
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
|
||||
<template v-if="seasonAvailableCount(season) > 0">
|
||||
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
|
||||
</template>
|
||||
<span
|
||||
v-if="season.organized_state !== 'none'"
|
||||
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px]"
|
||||
:class="season.organized_state === 'complete'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-yellow-500/10 text-yellow-500'"
|
||||
>
|
||||
{{ season.organized_state === 'complete'
|
||||
? `✓ ${season.organized_count} importé${season.organized_count > 1 ? 's' : ''}`
|
||||
: `${season.organized_count}/${seasonAvailableCount(season) || season.episodes.length} importés` }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
:season="season"
|
||||
:collapsed="collapsedSeasons.has(season.id)"
|
||||
:active-torrents="activeTorrents"
|
||||
:downloading="downloading"
|
||||
:downloaded="downloaded"
|
||||
:organized-by-episode="organizedByEpisode"
|
||||
:ep-action-loading="epActionLoading"
|
||||
:downloading-season="!!downloadingSeason[season.id]"
|
||||
:nfo-support="nfoSupport"
|
||||
@toggle="toggleSeason"
|
||||
@download="(key, url, magnet, fi, fp, ih) => download(key, url, magnet, fi, fp, ih)"
|
||||
@download-season="(s, h) => downloadSeason(s, h)"
|
||||
@rename-episode="(ep, s) => renameEpisode(ep, s)"
|
||||
@unimport-episode="(ep, s, del) => unimportEpisode(ep, s, del)"
|
||||
@unimport-season="(s, del) => unimportSeason(s, del)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Bouton pack saison -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
|
||||
:class="season.torrent && !isDownloaded(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
|
||||
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
|
||||
: 'text-muted border-border cursor-not-allowed opacity-50'"
|
||||
:disabled="!season.torrent || isDownloading(`season-${season.id}`) || isDownloaded(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
|
||||
@click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
|
||||
>
|
||||
<EpStateIcon :state="seasonBtnState(season)" :size="12" />
|
||||
{{ season.organized_state === 'complete' ? 'Importé' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : isDownloaded(`season-${season.id}`) ? 'Envoyé' : 'Saison entière' }}
|
||||
<!-- Modal désimport série -->
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportSerieModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="unimportSerieModal = false">
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-5">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-primary">Supprimer la série</p>
|
||||
<p class="text-xs text-muted mt-1 truncate">{{ data?.serie?.title }}</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteSerieFiles" class="w-4 h-4 rounded border-border accent-accent" />
|
||||
<span class="text-xs text-muted">Supprimer également les fichiers physiques du disque</span>
|
||||
</label>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button @click="unimportSerieModal = false" class="btn-secondary text-xs">Annuler</button>
|
||||
<button @click="confirmUnimportSerie" class="px-4 py-2 rounded-lg border border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20 transition-colors text-xs font-medium">
|
||||
{{ deleteSerieFiles ? 'Supprimer les fichiers' : 'Supprimer' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progression saison -->
|
||||
<div
|
||||
v-if="torrentProgress(extractHash(season.torrent)) && torrentProgress(extractHash(season.torrent))!.progress < 100"
|
||||
class="px-5 pb-3 -mt-1"
|
||||
>
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-accent rounded-full transition-all duration-500"
|
||||
:style="{ width: `${torrentProgress(extractHash(season.torrent))!.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted mt-1">{{ torrentProgress(extractHash(season.torrent))!.progress }}%</p>
|
||||
</div>
|
||||
|
||||
<!-- Épisodes -->
|
||||
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-border/50">
|
||||
<div
|
||||
v-for="ep in season.episodes"
|
||||
:key="ep.id"
|
||||
class="px-5 py-3 hover:bg-hover/50 transition-colors"
|
||||
:class="{ 'opacity-40': !ep.available && !ep.organized }"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="shrink-0 w-20 aspect-video rounded-md overflow-hidden bg-shell hidden sm:flex items-center justify-center text-muted text-xs font-mono">
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
|
||||
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Infos épisode -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted shrink-0 font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
|
||||
</span>
|
||||
<span class="text-sm text-primary truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||
<span
|
||||
v-if="ep.fankai === false || ep.torrent?.fankai === false"
|
||||
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||||
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
|
||||
>
|
||||
Hors Fankai
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span v-if="ep.aired" class="text-xs text-muted">{{ formatDate(ep.aired) }}</span>
|
||||
<span v-if="ep.duration" class="text-xs text-muted">{{ formatDuration(ep.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton état unique -->
|
||||
<button
|
||||
class="shrink-0 w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
|
||||
:class="epBtnClass(ep)"
|
||||
:disabled="!ep.torrent || ep.organized || isAlreadyQueued(ep.torrent)"
|
||||
@click="ep.torrent && !ep.organized && !isAlreadyQueued(ep.torrent) && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
|
||||
>
|
||||
<EpStateIcon :state="epState(ep)" :size="14" />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Barre progression épisode -->
|
||||
<div
|
||||
v-if="torrentProgress(extractHash(ep.torrent)) && torrentProgress(extractHash(ep.torrent))!.progress < 100"
|
||||
class="mt-1.5 sm:pl-[92px]"
|
||||
>
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-accent rounded-full transition-all duration-500"
|
||||
:style="{ width: `${torrentProgress(extractHash(ep.torrent))!.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</Teleport>
|
||||
|
||||
<!-- Modal import manuel -->
|
||||
<ManualImportModal
|
||||
@@ -266,46 +147,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { ArrowLeft, Tv, ChevronUp, Download, Loader, Check, X } from 'lucide-vue-next'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import ManualImportModal from '@/components/ManualImportModal.vue'
|
||||
import SerieHero from '@/components/serie/SerieHero.vue'
|
||||
import SerieSeasonCard from '@/components/serie/SerieSeasonCard.vue'
|
||||
import type { Season } from '@/stores/series'
|
||||
|
||||
// ── Icône d'état du bouton ────────────────────────────────────
|
||||
const EpStateIcon = defineComponent({
|
||||
props: {
|
||||
state: { type: String, default: 'idle' },
|
||||
size : { type: Number, default: 14 },
|
||||
},
|
||||
setup(props) {
|
||||
return () => {
|
||||
if (props.state === 'loading') return h(Loader, { size: props.size, class: 'animate-spin' })
|
||||
if (props.state === 'done') return h(Check, { size: props.size })
|
||||
if (props.state === 'unavailable') return h(X, { size: props.size })
|
||||
return h(Download, { size: props.size })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// ── Barre de progression ──────────────────────────────────────
|
||||
const ProgressBar = defineComponent({
|
||||
props: { progress: { type: Number, default: null } },
|
||||
setup(props) {
|
||||
return () => {
|
||||
if (props.progress == null || props.progress >= 100) return null
|
||||
return h('div', { class: 'h-0.5 bg-border rounded-full overflow-hidden' }, [
|
||||
h('div', {
|
||||
class: 'h-full bg-accent rounded-full transition-all duration-500',
|
||||
style: { width: `${props.progress}%` },
|
||||
}),
|
||||
])
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const store = useSeriesStore()
|
||||
const { add: toast } = useToast()
|
||||
@@ -316,18 +166,93 @@ const downloaded = ref<string[]>([])
|
||||
const manualImportOpen = ref(false)
|
||||
const organizedByEpisode = ref<Record<string, any>>({})
|
||||
const mediaPath = ref('/')
|
||||
const nfoSupport = ref(false)
|
||||
const epActionLoading = ref<Record<number, boolean>>({})
|
||||
const unimportSerieModal = ref(false)
|
||||
const deleteSerieFiles = ref(false)
|
||||
const downloadMenuOpen = ref(false)
|
||||
const downloadingAll = ref(false)
|
||||
const downloadingSeason = ref<Record<number, boolean>>({})
|
||||
|
||||
export interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[]; save_path?: string; name?: string }
|
||||
const activeTorrents = ref<ActiveTorrent[]>([])
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Suivi des états précédents pour détecter les transitions (download → seeding)
|
||||
const _prevTorrentStates = new Map<string, string>() // hash → state
|
||||
const _prevFileProgresses = new Map<string, number>() // hash:index → progress
|
||||
const _recentlyOrganized = new Set<string>() // hash:fileIndex ou hash seul
|
||||
|
||||
const data = computed(() => store.currentSerie)
|
||||
|
||||
// ── Helpers intégrale ─────────────────────────────────────────
|
||||
function integraleGroupLabels(): string[] {
|
||||
const torrents = data.value?.torrents_integrale ?? []
|
||||
const names = torrents.map((t: any) => t.torrent_name ?? null)
|
||||
const allPresent = names.every((n: any) => n !== null && String(n).trim() !== '')
|
||||
const allUnique = allPresent && new Set(names).size === names.length
|
||||
if (allUnique) return names as string[]
|
||||
// Fallback : titre Nyaa brut
|
||||
return torrents.map((t: any, i: number) => {
|
||||
const raw = t.raw ?? ''
|
||||
if (raw) return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
|
||||
return `Intégrale ${i + 1}`
|
||||
})
|
||||
}
|
||||
|
||||
// Épisodes couverts par une intégrale donnée (hash commun)
|
||||
function integraleEpisodeCoverage(integraleIndex: number): Set<number> {
|
||||
const covered = new Set<number>()
|
||||
const t = data.value?.torrents_integrale[integraleIndex]
|
||||
if (!t || !data.value) return covered
|
||||
const hash = t.infohash?.toLowerCase() ?? extractHash(t)
|
||||
if (!hash) return covered
|
||||
for (const season of data.value.seasons)
|
||||
for (const ep of season.episodes)
|
||||
if (ep.torrents?.some((et: any) => (et.infohash ?? extractHash(et)) === hash))
|
||||
covered.add(ep.id)
|
||||
return covered
|
||||
}
|
||||
|
||||
// Vrai s'il reste des épisodes/saisons non couverts par cette intégrale
|
||||
function hasUncoveredByIntegrale(integraleIndex: number): boolean {
|
||||
if (!data.value) return false
|
||||
const covered = integraleEpisodeCoverage(integraleIndex)
|
||||
for (const season of data.value.seasons)
|
||||
for (const ep of season.episodes)
|
||||
if (ep.available && !ep.organized && !covered.has(ep.id)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Helpers d'état ────────────────────────────────────────────
|
||||
function isDownloading(key: string) { return downloading.value.includes(key) }
|
||||
function isDownloaded(key: string) { return downloaded.value.includes(key) }
|
||||
function addDownloading(key: string) { if (!downloading.value.includes(key)) downloading.value.push(key) }
|
||||
function removeDownloading(key: string) { downloading.value = downloading.value.filter(k => k !== key) }
|
||||
function addDownloaded(key: string) { if (!downloaded.value.includes(key)) downloaded.value.push(key) }
|
||||
function addDownloaded(key: string) { if (!downloaded.value.includes(key)) downloaded.value.push(key) }
|
||||
function removeDownloaded(key: string) { downloaded.value = downloaded.value.filter(k => k !== key) }
|
||||
function clearEpDownloaded(ep: any) {
|
||||
removeDownloaded(`ep-${ep.id}`)
|
||||
const count = ep.torrents?.length ?? 0
|
||||
for (let i = 0; i < count; i++) removeDownloaded(`ep-${ep.id}-${i}`)
|
||||
}
|
||||
function extractHash(torrent: any): string | null {
|
||||
if (torrent?.infohash) return torrent.infohash.toLowerCase()
|
||||
if (!torrent?.magnet) return null
|
||||
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
return m ? m[1].toLowerCase() : null
|
||||
}
|
||||
function isAlreadyQueued(torrent: any): boolean {
|
||||
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas :
|
||||
// le pack peut être actif sans que CE fichier spécifique soit en téléchargement.
|
||||
if (torrent?.file_index != null) return false
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||||
}
|
||||
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string }
|
||||
const activeTorrents = ref<ActiveTorrent[]>([])
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const data = computed(() => store.currentSerie)
|
||||
// ── Fetch ─────────────────────────────────────────────────────
|
||||
function load() { store.fetchSerieDetail(Number(route.params.id)) }
|
||||
|
||||
async function fetchOrganized() {
|
||||
try {
|
||||
@@ -339,135 +264,262 @@ async function fetchOrganized() {
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const s = await res.json()
|
||||
mediaPath.value = s.mediaPath || '/'
|
||||
}
|
||||
if (res.ok) { const s = await res.json(); mediaPath.value = s.mediaPath || '/'; nfoSupport.value = !!s.nfoSupport }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openManualImport() {
|
||||
fetchOrganized()
|
||||
manualImportOpen.value = true
|
||||
}
|
||||
|
||||
// ── Polling downloads ─────────────────────────────────────────
|
||||
async function fetchActiveDownloads() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const list: any[] = await res.json()
|
||||
|
||||
const isFirstPoll = _prevTorrentStates.size === 0
|
||||
|
||||
for (const t of list) {
|
||||
const prevState = _prevTorrentStates.get(t.hash)
|
||||
|
||||
// Détecter fichier individuel terminé (progression par fichier)
|
||||
if (t.files) {
|
||||
for (const f of t.files) {
|
||||
const key = `${t.hash}:${f.index}`
|
||||
const prevProg = _prevFileProgresses.get(key)
|
||||
_prevFileProgresses.set(key, f.progress)
|
||||
if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) {
|
||||
_recentlyOrganized.add(key)
|
||||
triggerOrganize(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Détecter torrent entier passé en seeding (épisodes standalone sans per-file data)
|
||||
if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) {
|
||||
_recentlyOrganized.add(t.hash)
|
||||
triggerOrganize(t)
|
||||
}
|
||||
|
||||
_prevTorrentStates.set(t.hash, t.state)
|
||||
}
|
||||
|
||||
activeTorrents.value = list.map(t => ({
|
||||
hash : t.hash,
|
||||
progress: t.progress ?? 0,
|
||||
state : t.state,
|
||||
hash : t.hash,
|
||||
progress : t.progress ?? 0,
|
||||
state : t.state,
|
||||
files : t.files,
|
||||
save_path: t.save_path,
|
||||
name : t.name,
|
||||
}))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
|
||||
if (!hash) return null
|
||||
return activeTorrents.value.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
|
||||
async function triggerOrganize(torrent: any) {
|
||||
if (!torrent?.hash || !torrent?.name || !torrent?.save_path) return
|
||||
try {
|
||||
const res = await fetch('/api/organize', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ hash: torrent.hash, name: torrent.name, save_path: torrent.save_path }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const result = await res.json()
|
||||
if (result.done > 0) {
|
||||
toast(`${result.done} épisode(s) importé(s) automatiquement ✓`, 'success')
|
||||
await fetchOrganized()
|
||||
load()
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function extractHash(torrent: { magnet?: string | null } | null | undefined): string | null {
|
||||
if (!torrent?.magnet) return null
|
||||
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
return m ? m[1].toLowerCase() : null
|
||||
function openManualImport() { fetchOrganized(); manualImportOpen.value = true }
|
||||
|
||||
// ── Download ──────────────────────────────────────────────────
|
||||
async function download(key: string, torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null) {
|
||||
if (isDownloading(key) || isDownloaded(key)) return
|
||||
addDownloading(key)
|
||||
const result = await store.download(torrent_url, magnet, file_index, file_path, infohash)
|
||||
removeDownloading(key)
|
||||
if (result.success) { addDownloaded(key); toast('Téléchargement lancé ✓', 'success'); fetchActiveDownloads() }
|
||||
else toast(result.error ?? 'Erreur inconnue', 'error')
|
||||
}
|
||||
|
||||
function isAlreadyQueued(torrent: { magnet?: string | null } | null | undefined): boolean {
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||||
type DlItem = { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null; infohash?: string | null }
|
||||
|
||||
// Construit la liste de tout ce qui doit être téléchargé.
|
||||
// integraleIndex : si défini, utilise uniquement cette intégrale (+ épisodes non couverts).
|
||||
// si omis, utilise toutes les intégrales disponibles.
|
||||
function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
if (!data.value) return []
|
||||
const result: DlItem[] = []
|
||||
const covered = new Set<number>()
|
||||
|
||||
// ── 1. Intégrale(s) ───────────────────────────────────────────
|
||||
const integrales: { t: any; i: number }[] = integraleIndex !== undefined
|
||||
? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }]
|
||||
: data.value.torrents_integrale.map((t: any, i: number) => ({ t, i }))
|
||||
|
||||
for (const { t, i } of integrales) {
|
||||
if (!t) continue
|
||||
if (!isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
|
||||
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
// Marquer les épisodes couverts par CETTE intégrale uniquement
|
||||
const hash = t.infohash?.toLowerCase() ?? extractHash(t)
|
||||
if (hash)
|
||||
for (const season of data.value!.seasons)
|
||||
for (const ep of season.episodes)
|
||||
if (ep.torrents?.some((et: any) => (et.infohash ?? extractHash(et)) === hash))
|
||||
covered.add(ep.id)
|
||||
}
|
||||
|
||||
// ── 2. Packs saison non couverts ──────────────────────────────
|
||||
for (const season of data.value.seasons) {
|
||||
if (!season.torrents?.length || season.organized_state === 'complete') continue
|
||||
// Skiper si tous les épisodes disponibles sont déjà couverts par l'intégrale
|
||||
const hasUncovered = season.episodes.some((ep: any) => ep.available && !ep.organized && !covered.has(ep.id))
|
||||
if (!hasUncovered) continue
|
||||
let addedAny = false
|
||||
season.torrents.forEach((t: any, i: number) => {
|
||||
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`
|
||||
if (!isAlreadyQueued(t) && !isDownloaded(key)) { result.push({ key, torrent_url: t.torrent_url, magnet: t.magnet }); addedAny = true }
|
||||
})
|
||||
if (addedAny) for (const ep of season.episodes) covered.add(ep.id)
|
||||
}
|
||||
|
||||
// ── 3. Épisodes individuels non couverts ─────────────────────
|
||||
for (const season of data.value.seasons)
|
||||
for (const ep of season.episodes) {
|
||||
if (!ep.torrent || !ep.available || ep.organized || isAlreadyQueued(ep.torrent) || isDownloaded(`ep-${ep.id}`) || covered.has(ep.id)) continue
|
||||
result.push({ key: `ep-${ep.id}`, torrent_url: ep.torrent.torrent_url, magnet: ep.torrent.magnet, file_index: ep.torrent.file_index ?? null, file_path: ep.torrent.file_path ?? null, infohash: ep.torrent.infohash ?? null })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ── États boutons ─────────────────────────────────────────────
|
||||
type BtnState = 'idle' | 'loading' | 'done' | 'unavailable'
|
||||
// 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
|
||||
})
|
||||
|
||||
function epState(ep: any): BtnState {
|
||||
if (ep.organized) return 'done'
|
||||
if (isDownloaded(`ep-${ep.id}`)) return 'done'
|
||||
if (isAlreadyQueued(ep.torrent)) return 'done'
|
||||
const prog = torrentProgress(extractHash(ep.torrent))
|
||||
if (prog && prog.progress < 100) return 'loading'
|
||||
if (!ep.torrent || !ep.available) return 'unavailable'
|
||||
if (isDownloading(`ep-${ep.id}`)) return 'loading'
|
||||
return 'idle'
|
||||
async function downloadAll(integraleIndex?: number) {
|
||||
downloadingAll.value = true
|
||||
const torrents = collectDownloadables(integraleIndex)
|
||||
let sent = 0
|
||||
for (const t of torrents) {
|
||||
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
|
||||
}
|
||||
downloadingAll.value = false
|
||||
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
|
||||
else toast('Aucun nouveau torrent à télécharger', 'success')
|
||||
}
|
||||
|
||||
function epBtnState(key: string, torrent: any): BtnState {
|
||||
if (isDownloaded(key) || isAlreadyQueued(torrent)) return 'done'
|
||||
if (isDownloading(key)) return 'loading'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function seasonBtnState(season: any): BtnState {
|
||||
if (season.organized_state === 'complete') return 'done'
|
||||
if (isDownloaded(`season-${season.id}`)) return 'done'
|
||||
if (isAlreadyQueued(season.torrent)) return 'done'
|
||||
if (isDownloading(`season-${season.id}`)) return 'loading'
|
||||
if (!season.torrent) return 'unavailable'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function epBtnClass(ep: any): string {
|
||||
const state = epState(ep)
|
||||
if (state === 'done') return 'border-green-500/30 text-green-500 bg-green-500/10 cursor-default'
|
||||
if (state === 'loading') return 'border-accent/30 text-accent bg-accent-muted cursor-default'
|
||||
if (state === 'unavailable') return 'border-border text-muted opacity-40 cursor-not-allowed'
|
||||
return 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'
|
||||
async function downloadSeason(season: Season, packHash?: string) {
|
||||
downloadingSeason.value[season.id] = true
|
||||
const torrents: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = []
|
||||
const seasonAny = season as any
|
||||
if (seasonAny.torrents && seasonAny.torrents.length > 0 && seasonAny.organized_state !== 'complete') {
|
||||
seasonAny.torrents.forEach((t: any, i: number) => {
|
||||
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`
|
||||
if (!isAlreadyQueued(t) && !isDownloaded(key)) {
|
||||
torrents.push({ key, torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
for (const ep of seasonAny.episodes ?? []) {
|
||||
// Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant
|
||||
const epTorrent = packHash
|
||||
? (ep.torrents?.find((t: any) => (t.infohash ?? extractHash(t)) === packHash) ?? ep.torrent)
|
||||
: ep.torrent
|
||||
if (!epTorrent || !ep.available || ep.organized || isAlreadyQueued(epTorrent) || isDownloaded(`ep-${ep.id}`)) continue
|
||||
torrents.push({ key: `ep-${ep.id}`, torrent_url: epTorrent.torrent_url, magnet: epTorrent.magnet, file_index: epTorrent.file_index ?? null, file_path: epTorrent.file_path ?? null, infohash: epTorrent.infohash ?? null })
|
||||
}
|
||||
}
|
||||
let sent = 0
|
||||
for (const t of torrents) {
|
||||
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
|
||||
}
|
||||
downloadingSeason.value[season.id] = false
|
||||
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
|
||||
else toast('Aucun nouveau torrent à télécharger pour cette saison', 'success')
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
function seasonAvailableCount(season: Season) {
|
||||
return season.episodes.filter((e: any) => e.available).length
|
||||
}
|
||||
|
||||
function toggleSeason(id: number) {
|
||||
if (collapsedSeasons.value.has(id)) collapsedSeasons.value.delete(id)
|
||||
else collapsedSeasons.value.add(id)
|
||||
}
|
||||
|
||||
async function download(key: string, torrent_url: string | null, magnet: string | null) {
|
||||
if (isDownloading(key) || isDownloaded(key)) return
|
||||
addDownloading(key)
|
||||
const result = await store.download(torrent_url, magnet)
|
||||
removeDownloading(key)
|
||||
if (result.success) {
|
||||
addDownloaded(key)
|
||||
toast('Téléchargement lancé ✓', 'success')
|
||||
fetchActiveDownloads()
|
||||
} else {
|
||||
toast(result.error ?? 'Erreur inconnue', 'error')
|
||||
}
|
||||
function openUnimportSerieModal() { deleteSerieFiles.value = false; unimportSerieModal.value = true }
|
||||
async function confirmUnimportSerie() { unimportSerieModal.value = false; await unimportSerie(deleteSerieFiles.value) }
|
||||
|
||||
async function unimportSeason(season: any, deleteFile: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${route.params.id}/seasons/${season.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport saison', 'error'); return }
|
||||
const d = await res.json()
|
||||
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
|
||||
for (const ep of season.episodes ?? []) clearEpDownloaded(ep)
|
||||
removeDownloaded(`season-${season.id}`)
|
||||
await fetchOrganized(); load()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
}
|
||||
|
||||
function load() {
|
||||
store.fetchSerieDetail(Number(route.params.id))
|
||||
async function unimportSerie(deleteFile: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${route.params.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
|
||||
const d = await res.json()
|
||||
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
|
||||
downloaded.value = []
|
||||
await fetchOrganized(); load()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
}
|
||||
|
||||
function formatDate(d: string): string {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
async function renameEpisode(ep: any, _season: any) {
|
||||
epActionLoading.value[ep.id] = true
|
||||
try {
|
||||
const torrentHash = ep.paths?.[0]?.infohash ?? null
|
||||
const res = await fetch('/api/rename-episode', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ serie_id: Number(route.params.id), episode_id: ep.id, torrent_hash: torrentHash }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (!res.ok) { toast(d.error ?? 'Erreur rename', 'error'); return }
|
||||
if (d.renamed) { toast(`Renommé : ${d.new_name}`, 'success'); await fetchOrganized() }
|
||||
else toast(d.message ?? 'Nom déjà correct', 'success')
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { epActionLoading.value[ep.id] = false }
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) return m > 0 ? `${h}h ${m}min` : `${h}h`
|
||||
return `${m} min`
|
||||
async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
|
||||
epActionLoading.value[ep.id] = true
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${route.params.id}/${ep.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
|
||||
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
|
||||
clearEpDownloaded(ep)
|
||||
await fetchOrganized(); load()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { epActionLoading.value[ep.id] = false }
|
||||
}
|
||||
|
||||
const closeMenus = () => { downloadMenuOpen.value = false }
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
fetchSettings()
|
||||
fetchActiveDownloads()
|
||||
load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads()
|
||||
pollTimer = setInterval(fetchActiveDownloads, 5000)
|
||||
document.addEventListener('click', closeMenus)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
document.removeEventListener('click', closeMenus)
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,121 +1,19 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="shrink-0 px-4 md:px-6 py-3 border-b border-border flex flex-col gap-2">
|
||||
|
||||
<!-- Ligne 1 : recherche + filtres -->
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Rechercher…"
|
||||
class="settings-input pl-9 w-full py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filtres : icône only sur mobile, texte sur desktop -->
|
||||
<div class="relative shrink-0" ref="filterRef">
|
||||
<button
|
||||
@click="filterOpen = !filterOpen"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 relative"
|
||||
:class="hasActiveFilter ? 'border-accent text-accent' : ''"
|
||||
title="Filtres"
|
||||
>
|
||||
<SlidersHorizontal :size="15" />
|
||||
<span class="hidden md:inline text-xs">Filtres</span>
|
||||
<ChevronDown class="hidden md:inline transition-transform" :size="12" :class="filterOpen ? 'rotate-180' : ''" />
|
||||
<span v-if="hasActiveFilter" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="filterOpen"
|
||||
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-3 z-20 w-52 flex flex-col gap-3 shadow-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs text-muted mb-1.5">Disponibilité</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="f in filtersDisponibilite" :key="f.value"
|
||||
@click="activeFilter = f.value"
|
||||
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
|
||||
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
{{ f.label }}
|
||||
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-border pt-3">
|
||||
<p class="text-xs text-muted mb-1.5">État import</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="f in filtersImport" :key="f.value"
|
||||
@click="activeFilter = f.value"
|
||||
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
|
||||
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full shrink-0" :style="{ background: f.color }" />
|
||||
{{ f.label }}
|
||||
</div>
|
||||
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="hasActiveFilter"
|
||||
@click="activeFilter = 'all'"
|
||||
class="text-xs text-muted hover:text-primary transition-colors text-left pt-1 border-t border-border"
|
||||
>
|
||||
Effacer les filtres
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 2 : tri + taille + count -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
@click="activeSort = s.value"
|
||||
class="px-2.5 py-1 text-xs rounded-lg border transition-colors"
|
||||
:class="activeSort === s.value
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 border-l border-border pl-2 ml-auto">
|
||||
<button
|
||||
v-for="(_, size) in posterSizes" :key="size"
|
||||
@click="posterSize = size"
|
||||
class="w-7 h-7 text-[10px] rounded-md border transition-colors flex items-center justify-center"
|
||||
:class="posterSize === size
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-muted">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Légende barres -->
|
||||
<div class="shrink-0 px-4 md:px-6 py-2 flex items-center gap-4 border-b border-border">
|
||||
<div v-for="l in legend" :key="l.label" class="flex items-center gap-1.5">
|
||||
<span class="w-3 h-1.5 rounded-full" :style="{ background: l.color }" />
|
||||
<span class="text-[11px] text-muted">{{ l.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SeriesToolbar
|
||||
v-model:search="search"
|
||||
v-model:active-filter="activeFilter"
|
||||
v-model:active-sort="activeSort"
|
||||
v-model:poster-size="posterSize"
|
||||
:poster-sizes="posterSizes"
|
||||
:count="filtered.length"
|
||||
:has-active-filter="hasActiveFilter"
|
||||
:filters-disponibilite="filtersDisponibilite"
|
||||
:filters-import="filtersImport"
|
||||
:sort-options="sortOptions"
|
||||
:legend="legend"
|
||||
/>
|
||||
|
||||
<!-- Grille -->
|
||||
<div class="px-4 md:px-6 py-6">
|
||||
@@ -189,12 +87,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Search, SlidersHorizontal, ChevronDown, Tv } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { Tv } from 'lucide-vue-next'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { usePosterSize } from '@/composables/usePosterSize'
|
||||
import SeriesToolbar from '@/components/series/SeriesToolbar.vue'
|
||||
|
||||
defineOptions({ name: 'SeriesView' })
|
||||
|
||||
@@ -206,10 +104,6 @@ const { current: posterSize, sizes: posterSizes } = usePosterSize()
|
||||
const search = ref('')
|
||||
const activeFilter = ref('all')
|
||||
const activeSort = ref('alpha')
|
||||
const filterOpen = ref(false)
|
||||
const filterRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(filterRef, () => { filterOpen.value = false })
|
||||
|
||||
const filtersDisponibilite = [
|
||||
{ label: 'Toutes', value: 'all' },
|
||||
@@ -320,4 +214,4 @@ onActivated(async () => {
|
||||
onUnmounted(() => {
|
||||
if (dlInterval) clearInterval(dlInterval)
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
@@ -8,38 +8,47 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bannière données manquantes -->
|
||||
<div
|
||||
v-if="status.empty"
|
||||
class="border border-accent/30 bg-accent-muted rounded-lg p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-1">Données manquantes</p>
|
||||
<p class="text-sm text-secondary">
|
||||
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
|
||||
Téléchargez les données pour utiliser FanKarr.
|
||||
<!-- Loading -->
|
||||
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Bannière données manquantes -->
|
||||
<div
|
||||
v-if="status.empty"
|
||||
class="border border-accent/30 bg-accent-muted rounded-lg p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-1">Données manquantes</p>
|
||||
<p class="text-sm text-secondary">
|
||||
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
|
||||
Téléchargez les données pour utiliser FanKarr.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary whitespace-nowrap">
|
||||
{{ updating ? 'Téléchargement...' : 'Télécharger' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Statut -->
|
||||
<div class="settings-card">
|
||||
<p class="text-xs text-muted mb-1">Statut</p>
|
||||
<p class="text-sm text-primary font-medium">
|
||||
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary whitespace-nowrap">
|
||||
{{ updating ? 'Téléchargement...' : 'Télécharger' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Statut -->
|
||||
<div class="settings-card">
|
||||
<p class="text-xs text-muted mb-1">Statut</p>
|
||||
<p class="text-sm text-primary font-medium">
|
||||
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Action -->
|
||||
<div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary">
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour maintenant' }}
|
||||
</button>
|
||||
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary">
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour maintenant' }}
|
||||
</button>
|
||||
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -51,11 +60,13 @@ import { useToast } from '@/composables/useToast'
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const updating = ref(false)
|
||||
const loaded = ref(false)
|
||||
const status = ref({ exists: false, count: 0, empty: true })
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch('/api/torrents/status', { credentials: 'include' })
|
||||
if (res.ok) status.value = await res.json()
|
||||
loaded.value = true
|
||||
})
|
||||
|
||||
async function update() {
|
||||
|
||||
@@ -40,6 +40,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tableau comparatif -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-primary mb-1">Comparatif des clients</h3>
|
||||
<p class="text-xs text-muted mb-3">Fonctionnalités supportées par chaque client.</p>
|
||||
<div class="overflow-x-auto rounded-xl border border-border">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-shell">
|
||||
<th class="text-left px-4 py-2.5 text-muted font-medium">Client</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Sélection fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Catégories</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Dossier cible</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Remap. chemin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/60">
|
||||
<tr v-for="row in clientMatrix" :key="row.id" class="hover:bg-hover/40 transition-colors">
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === true ? 'bg-green-500' :
|
||||
healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === false ? 'bg-red-500' : 'bg-transparent'" />
|
||||
<span class="text-primary font-medium">{{ row.label }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center px-3 py-2.5">
|
||||
<span :class="row.fileSelect === '✗' ? 'text-muted' : row.fileSelect.startsWith('Async') ? 'text-yellow-500' : 'text-green-400'">
|
||||
{{ row.fileSelect }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-green-400' : 'text-muted'">{{ row.categories ? '✓' : '✗' }}</td>
|
||||
<td class="text-center px-3 py-2.5 text-green-400">✓</td>
|
||||
<td class="text-center px-3 py-2.5 text-green-400">✓</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted mt-2">
|
||||
<span class="text-green-400">✓ Natif</span> — sélection à l'ajout, aucun fichier superflu ·
|
||||
<span class="text-green-400">Async ✓</span> — sélection dès réception des métadonnées, avant tout téléchargement ·
|
||||
<span class="text-muted">✗</span> — non supporté, torrent complet téléchargé
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal ajout -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
@@ -66,18 +110,61 @@
|
||||
<option v-for="def in availableClients" :key="def.id" :value="def.id">{{ def.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="currentDefinition" v-for="field in currentDefinition.fields" :key="field.key">
|
||||
<label class="settings-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder ?? ''"
|
||||
class="settings-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Champs principaux -->
|
||||
<template v-if="currentDefinition">
|
||||
<div v-for="field in basicFields" :key="field.key">
|
||||
<label class="settings-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder ?? ''"
|
||||
class="settings-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Paramètres avancés -->
|
||||
<div v-if="advancedFields.length > 0">
|
||||
<button
|
||||
type="button"
|
||||
@click="advancedOpen = !advancedOpen"
|
||||
class="flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-full py-1"
|
||||
>
|
||||
<ChevronDown
|
||||
:size="13"
|
||||
class="transition-transform duration-200 shrink-0"
|
||||
:class="advancedOpen ? 'rotate-180' : ''"
|
||||
/>
|
||||
Paramètres avancés
|
||||
</button>
|
||||
|
||||
<div v-if="advancedOpen" class="flex flex-col gap-3 mt-2 pl-3 border-l border-border">
|
||||
<div v-for="field in advancedFields" :key="field.key">
|
||||
<label class="settings-label flex items-center gap-1.5">
|
||||
{{ field.label }}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-4 h-4 rounded-full border border-border text-[10px] text-muted cursor-default leading-none shrink-0 relative group"
|
||||
tabindex="-1"
|
||||
>
|
||||
i
|
||||
<span class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-56 text-[11px] text-secondary bg-card border border-border rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-10 leading-relaxed">
|
||||
{{ fieldTooltip(field.key) }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
placeholder=""
|
||||
class="settings-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-if="!modal.tested && modal.type" class="text-xs text-muted">
|
||||
@@ -110,6 +197,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
@@ -118,6 +206,14 @@ const clients = ref<any[]>([])
|
||||
const availableClients = ref<any[]>([])
|
||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
|
||||
const clientMatrix = [
|
||||
{ id: 'qbittorrent', label: 'qBittorrent', fileSelect: 'Async ✓', categories: true },
|
||||
{ id: 'transmission', label: 'Transmission', fileSelect: '✓ Natif', categories: true },
|
||||
{ id: 'rtorrent', label: 'rTorrent / ruTorrent', fileSelect: 'Async ✓', categories: true },
|
||||
{ id: 'utorrent', label: 'uTorrent', fileSelect: 'Async ✓', categories: true },
|
||||
{ id: 'synology-ds', label: 'Synology Download Station', fileSelect: '✗', categories: false },
|
||||
]
|
||||
|
||||
const modal = ref({
|
||||
open : false,
|
||||
uuid : null as string | null,
|
||||
@@ -129,10 +225,32 @@ const modal = ref({
|
||||
tested : false,
|
||||
})
|
||||
|
||||
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath']
|
||||
|
||||
const FIELD_TOOLTIPS: Record<string, string> = {
|
||||
savePath : 'Optionnel. Permet de placer les téléchargements dans un sous-dossier spécifique à l\'intérieur du dossier déjà configuré dans la gestion des médias.',
|
||||
remotePath : 'Si le client tourne sur une autre machine, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.',
|
||||
localPath : 'Chemin équivalent au chemin distant, mais vu par FanKarr sur sa machine (ex: /mnt/nas/downloads). Les deux champs fonctionnent en binôme pour faire la correspondance.',
|
||||
}
|
||||
|
||||
function fieldTooltip(key: string): string {
|
||||
return FIELD_TOOLTIPS[key] ?? ''
|
||||
}
|
||||
|
||||
const advancedOpen = ref(false)
|
||||
|
||||
const currentDefinition = computed(() =>
|
||||
availableClients.value.find(d => d.id === modal.value.type) ?? null
|
||||
)
|
||||
|
||||
const basicFields = computed(() =>
|
||||
currentDefinition.value?.fields.filter((f: any) => !ADVANCED_KEYS.includes(f.key)) ?? []
|
||||
)
|
||||
|
||||
const advancedFields = computed(() =>
|
||||
currentDefinition.value?.fields.filter((f: any) => ADVANCED_KEYS.includes(f.key)) ?? []
|
||||
)
|
||||
|
||||
function clientLabel(type: string) {
|
||||
return availableClients.value.find(d => d.id === type)?.label ?? type
|
||||
}
|
||||
@@ -177,6 +295,7 @@ async function deleteClient(uuid: string) {
|
||||
|
||||
function openAddModal() {
|
||||
modal.value = { open: true, uuid: null, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
||||
advancedOpen.value = false
|
||||
}
|
||||
|
||||
function editClient(client: any) {
|
||||
@@ -197,6 +316,7 @@ function closeModal() { modal.value.open = false }
|
||||
function onTypeChange() {
|
||||
modal.value.config = {}
|
||||
modal.value.tested = false
|
||||
advancedOpen.value = false
|
||||
const def = currentDefinition.value
|
||||
if (def) {
|
||||
for (const field of def.fields) {
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 px-4 md:px-8 py-6 max-w-4xl">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Gestion des imports</h2>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
Visualisez et renommez vos fichiers importés selon le mode actuel
|
||||
<span class="ml-1 px-1.5 py-0.5 rounded text-[10px]"
|
||||
:class="nfoSupport ? 'bg-accent/10 text-accent' : 'bg-hover text-muted'">
|
||||
{{ nfoSupport ? 'Mode NFO' : 'Mode Formaté' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="load" :disabled="loading" class="btn-secondary">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
|
||||
:class="loading ? 'animate-spin' : ''">
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
|
||||
<polyline points="16 5 21 5 21 10"/>
|
||||
</svg>
|
||||
Actualiser
|
||||
</button>
|
||||
<button
|
||||
@click="purgeNfo"
|
||||
:disabled="purgingNfo"
|
||||
class="btn-secondary text-red-400 hover:text-red-300"
|
||||
title="Supprime tous les .nfo, .png, .jpg, .tbn dans les dossiers séries"
|
||||
>
|
||||
{{ purgingNfo ? 'Suppression…' : 'Purger NFO / images' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="totalNeedsRename > 0"
|
||||
@click="renameAll()"
|
||||
:disabled="renamingAll"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ renamingAll ? 'Renommage…' : `Tout renommer (${totalNeedsRename})` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="series.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
|
||||
<p class="text-sm text-muted">Aucune série importée</p>
|
||||
<p class="text-xs text-muted">Importez des séries depuis la page Activité ou via l'import manuel.</p>
|
||||
</div>
|
||||
|
||||
<!-- Résultat rename all -->
|
||||
<div v-if="renameResult" class="settings-card flex items-center justify-between gap-3">
|
||||
<p class="text-xs" :class="renameResult.errors.length > 0 ? 'text-yellow-500' : 'text-green-400'">
|
||||
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}
|
||||
<span v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</span>
|
||||
</p>
|
||||
<button @click="renameResult = null" class="text-xs text-muted hover:text-primary">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste séries -->
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="serie in filteredSeries"
|
||||
:key="serie.serie_id"
|
||||
class="settings-card flex flex-col gap-0"
|
||||
>
|
||||
<!-- Header série -->
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<button @click="toggleSerie(serie.serie_id)" class="text-muted hover:text-primary transition-colors">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
|
||||
class="transition-transform" :class="collapsed.has(serie.serie_id) ? '' : 'rotate-180'">
|
||||
<polyline points="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<p class="text-sm font-medium text-primary">{{ serie.serie_title }}</p>
|
||||
<span class="text-xs text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
|
||||
<span v-if="serie.needs_rename > 0"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/20">
|
||||
{{ serie.needs_rename }} à renommer
|
||||
</span>
|
||||
<span v-if="serie.episodes.filter((e: any) => !e.file_exists).length > 0"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">
|
||||
{{ serie.episodes.filter((e: any) => !e.file_exists).length }} introuvable{{ serie.episodes.filter((e: any) => !e.file_exists).length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span v-else-if="serie.needs_rename === 0" class="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400 border border-green-500/20">
|
||||
✓ À jour
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="serie.needs_rename > 0" @click="renameAll(serie.serie_id)" :disabled="renamingAll" class="text-xs text-accent hover:underline">
|
||||
Tout renommer
|
||||
</button>
|
||||
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="text-xs text-muted hover:text-primary transition-colors">
|
||||
Désimporter
|
||||
</button>
|
||||
<button @click="unimportSerie(serie, true)" :disabled="renamingAll" class="text-xs text-red-400 hover:text-red-300 transition-colors">
|
||||
Supprimer fichiers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste épisodes -->
|
||||
<div v-if="!collapsed.has(serie.serie_id)" class="flex flex-col divide-y divide-border/50 mt-2 -mx-4 px-4">
|
||||
<div
|
||||
v-for="ep in serie.episodes"
|
||||
:key="ep.episode_id"
|
||||
class="flex items-center gap-3 py-2.5"
|
||||
>
|
||||
<!-- Numéro -->
|
||||
<span class="text-xs text-muted font-mono shrink-0 w-14">
|
||||
S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}
|
||||
</span>
|
||||
|
||||
<!-- Noms -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-primary truncate">{{ ep.title }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span class="text-[11px] font-mono text-muted truncate max-w-xs">{{ ep.current_name }}</span>
|
||||
<template v-if="ep.needs_rename">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0 text-yellow-400">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
<span class="text-[11px] font-mono text-yellow-400 truncate max-w-xs">{{ ep.expected_name }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span v-if="!ep.file_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">Introuvable</span>
|
||||
<span v-else-if="!ep.needs_rename" class="text-[10px] text-green-400">✓</span>
|
||||
<button
|
||||
v-else
|
||||
@click="renameSingle(ep, serie)"
|
||||
:disabled="renamingEp[ep.episode_id]"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 transition-colors"
|
||||
>
|
||||
{{ renamingEp[ep.episode_id] ? '…' : 'Renommer' }}
|
||||
</button>
|
||||
<button
|
||||
@click="unimportEp(ep, false)"
|
||||
:disabled="renamingEp[ep.episode_id]"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
Désimporter
|
||||
</button>
|
||||
<button
|
||||
@click="unimportEp(ep, true)"
|
||||
:disabled="renamingEp[ep.episode_id]"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-red-500/20 text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
const loading = ref(true)
|
||||
const renamingAll = ref(false)
|
||||
const purgingNfo = ref(false)
|
||||
const series = ref<any[]>([])
|
||||
const nfoSupport = ref(false)
|
||||
const collapsed = ref<Set<number>>(new Set())
|
||||
const renamingEp = ref<Record<number, boolean>>({})
|
||||
const renameResult = ref<{ done: number; errors: any[] } | null>(null)
|
||||
const showOnlyNeedsRename = ref(false)
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
|
||||
|
||||
const filteredSeries = computed(() =>
|
||||
showOnlyNeedsRename.value ? series.value.filter(s => s.needs_rename > 0) : series.value
|
||||
)
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/organized-summary', { credentials: 'include' })
|
||||
if (!res.ok) { toast('Erreur chargement', 'error'); return }
|
||||
const data = await res.json()
|
||||
series.value = data.series ?? []
|
||||
nfoSupport.value = data.nfo_support ?? false
|
||||
// Replier les séries sans rename par défaut
|
||||
collapsed.value = new Set(series.value.filter(s => s.needs_rename === 0).map((s: any) => s.serie_id))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSerie(id: number) {
|
||||
if (collapsed.value.has(id)) collapsed.value.delete(id)
|
||||
else collapsed.value.add(id)
|
||||
// Forcer la réactivité
|
||||
collapsed.value = new Set(collapsed.value)
|
||||
}
|
||||
|
||||
async function renameAll(serieId?: number) {
|
||||
renamingAll.value = true
|
||||
renameResult.value = null
|
||||
try {
|
||||
const res = await fetch('/api/rename-all', {
|
||||
method : 'POST',
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body : JSON.stringify(serieId ? { serie_id: serieId } : {}),
|
||||
})
|
||||
const data = await res.json()
|
||||
renameResult.value = { done: data.done, errors: data.errors ?? [] }
|
||||
if (data.done > 0) await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
renamingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function renameSingle(ep: any, serie: any) {
|
||||
renamingEp.value[ep.episode_id] = true
|
||||
try {
|
||||
const res = await fetch('/api/rename-episode', {
|
||||
method : 'POST',
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body : JSON.stringify({ serie_id: serie.serie_id, episode_id: ep.episode_id, torrent_hash: ep.torrent_hash }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
|
||||
toast(`Renommé : ${data.new_name}`, 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
renamingEp.value[ep.episode_id] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeNfo() {
|
||||
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
|
||||
purgingNfo.value = true
|
||||
try {
|
||||
const res = await fetch('/api/purge-nfo', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
|
||||
toast(`${data.deleted} fichier${data.deleted > 1 ? 's' : ''} supprimé${data.deleted > 1 ? 's' : ''}${data.errors.length > 0 ? ` (${data.errors.length} erreur${data.errors.length > 1 ? 's' : ''})` : ''}`, data.errors.length > 0 ? 'error' : 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
purgingNfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function unimportSerie(serie: any, deleteFile: boolean) {
|
||||
if (!confirm(`Désimporter "${serie.serie_title}"${deleteFile ? ' et supprimer les fichiers' : ''} ?`)) return
|
||||
renamingAll.value = true
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${serie.serie_id}?deleteFile=${deleteFile}`, {
|
||||
method: 'DELETE', credentials: 'include',
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
|
||||
const data = await res.json()
|
||||
toast(`${data.removed} épisode(s) désimporté(s) ✓`, 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
renamingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function unimportEp(ep: any, deleteFile: boolean) {
|
||||
renamingEp.value[ep.episode_id] = true
|
||||
try {
|
||||
// On cherche le serie_id depuis la liste
|
||||
const serie = series.value.find(s => s.episodes.some((e: any) => e.episode_id === ep.episode_id))
|
||||
if (!serie) return
|
||||
const res = await fetch(`/api/organized/${serie.serie_id}/${ep.episode_id}?deleteFile=${deleteFile}`, {
|
||||
method : 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
|
||||
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
renamingEp.value[ep.episode_id] = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -6,131 +6,131 @@
|
||||
<p class="text-sm text-muted mt-1">Configuration des dossiers et du comportement d'import.</p>
|
||||
</div>
|
||||
|
||||
<!-- Dossiers -->
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="settings-label">Dossier téléchargements</label>
|
||||
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
||||
</div>
|
||||
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
|
||||
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
|
||||
{{ form.completePath || '/' }}
|
||||
</button>
|
||||
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
|
||||
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="settings-label">Médiathèque Fankai</label>
|
||||
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
||||
</div>
|
||||
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
|
||||
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
|
||||
{{ form.mediaPath || '/' }}
|
||||
</button>
|
||||
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
|
||||
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
|
||||
<!-- Alerte globale si un des deux paths est sur / -->
|
||||
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
|
||||
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
|
||||
<template v-else>
|
||||
|
||||
<!-- Chemins -->
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="settings-label">Dossier téléchargements</label>
|
||||
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
||||
</div>
|
||||
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
|
||||
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
|
||||
{{ form.completePath || '/' }}
|
||||
</button>
|
||||
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
|
||||
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="settings-label">Médiathèque Fankai</label>
|
||||
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
||||
</div>
|
||||
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
|
||||
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
|
||||
{{ form.mediaPath || '/' }}
|
||||
</button>
|
||||
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
|
||||
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerte chemins non configurés -->
|
||||
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
|
||||
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
|
||||
<p class="text-xs text-muted">
|
||||
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
|
||||
Configurez-les ci-dessus avant de sauvegarder.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode import -->
|
||||
<div class="settings-card flex flex-col gap-2">
|
||||
<label class="settings-label">Mode d'import</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="mode in (['hardlink', 'copy', 'move'] as const)"
|
||||
:key="mode"
|
||||
@click="form.organizeMode = mode"
|
||||
class="px-4 py-2 text-xs rounded-lg border transition-colors capitalize"
|
||||
:class="form.organizeMode === mode
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ mode === 'hardlink' ? 'Hardlink' : mode === 'copy' ? 'Copier' : 'Déplacer' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
|
||||
Configurez-les ci-dessus avant de sauvegarder.
|
||||
<span class="text-primary">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode import -->
|
||||
<div class="settings-card flex flex-col gap-2">
|
||||
<label class="settings-label">Mode d'import</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="form.organizeMode = 'hardlink'"
|
||||
class="px-4 py-2 text-xs rounded-lg border transition-colors"
|
||||
:class="form.organizeMode === 'hardlink'
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
Hardlink
|
||||
</button>
|
||||
<button
|
||||
@click="form.organizeMode = 'copy'"
|
||||
class="px-4 py-2 text-xs rounded-lg border transition-colors"
|
||||
:class="form.organizeMode === 'copy'
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
Copier
|
||||
</button>
|
||||
<button
|
||||
@click="form.organizeMode = 'move'"
|
||||
class="px-4 py-2 text-xs rounded-lg border transition-colors"
|
||||
:class="form.organizeMode === 'move'
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
Déplacer
|
||||
</button>
|
||||
<!-- Toggles -->
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<SettingsToggle
|
||||
v-model="form.autoImport"
|
||||
label="Import automatique"
|
||||
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
|
||||
/>
|
||||
<SettingsToggle
|
||||
:model-value="form.nfoSupport"
|
||||
@update:model-value="onNfoToggle"
|
||||
label="NFO / Métadonnées"
|
||||
description="Télécharge les NFO et images depuis GitLab lors de l'import (Kodi, Infuse, etc.)."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-if="form.organizeMode === 'move'"
|
||||
v-model="form.deleteTorrentOnMove"
|
||||
label="Supprimer le torrent après déplacement"
|
||||
description="Supprime automatiquement le torrent du client après un import en mode Déplacer."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.autoUnimportMissing"
|
||||
label="Désimporter si fichier manquant"
|
||||
description="Si un fichier importé n'est plus trouvé sur le disque lors du scan, il est automatiquement retiré de la bibliothèque."
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
<span class="text-primary">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Toggles -->
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<!-- Plex -->
|
||||
<div class="settings-card flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="settings-label">Plex</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
Créez une bibliothèque Plex connectée à l'agent de métadonnées Fankai.
|
||||
Requiert Plex Media Server 1.43+ pour la configuration automatique de l'agent.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="plexOpen = true" class="btn-secondary shrink-0">Configurer Plex</button>
|
||||
</div>
|
||||
|
||||
<SettingsToggle
|
||||
v-model="form.autoImport"
|
||||
label="Import automatique"
|
||||
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
v-model="form.nfoSupport"
|
||||
label="NFO / Métadonnées"
|
||||
description="Télécharge les NFO et images depuis GitLab lors de l'import (Kodi, Infuse, etc.)."
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
v-if="form.organizeMode === 'move'"
|
||||
v-model="form.deleteTorrentOnMove"
|
||||
label="Supprimer le torrent après déplacement"
|
||||
description="Supprime automatiquement le torrent du client après un import en mode Déplacer."
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="save" :disabled="saving" class="btn-primary">
|
||||
{{ saving ? '...' : 'Sauvegarder' }}
|
||||
</button>
|
||||
<button @click="scan" :disabled="scanning" class="btn-secondary">
|
||||
{{ scanning ? 'Scan...' : 'Analyser médiathèque' }}
|
||||
</button>
|
||||
<span
|
||||
v-if="scanResult"
|
||||
class="text-xs"
|
||||
:class="scanResult.added > 0 ? 'text-green-400' : 'text-muted'"
|
||||
>
|
||||
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
|
||||
</span>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="save" :disabled="saving" class="btn-primary">
|
||||
{{ saving ? '...' : 'Sauvegarder' }}
|
||||
</button>
|
||||
<button @click="scan" :disabled="scanning" class="btn-secondary">
|
||||
{{ scanning ? 'Scan...' : 'Analyser médiathèque' }}
|
||||
</button>
|
||||
<span v-if="scanResult" class="text-xs" :class="scanResult.added > 0 ? 'text-green-400' : 'text-muted'">
|
||||
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Folder Picker -->
|
||||
@@ -140,6 +140,41 @@
|
||||
@select="onPickerSelect"
|
||||
@cancel="picker.open = false"
|
||||
/>
|
||||
|
||||
<!-- Plex Wizard -->
|
||||
<PlexWizard
|
||||
v-if="plexOpen"
|
||||
:media-path="form.mediaPath"
|
||||
@close="plexOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Confirmation NFO -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="nfoConfirmOpen"
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
|
||||
@click.self="nfoConfirmOpen = false"
|
||||
>
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h3 class="text-sm font-semibold text-primary">Activer les NFO / Métadonnées ?</h3>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex ou Emby</span>,
|
||||
les métadonnées sont déjà gérées directement par l'agent — activer les NFO est inutile
|
||||
et peut créer des conflits.
|
||||
</p>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Activez cette option uniquement si vous utilisez <span class="text-primary">Kodi, Infuse</span>
|
||||
ou un lecteur qui lit les fichiers NFO locaux.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button @click="confirmNfo" class="btn-primary text-xs">Oui, activer quand même</button>
|
||||
<button @click="nfoConfirmOpen = false" class="btn-secondary text-xs">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -147,33 +182,46 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
import PlexWizard from '@/components/settings/PlexWizard.vue'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const saving = ref(false)
|
||||
const scanning = ref(false)
|
||||
const isDocker = ref(false)
|
||||
const scanResult = ref<{ found: number; added: number } | null>(null)
|
||||
const saving = ref(false)
|
||||
const scanning = ref(false)
|
||||
const isDocker = ref(false)
|
||||
const loaded = ref(false)
|
||||
const plexOpen = ref(false)
|
||||
const nfoConfirmOpen = ref(false)
|
||||
const scanResult = ref<{ found: number; added: number } | null>(null)
|
||||
|
||||
function onNfoToggle(val: boolean) {
|
||||
if (val && !form.value.nfoSupport) {
|
||||
nfoConfirmOpen.value = true
|
||||
} else {
|
||||
form.value.nfoSupport = val
|
||||
}
|
||||
}
|
||||
|
||||
function confirmNfo() {
|
||||
form.value.nfoSupport = true
|
||||
nfoConfirmOpen.value = false
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
mediaPath : '',
|
||||
completePath : '',
|
||||
organizeMode : 'hardlink' as 'hardlink' | 'copy' | 'move',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
deleteTorrentOnMove: false,
|
||||
mediaPath : '',
|
||||
completePath : '',
|
||||
organizeMode : 'hardlink' as 'hardlink' | 'copy' | 'move',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
deleteTorrentOnMove : false,
|
||||
autoUnimportMissing : false,
|
||||
})
|
||||
|
||||
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
||||
open: false,
|
||||
field: 'completePath',
|
||||
currentPath: '/',
|
||||
open: false, field: 'completePath', currentPath: '/',
|
||||
})
|
||||
|
||||
// FIX : détection path non configuré (vide ou racine /)
|
||||
function isRootPath(p: string): boolean {
|
||||
return !p || p === '/'
|
||||
}
|
||||
function isRootPath(p: string): boolean { return !p || p === '/' }
|
||||
|
||||
const hasUnconfiguredPaths = computed(() =>
|
||||
isRootPath(form.value.completePath) || isRootPath(form.value.mediaPath)
|
||||
@@ -188,17 +236,6 @@ function onPickerSelect(path: string) {
|
||||
picker.value.open = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [settingsRes, systemRes, infoRes] = await Promise.all([
|
||||
fetch('/api/settings', { credentials: 'include' }),
|
||||
fetch('/api/system', { credentials: 'include' }),
|
||||
fetch('/api/system/info', { credentials: 'include' }),
|
||||
])
|
||||
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
|
||||
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
|
||||
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -214,19 +251,14 @@ async function save() {
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
scanning.value = true
|
||||
scanResult.value = null
|
||||
try {
|
||||
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
scanResult.value = { found: data.found, added: data.added }
|
||||
toast(
|
||||
data.added > 0
|
||||
? `${data.found} fichiers analysés — ${data.added} ajoutés`
|
||||
: `${data.found} fichiers analysés — rien de nouveau`,
|
||||
'success'
|
||||
)
|
||||
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
|
||||
} else {
|
||||
toast("Erreur lors de l'analyse", 'error')
|
||||
}
|
||||
@@ -236,4 +268,16 @@ async function scan() {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
onMounted(async () => {
|
||||
const [settingsRes, systemRes, infoRes] = await Promise.all([
|
||||
fetch('/api/settings', { credentials: 'include' }),
|
||||
fetch('/api/system', { credentials: 'include' }),
|
||||
fetch('/api/system/info', { credentials: 'include' }),
|
||||
])
|
||||
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
|
||||
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
|
||||
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
|
||||
loaded.value = true
|
||||
})
|
||||
</script>
|
||||
@@ -1 +1 @@
|
||||
3.8.0
|
||||
3.14.1
|
||||