Compare commits

...
46 Commits
Author SHA1 Message Date
MasutaYunikon 6142dbdb25 Merge pull request #99 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.2
2026-04-26 16:35:15 +02:00
MasutaYunikon 01040ada26 chore(main): release 3.11.2 2026-04-26 16:34:33 +02:00
MasutaYunikon ee2d83993a Merge remote-tracking branch 'origin/main' 2026-04-26 16:34:09 +02:00
MasutaYunikon 0103960645 fix: ajout de undici dans le package.json 2026-04-26 16:34:00 +02:00
github-actions[bot] b8e30e13f2 chore: update version.txt to v3.11.1 2026-04-26 14:16:42 +00:00
MasutaYunikon b8554af71b Merge pull request #98 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.1
2026-04-26 16:16:22 +02:00
MasutaYunikon e130e945d5 chore(main): release 3.11.1 2026-04-26 15:57:11 +02:00
MasutaYunikon 7c4496ae1d Merge remote-tracking branch 'origin/main' 2026-04-26 15:56:44 +02:00
MasutaYunikon cfd0914b9b fix: le setup plex peux maintenant etre utilisé sur un serveur local (probleme de certificat) 2026-04-26 15:56:35 +02:00
github-actions[bot] 53a6f0abfc chore: update version.txt to v3.11.0 2026-04-23 20:06:51 +00:00
MasutaYunikon 0c2c9ca456 Merge pull request #95 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.0
2026-04-23 22:06:23 +02:00
MasutaYunikon 74a6d0a895 chore(main): release 3.11.0 2026-04-23 22:05:02 +02:00
MasutaYunikon 01e02502c0 Merge remote-tracking branch 'origin/main' 2026-04-23 22:04:28 +02:00
MasutaYunikon e1e68ef2bf fix: renommage d'episodes qui ne fonctionne pas en 1 par 1
feat: ajout de log pour le bouton tout renommer

feat: ajout du support multifile sur utorrent

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

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

fix: prise en charge de multiple torrent sur les telechargement d'episodes
2026-04-21 23:08:41 +02:00
MasutaYunikon 79b6a8bbb4 fix: ne retire pas les fichiers quand on en ajoute sur le torrent 2026-04-21 15:58:01 +02:00
MasutaYunikon 7e53bab0fc feat: utilisation des bar de telechargement par fichier dans le torrent plutot que celui du torrent 2026-04-21 15:53:39 +02:00
MasutaYunikon 4955a730b7 fix: qbit utilisation de /start au lieu de /resume pour la v5 2026-04-21 15:33:32 +02:00
MasutaYunikon b3ff17ab11 fix: les torrents sont stopé lors de la selection de fichier pour eviter qu'il commence le telechargement des autres fichiers avant de set les indexs 2026-04-21 15:30:00 +02:00
MasutaYunikon f19cc027ee fix: les torrents sont stopé lors de la selection de fichier pour eviter qu'il commence le telechargement des autres fichiers avant de set les indexs 2026-04-21 15:22:06 +02:00
MasutaYunikon ed66c2c0a2 fix: bouton global de serie 2026-04-21 10:58:29 +02:00
MasutaYunikon e6ca1d2a36 fix: n'affiche plus tous les épisodes en train de se telecharger si seulement un fichier est selectionner 2026-04-21 10:42:02 +02:00
MasutaYunikon c9534c2011 fix: n'affiche plus tous les épisodes en train de se telecharger si seulement un fichier est selectionner 2026-04-21 10:37:40 +02:00
MasutaYunikon 1b2036b57b fix: ajout des boutons pour les episodes sans torrent 2026-04-21 10:27:48 +02:00
MasutaYunikon 247694ab3e feat: ajout des telechargments par episodes / saisons 2026-04-21 10:17:53 +02:00
MasutaYunikon 215379eca4 Merge remote-tracking branch 'origin/main' 2026-04-20 22:30:15 +02:00
MasutaYunikon 7661b65df4 fix: improve container detection to support Podman and other runtimes
Replace single /.dockerenv check with a robust isInContainer() helper
that covers Docker, Podman (/.containerenv), cgroup-based detection
(containerd, LXC) and /proc/1/environ as a general fallback.
2026-04-20 22:30:07 +02:00
github-actions[bot] 996064413f chore: update version.txt to v3.9.2 2026-04-16 19:18:54 +00:00
35 changed files with 1639 additions and 410 deletions

No files matched your search

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

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+68
View File
@@ -1,5 +1,73 @@
# Changelog # Changelog
## [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) ## [3.9.2](https://github.com/Masutayunikon/FanKarr/compare/v3.9.1...v3.9.2) (2026-04-16)
+123 -70
View File
@@ -1,58 +1,72 @@
<div align="center"> <div align="center">
# Fankarr # FanKarr
![Version](https://img.shields.io/github/v/release/masutayunikon/fankarr?style=flat-square&color=e8513a&label=version) ![Version](https://img.shields.io/github/v/release/masutayunikon/fankarr?style=flat-square&color=e8513a&label=version)
![Docker Image Size](https://img.shields.io/docker/image-size/masutayunikon/fankarr/latest?style=flat-square&color=1e2d3d&label=image) ![Docker Image Size](https://img.shields.io/docker/image-size/masutayunikon/fankarr/latest?style=flat-square&color=1e2d3d&label=image)
![Docker Pulls](https://img.shields.io/docker/pulls/masutayunikon/fankarr?style=flat-square&color=1e2d3d) ![Docker Pulls](https://img.shields.io/docker/pulls/masutayunikon/fankarr?style=flat-square&color=1e2d3d)
![GitHub Actions](https://img.shields.io/github/actions/workflow/status/masutayunikon/fankarr/containerize.yml?style=flat-square&label=build) ![GitHub Actions](https://img.shields.io/github/actions/workflow/status/masutayunikon/fankarr/containerize.yml?style=flat-square&label=build)
![License](https://img.shields.io/github/license/masutayunikon/fankarr?style=flat-square&color=5a7a94) ![License](https://img.shields.io/github/license/masutayunikon/fankarr?style=flat-square&color=5a7a94)
![Node](https://img.shields.io/badge/node-20-5a7a94?style=flat-square)
![Vue](https://img.shields.io/badge/vue-3-42b883?style=flat-square) ![Vue](https://img.shields.io/badge/vue-3-42b883?style=flat-square)
![Bun](https://img.shields.io/badge/bun-ready-f9f1e1?style=flat-square)
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)** **Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
![Banner](.github/images/banner.svg)
</div> </div>
--- ---
## Aperçu
<!-- [img] : vue catalogue complète — grille des affiches avec les barres d'état en bas de chaque affiche (couleurs : dispo / en cours / importé), barre de recherche visible -->
![Catalogue](.github/images/catalog.png) ![Catalogue](.github/images/catalog.png)
> Vue catalogue : toutes les séries Fankai avec leur état d'organisation. Les badges indiquent si les fichiers sont déjà présents dans votre médiathèque Jellyfin. > Vue catalogue : toutes les séries Fankai. Une barre en bas de chaque affiche indique l'état — disponible, en cours de téléchargement ou déjà importé dans votre médiathèque.
---
<!-- [img] : vue série — détail des saisons et épisodes avec badges VOSTFR/MULTI + drapeaux, boutons de téléchargement avec dropdown multi-sources, barres de progression par épisode -->
![Série](.github/images/serie.png)
> Vue série : épisodes avec badge de langue (VOSTFR / MULTI), progression par fichier, et dropdown de sélection quand plusieurs sources sont disponibles.
---
<!-- [img] : vue téléchargements — liste des torrents actifs avec barres de progression, état d'organisation, badges erreur au hover -->
![Téléchargements](.github/images/downloads.png)
> Vue téléchargements : suivi en temps réel, état d'organisation par torrent et détail des erreurs au survol.
--- ---
## Fonctionnalités ## Fonctionnalités
- **Catalogue complet** des séries Fankai avec affiches, statut et badges d'état ### 📥 Téléchargement
- **Téléchargement en un clic** vers qBittorrent — par épisode, par saison ou intégrale - **Catalogue complet** des séries Fankai avec affiches et état de disponibilité
- **Organisation automatique** des fichiers téléchargés vers votre médiathèque (hardlink ou déplacement) - **Téléchargement en un clic** vers votre client torrent — par épisode, par saison ou en intégrale
- **Suivi par épisode** — badge ✓ organisé sur chaque épisode et compteur par saison - **Dropdown intelligent** sur chaque bouton quand plusieurs sources sont disponibles (qualités différentes, packs différents)
- **Notifications** toast automatiques quand une organisation se termine - **"Tout télécharger"** unifié — sélectionne automatiquement les épisodes non couverts par l'intégrale choisie et complète avec les packs individuels
- **Page téléchargements** avec statut d'organisation et badge erreurs avec détail au hover - **Progression par fichier** — barre de téléchargement au niveau de l'épisode, même pour les fichiers dans un pack intégrale
- **Logs centralisés** avec filtre par niveau/source, rotation automatique et clear depuis l'UI
- **Multi-client torrent** — architecture extensible (qBittorrent supporté) ### 🗂️ 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 - **Authentification** par mot de passe avec session JWT
- **Compatible Runtipi / Docker** - **Multi-client torrent** — architecture extensible (qBittorrent supporté)
- **Compatible Docker / Runtipi / Binaire autonome**
---
![Serie](.github/images/serie.png)
> Vue série : détail des épisodes avec leur état de disponibilité torrent et d'organisation dans Jellyfin.
---
![Download](.github/images/download.png)
> Vue téléchargements : suivi en temps réel de l'organisation avec détail des erreurs au survol.
---
![Logs](.github/images/log.png)
> Page logs : historique complet des événements avec filtres et gestion de la taille.
--- ---
@@ -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). 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é) ### 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 ```yaml
services: services:
@@ -93,83 +107,121 @@ services:
docker compose up -d 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) ### 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) : Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
| Système | Archive | | Système | Archive |
| --------------------- | ------------------------- | | -------------------------------- | -------------------------------- |
| Windows x64 | `fankarr-windows-x64.zip` | | Windows x64 | `fankarr-windows-x64.zip` |
| Linux x64 | `fankarr-linux-x64.zip` | | Windows Legacy (anciens CPU) | `fankarr-windows-legacy.zip` |
| macOS (Apple Silicon) | `fankarr-macos.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-windows-x64/
├── fankarr.exe ← lancer ce fichier ├── fankarr.exe ← lancer ce fichier
└── public/ ← ne pas déplacer séparément ├── 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 ```bash
chmod +x fankarr chmod +x fankarr
./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 ### Variables d'environnement
| Variable | Défaut | Description | | Variable | Défaut | Description |
| ---------------- | ------------ | ----------------------------------------------------------------------- | | ------------------ | ------------- | ------------------------------------------------------------------------------- |
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) | | `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) | | `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
| `TZ` | — | Timezone (ex: `Europe/Paris`) | | `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `PORT` | `9898` | Port d'écoute du serveur | | `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 | | `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 | | `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 ### Premier démarrage
1. Ouvrir `http://localhost:9898` 1. Ouvrir `http://localhost:9898`
2. Créer un mot de passe à la première connexion 2. Créer un mot de passe à la première connexion
3. Aller dans **Paramètres** → ajouter votre client qBittorrent 3. Aller dans **Paramètres** → configurer votre client torrent
4. Vérifier les chemins `completePath` et `mediaPath` 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é) ou `move` 5. Choisir le mode d'organisation : `hardlink` (recommandé), `move` ou `copy`
6. Retourner au catalogue et télécharger 6. Optionnel : lancer le **scan** pour référencer les fichiers déjà présents sur le disque
7. Retourner au catalogue et télécharger
<!-- [img] : page paramètres — formulaire de configuration client torrent, chemins, mode d'organisation, toggles autoImport / NFO / désimport auto -->
![Paramètres](.github/images/settings.png)
--- ---
## Organisation des fichiers ## 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ū/
└── Black Lagoon Henshū/ ├── Saison 1/
└── Saison 1/ │ ├── Black Lagoon Henshū.S01E01.MULTI.1080p.x264-FANKAI.mkv
├── Black Lagoon Henshū S01E01.mkv │ ├── Black Lagoon Henshū.S01E02.MULTI.1080p.x264-FANKAI.mkv
├── Black Lagoon Henshū S01E02.mkv │ ├── Black Lagoon Henshū.S01E03.MULTI.1080p.x264-FANKAI.mkv
└── ... │ └── Black Lagoon Henshū.S01E04.MULTI.1080p.x264-FANKAI.mkv
├── Saison 2/
│ ├── Black Lagoon Henshū.S02E05.MULTI.1080p.x264-FANKAI.mkv
│ ├── Black Lagoon Henshū.S02E06.MULTI.1080p.x264-FANKAI.mkv
│ └── Black Lagoon Henshū.S02E07.MULTI.1080p.x264-FANKAI.mkv
└── Saison 3/
└── Black Lagoon Henshū.S03E08.MULTI.1080p.x264-FANKAI.mkv
``` ```
Le mode **hardlink** est recommandé si vos dossiers `complete` et `media` sont sur le même filesystem — les fichiers ne sont pas déplacés, qBittorrent continue de seeder. Le mode **hardlink** est recommandé si vos dossiers de téléchargement et de médiathèque sont sur le même filesystem — les fichiers ne sont pas déplacés et le client torrent continue de seeder sans interruption.
L'organisation se déclenche :
- **Automatiquement** dès la fin d'un téléchargement, et toutes les 5 minutes en arrière-plan pour les torrents déjà en seeding
- **Manuellement** depuis le bouton **Importer** dans la vue téléchargements
Le **scan médiathèque** permet de référencer des fichiers déjà présents sur le disque sans les déplacer — utile si vous avez déjà une bibliothèque existante.
---
## Logs
<!-- [img] : page logs — liste des événements avec filtres niveau/source, bouton clear, affichage compact -->
![Logs](.github/images/logs.png)
> Tous les événements sont horodatés et filtrables par source (`organize`, `api`, `torrent`, …) et niveau (`info`, `warn`, `error`, `debug`).
--- ---
## Runtipi ## 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** 1. Dans Runtipi, aller dans **Paramètres → App Stores**
2. Ajouter l'URL : `https://github.com/Masutayunikon/runtipi-appstore` 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 | | Couche | Technologie |
| ----------------- | ---------------------------------------- | | ----------------- | ---------------------------------------- |
| Frontend | Vue 3 + Vite + Tailwind CSS | | Frontend | Vue 3 + Vite + Tailwind CSS v4 |
| Backend | Express 5 + TypeScript | | Backend | Express 5 + TypeScript |
| Auth | JWT + bcrypt | | Auth | JWT + bcrypt |
| Worker | Node Worker Threads (organisation async) |
| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) | | Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) |
| Packaging binaire | Bun (self-contained, pas de Node requis) | | 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 - [fankai.fr](https://fankai.fr) — Le projet Fankai
- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) — Reconnaissance des métadonnées dans Jellyfin - [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 - [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
+1
View File
@@ -26,6 +26,7 @@
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"undici": "^8.1.0",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-router": "^5.0.3" "vue-router": "^5.0.3"
}, },
+9
View File
@@ -38,6 +38,9 @@ importers:
tailwindcss: tailwindcss:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1 version: 4.2.1
undici:
specifier: ^8.1.0
version: 8.1.0
vue: vue:
specifier: ^3.5.25 specifier: ^3.5.25
version: 3.5.29(typescript@5.9.3) version: 3.5.29(typescript@5.9.3)
@@ -1343,6 +1346,10 @@ packages:
undici-types@7.16.0: undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} 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: unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2645,6 +2652,8 @@ snapshots:
undici-types@7.16.0: {} undici-types@7.16.0: {}
undici@8.1.0: {}
unpipe@1.0.0: {} unpipe@1.0.0: {}
unplugin-utils@0.3.1: unplugin-utils@0.3.1:
+15 -3
View File
@@ -7,6 +7,18 @@ import { JWT_SECRET } from './secret.js'
import { DATA_DIR } from './config.js' import { DATA_DIR } from './config.js'
import { logger } from './logger.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 DATA_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10 const SALT_ROUNDS = 10
@@ -66,7 +78,7 @@ export function authSetup(req: Request, res: Response): void {
writeAuth({ username, passwordHash }) writeAuth({ username, passwordHash })
logger.info('auth', `Compte créé pour "${username}"`) 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.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true }) res.json({ success: true })
} }
@@ -91,9 +103,9 @@ export function authLogin(req: Request, res: Response): void {
return return
} }
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' }) const token = signToken(username)
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) 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 }) res.json({ success: true })
} }
+55 -2
View File
@@ -4,10 +4,63 @@ import path from 'path'
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined' export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
&& path.dirname((process as any).execPath) !== process.cwd() && 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 export const BASE_DIR = _isBunBinary
? path.dirname((process as any).execPath) ? path.dirname((process as any).execPath)
: process.cwd() : 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' ? '/config'
: path.join(BASE_DIR, 'config') : path.join(BASE_DIR, 'config');
+1 -1
View File
@@ -5,7 +5,7 @@ import fs from 'fs'
const GITHUB_BASE = process.env.GITHUB_BASE const GITHUB_BASE = process.env.GITHUB_BASE
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main' ?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
const CACHE_TTL_MS = 6 * 60 * 60 * 1000 const CACHE_TTL_MS = 1 * 60 * 60 * 1000
interface CacheEntry<T> { data: T; expiresAt: number } interface CacheEntry<T> { data: T; expiresAt: number }
const _cache = new Map<string, CacheEntry<any>>() const _cache = new Map<string, CacheEntry<any>>()
+4 -2
View File
@@ -380,8 +380,10 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } } for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) { if (!src) {
error(`Fichier source introuvable : ${filename}`) // Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0).
result.errors.push({ file: filename, error: 'Source introuvable' }) // On skip silencieusement — pas une erreur.
debug(`Fichier non disponible (skip) : ${filename}`)
result.skipped++
continue continue
} }
+77
View File
@@ -327,6 +327,83 @@ export async function autoOrganizeAll(
worker.postMessage({ type: 'run', torrents, seriesData }) worker.postMessage({ type: 'run', torrents, seriesData })
} }
// ── 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)
const expectedName: string = nfoSupport
? (ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName)
: (ep.formatted_name?.trim() ? ep.formatted_name.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 ───────────────────────────────────────────── // ── Import manuel ─────────────────────────────────────────────
export async function organizeTorrent( export async function organizeTorrent(
+8 -3
View File
@@ -13,10 +13,15 @@ import { recentOrganized } from '../lib/notifs.js'
const router = Router() const router = Router()
router.post('/download', requireAuth, async (req, res) => { router.post('/download', requireAuth, async (req, res) => {
const url = req.body.magnet ?? req.body.torrent_url // 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 } if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
logger.info('api', 'Téléchargement demandé') const file_index: number | null = req.body.file_index != null ? Number(req.body.file_index) : null
const results = await dispatchDownload(url) const file_path : string | null = req.body.file_path ?? null
logger.info('api', `Téléchargement demandé via ${torrent_url ? '.torrent' : 'magnet'}${file_index != null ? ` (fichier #${file_index})` : ''}`)
const results = await dispatchDownload(url, { file_index, file_path, magnet })
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results }) res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
}) })
+57 -5
View File
@@ -175,7 +175,13 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
if (!foundEp) { res.status(404).json({ error: 'Épisode introuvable' }); return } if (!foundEp) { res.status(404).json({ error: 'Épisode introuvable' }); return }
const { ep, season } = foundEp const { ep, season } = foundEp
const hash = torrent_hash?.toLowerCase() ?? 'manual' const hash = torrent_hash?.toLowerCase() ?? 'manual'
const orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)] let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
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 } if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName) const srcExt = path.extname(currentName)
@@ -192,7 +198,6 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
if (!fs.existsSync(oldPath)) throw new Error('Fichier source introuvable sur le disque') 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}`) if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
fs.renameSync(oldPath, newPath) fs.renameSync(oldPath, newPath)
const entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() } organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
saveOrganizedJson(organized) saveOrganizedJson(organized)
logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`) logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
@@ -335,14 +340,24 @@ router.post('/rename-all', requireAuth, async (req, res) => {
const oldPath = orgEntry.dest_path const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), expectedName) const newPath = path.join(path.dirname(oldPath), expectedName)
try { try {
if (!fs.existsSync(oldPath)) { errors.push({ episode_id: ep.id, error: 'Fichier introuvable' }); continue } if (!fs.existsSync(oldPath)) {
if (fs.existsSync(newPath)) { errors.push({ episode_id: ep.id, error: `Fichier existant : ${expectedName}` }); continue } 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) fs.renameSync(oldPath, newPath)
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() } organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
done.push(ep.id) done.push(ep.id)
logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`) logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`)
} catch (err) { } catch (err) {
errors.push({ episode_id: ep.id, error: err instanceof Error ? err.message : 'Erreur' }) 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 })
} }
} }
} }
@@ -351,4 +366,41 @@ router.post('/rename-all', requireAuth, async (req, res) => {
res.json({ ok: true, done: done.length, errors }) 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 export default router
+41 -12
View File
@@ -1,4 +1,5 @@
import { Router } from 'express' import { Router } from 'express'
import { Agent, fetch as undiciFetch } from 'undici'
import { requireAuth } from '../auth.js' import { requireAuth } from '../auth.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
@@ -6,6 +7,16 @@ const router = Router()
const PLEX_TV_API = 'https://plex.tv/api/v2' 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> { async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
const res = await fetch(url, { const res = await fetch(url, {
...options, ...options,
@@ -105,6 +116,10 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
if (!token || !serverUrl || !libraryName || !libraryPath) { if (!token || !serverUrl || !libraryName || !libraryPath) {
res.status(400).json({ error: 'token, serverUrl, libraryName, libraryPath requis' }); return 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 headers: Record<string, string> = { 'X-Plex-Token': token, 'Accept': 'application/json' }
const TARGET_AGENT_URI = 'https://metadata.fankai.fr/plex' const TARGET_AGENT_URI = 'https://metadata.fankai.fr/plex'
const steps: { step: string; ok: boolean; message: string }[] = [] const steps: { step: string; ok: boolean; message: string }[] = []
@@ -114,27 +129,30 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
// ── Étape 1 : Enregistrer le provider Fankai ───────────── // ── Étape 1 : Enregistrer le provider Fankai ─────────────
try { try {
const providersRes = await fetch(`${serverUrl}/media/providers/metadata`, { headers }) const providersRes = await serverFetch(`${serverUrl}/media/providers/metadata`, { headers })
if (providersRes.ok) { if (providersRes.ok) {
const data = await providersRes.json() const data = await providersRes.json() as any
const providers: any[] = data?.MediaContainer?.MetadataAgentProvider ?? [] const providers: any[] = data?.MediaContainer?.MetadataAgentProvider ?? []
let provider = providers.find((p: any) => p.uri === TARGET_AGENT_URI) let provider = providers.find((p: any) => p.uri === TARGET_AGENT_URI)
if (!provider) { if (!provider) {
const r = await fetch(`${serverUrl}/media/providers/metadata?uri=${encodeURIComponent(TARGET_AGENT_URI)}`, { method: 'POST', headers }) const r = await serverFetch(`${serverUrl}/media/providers/metadata?uri=${encodeURIComponent(TARGET_AGENT_URI)}`, { method: 'POST', headers })
if (r.ok) provider = (await r.json())?.MediaContainer?.MetadataAgentProvider?.[0] if (r.ok) provider = ((await r.json()) as any)?.MediaContainer?.MetadataAgentProvider?.[0]
} }
if (provider) { if (provider) {
agentIdentifier = provider.identifier ?? agentIdentifier agentIdentifier = provider.identifier ?? agentIdentifier
steps.push({ step: 'provider', ok: true, message: `Provider enregistré (${agentIdentifier})` }) steps.push({ step: 'provider', ok: true, message: `Provider enregistré (${agentIdentifier})` })
} }
const groupsRes = await fetch(`${serverUrl}/media/providers/metadata/group`, { headers }) const groupsRes = await serverFetch(`${serverUrl}/media/providers/metadata/group`, { headers })
if (groupsRes.ok) { if (groupsRes.ok) {
const gdata = await groupsRes.json() const gdata = await groupsRes.json() as any
const groups: any[] = gdata?.MediaContainer?.MetadataAgentProviderGroup ?? [] const groups: any[] = gdata?.MediaContainer?.MetadataAgentProviderGroup ?? []
let group = groups.find((g: any) => g.primaryIdentifier === agentIdentifier) let group = groups.find((g: any) => g.primaryIdentifier === agentIdentifier)
if (!group) { if (!group) {
const r = await fetch(`${serverUrl}/media/providers/metadata/group?title=Fankai&primaryIdentifier=${encodeURIComponent(agentIdentifier)}`, { method: 'POST', headers }) const r = await serverFetch(
if (r.ok) group = (await r.json())?.MediaContainer?.MetadataAgentProviderGroup?.[0] `${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) { if (group) {
groupId = String(group.id ?? '') groupId = String(group.id ?? '')
@@ -142,18 +160,29 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
agentSetupOk = true 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) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur' const msg = err instanceof Error ? err.message : 'Erreur'
steps.push({ step: 'agent', ok: false, message: `Agent non configuré (Plex < 1.43 ?) — setup manuel requis` }) steps.push({ step: 'agent', ok: false, message: `Agent non configuré — setup manuel requis` })
logger.warn('plex', `Setup agent Fankai échoué : ${msg}`) logger.warn('plex', `Setup agent Fankai échoué : ${msg}`)
} }
// ── Étape 2 : Créer la bibliothèque ────────────────────── // ── Étape 2 : Créer la bibliothèque ──────────────────────
try { try {
const params = new URLSearchParams({ type: 'show', name: libraryName, agent: agentIdentifier, scanner: 'Plex TV Series', language: 'fr-FR', location: libraryPath }) 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) if (groupId) params.set('metadataAgentProviderGroupId', groupId)
const libRes = await fetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers }) const libRes = await serverFetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers })
if (!libRes.ok) throw new Error(`HTTP ${libRes.status}: ${await libRes.text()}`) if (!libRes.ok) throw new Error(`HTTP ${libRes.status}: ${await libRes.text()}`)
steps.push({ step: 'library', ok: true, message: `Bibliothèque "${libraryName}" créée` }) steps.push({ step: 'library', ok: true, message: `Bibliothèque "${libraryName}" créée` })
logger.info('plex', `Bibliothèque "${libraryName}" créée sur ${serverUrl}`) logger.info('plex', `Bibliothèque "${libraryName}" créée sur ${serverUrl}`)
@@ -166,4 +195,4 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
} }
}) })
export default router export default router
+53 -8
View File
@@ -51,8 +51,8 @@ router.get('/series/:id', requireAuth, async (req, res) => {
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const availableEpisodeIds = new Set<number>() const availableEpisodeIds = new Set<number>()
const episodeTorrentMap : Record<number, any> = {} const episodeTorrentMap : Record<number, any[]> = {}
const seasonTorrentMapBySn : Record<number, any> = {} const seasonTorrentMapBySn : Record<number, any[]> = {}
const integraleTorrents : any[] = [] const integraleTorrents : any[] = []
const organizedEpisodeIds = new Set<number>() const organizedEpisodeIds = new Set<number>()
@@ -65,6 +65,14 @@ router.get('/series/:id', requireAuth, async (req, res) => {
} }
} }
if (serieData) { 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 ?? [])) { for (const t of (serieData.torrents ?? [])) {
integraleTorrents.push({ ...t, raw: t.title }) integraleTorrents.push({ ...t, raw: t.title })
const resolved = buildResolvedEpisodes(serieData, t.infohash) const resolved = buildResolvedEpisodes(serieData, t.infohash)
@@ -77,7 +85,8 @@ router.get('/series/:id', requireAuth, async (req, res) => {
} }
for (const season of serieData.seasons ?? []) { for (const season of serieData.seasons ?? []) {
for (const t of (season.torrents ?? [])) { 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 } 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) const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id) for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
@@ -88,7 +97,9 @@ router.get('/series/:id', requireAuth, async (req, res) => {
} }
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
for (const t of (ep.torrents ?? [])) { 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 } 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 })
availableEpisodeIds.add(ep.id) availableEpisodeIds.add(ep.id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
const isOrg = orgFiles[String(ep.id)] !== undefined const isOrg = orgFiles[String(ep.id)] !== undefined
@@ -100,13 +111,31 @@ router.get('/series/:id', requireAuth, async (req, res) => {
organizedEpisodeIds.add(ep.id) 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 })
availableEpisodeIds.add(ep.id)
}
}
} }
} }
} }
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => { const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
const eps = season.episodes.map((ep: any) => { const eps = season.episodes.map((ep: any) => {
const epTorrent = episodeTorrentMap[ep.id] ?? null const epTorrents = episodeTorrentMap[ep.id] ?? []
const epTorrent = epTorrents[0] ?? null
let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null
if (fankai === null && serieData) { if (fankai === null && serieData) {
outer: for (const sd_season of serieData.seasons ?? []) { outer: for (const sd_season of serieData.seasons ?? []) {
@@ -121,7 +150,15 @@ router.get('/series/:id', requireAuth, async (req, res) => {
} }
} }
} }
return { ...ep, available: availableEpisodeIds.has(ep.id), torrent: epTorrent ? { ...epTorrent, fankai: fankai ?? true } : null, fankai, organized: organizedEpisodeIds.has(ep.id) } 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),
}
}) })
const total = eps.filter((e: any) => e.available).length const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).length const orgCount = eps.filter((e: any) => e.organized).length
@@ -133,10 +170,18 @@ router.get('/series/:id', requireAuth, async (req, res) => {
: total > 0 && orgCount >= total : total > 0 && orgCount >= total
? 'complete' ? 'complete'
: 'partial' : 'partial'
return { ...season, torrent: seasonTorrentMapBySn[season.season_number] ?? null, organized_state: orgState, organized_count: orgCount, episodes: eps } 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, raw: t.title ?? t.raw })) }) 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) { } catch (err) {
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`) logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+11 -1
View File
@@ -6,7 +6,7 @@ import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
import { DATA_DIR, BASE_DIR } from '../config.js' import { DATA_DIR, BASE_DIR } from '../config.js'
import { readSettings } from '../settings.js' import { readSettings } from '../settings.js'
import { systemInfo } from '../system.js' import { systemInfo } from '../system.js'
import { scanMediaPath } from '../organize.js' import { scanMediaPath, syncFilenameChanges } from '../organize.js'
import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js' import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js'
import { cacheSize } from '../lib/github-cache.js' import { cacheSize } from '../lib/github-cache.js'
import { recentOrganized } from '../lib/notifs.js' import { recentOrganized } from '../lib/notifs.js'
@@ -109,6 +109,16 @@ router.post('/update', requireAuth, async (_req, res) => {
if (!Array.isArray(availableIds)) throw new Error('available.json invalide') if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`) logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
res.json({ ok: true, count: availableIds.length }) 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) { } catch (err) {
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : 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' }) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+15 -3
View File
@@ -34,6 +34,11 @@ export interface SavedClient {
config: Record<string, string | number> config: Record<string, string | number>
} }
export interface TorrentFileProgress {
index : number
progress: number // 0–100
}
export interface TorrentInfo { export interface TorrentInfo {
hash : string hash : string
name : string name : string
@@ -48,13 +53,20 @@ export interface TorrentInfo {
eta : number eta : number
save_path : string save_path : string
category : string category : string
files ?: TorrentFileProgress[]
}
export interface DownloadOptions {
file_index?: number | null
file_path ?: string | null
magnet ?: string | null // conservé pour extraire le hash même quand on envoie le .torrent
} }
export interface TorrentClientDriver { export interface TorrentClientDriver {
definition : TorrentClientDefinition definition : TorrentClientDefinition
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }> test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: 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[]> list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void> remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
} }
@@ -208,7 +220,7 @@ function remapSavePath(savePath: string, remotePath: string, localPath: string):
// ─── Dispatch download ───────────────────────────────────────────────────────── // ─── 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() const clients = loadClients()
if (clients.length === 0) { if (clients.length === 0) {
@@ -226,7 +238,7 @@ export async function dispatchDownload(url: string): Promise<{ uuid: string; nam
continue continue
} }
try { try {
await driver.add(client.config, url) await driver.add(client.config, url, options)
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`) logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
results.push({ uuid: client.uuid, name: client.name, ok: true }) results.push({ uuid: client.uuid, name: client.name, ok: true })
} catch (err) { } catch (err) {
+176 -25
View File
@@ -2,13 +2,13 @@
* qBittorrent Driver * qBittorrent Driver
*/ */
import type { TorrentClientDriver, TorrentInfo } from './index.js' import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
function mapState(state: string): TorrentInfo['state'] { function mapState(state: string): TorrentInfo['state'] {
if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading' if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading'
if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding' 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 (['checkingDL', 'checkingUP', 'checkingResumeData'].includes(state)) return 'checking'
if (state === 'error' || state === 'missingFiles') return 'error' if (state === 'error' || state === 'missingFiles') return 'error'
return 'unknown' return 'unknown'
@@ -34,6 +34,98 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
return sid return sid
} }
async function qbTorrentExists(config: Record<string, string | number>, sid: string, hash: string): Promise<boolean> {
try {
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, {
headers: { Cookie: `SID=${sid}` },
})
if (!res.ok) return false
const list = await res.json()
return Array.isArray(list) && list.length > 0
} catch {
return false
}
}
/**
* Attend que les métadonnées soient disponibles, applique les priorités, puis reprend le torrent.
* Utilisé après un ajout avec stopCondition=MetadataReceived (qBit actif → auto-pause après metadata)
* ou pour un torrent déjà présent (resume=false, juste mise à jour de priorité).
*/
async function qbApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
resume : boolean,
): Promise<void> {
// 60 tentatives × 500 ms = 30 s max
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const sid = await qbLogin(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: { Cookie: `SID=${sid}` },
})
if (!res.ok) continue
const files: any[] = await res.json()
if (!Array.isArray(files) || files.length === 0) continue
// 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)
if (isInitialState) {
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
if (unwanted.length > 0) {
const form = new FormData()
form.append('hash', hash)
form.append('id', unwanted.join('|'))
form.append('priority', '0')
await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body: form, headers: { Cookie: `SID=${sid}` } })
}
}
// Activer le fichier cible (dans tous les cas)
const form2 = new FormData()
form2.append('hash', hash)
form2.append('id', String(fileIndex))
form2.append('priority', '1')
await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body: form2, headers: { Cookie: `SID=${sid}` } })
// Reprendre (torrent auto-mis en pause par stopCondition=MetadataReceived)
// On appelle les deux endpoints : resume (qBit ≤ 4.x) et start (qBit ≥ 5.0)
if (resume) {
const resumeForm = new FormData()
resumeForm.append('hashes', hash)
await Promise.allSettled([
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }),
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }),
])
}
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné${resume ? ' + reprise' : ''} pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
if (resume) {
try {
const sid = await qbLogin(config)
const resumeForm = new FormData()
resumeForm.append('hashes', hash)
await Promise.allSettled([
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }),
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }),
])
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
}
const QB: TorrentClientDriver = { const QB: TorrentClientDriver = {
definition: { definition: {
id : 'qbittorrent', id : 'qbittorrent',
@@ -86,37 +178,96 @@ const QB: TorrentClientDriver = {
}) })
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`) if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
const data: any[] = await res.json() const data: any[] = await res.json()
return data.map(t => ({
hash : t.hash, // Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
name : t.name, const fileMap = new Map<string, any[]>()
state : mapState(t.state), await Promise.all(data.map(async (t: any) => {
progress : Math.round(t.progress * 100), try {
size : t.size, const r = await fetch(`${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
downloaded: t.downloaded, headers: { Cookie: `SID=${sid}` },
uploaded : t.uploaded ?? 0, })
ratio : Math.round((t.ratio ?? 0) * 100) / 100, if (r.ok) fileMap.set(t.hash, await r.json())
speed : t.dlspeed, } catch {}
upspeed : t.upspeed ?? 0, }))
eta : t.eta ?? -1,
save_path : t.save_path, return data.map(t => {
category : t.category ?? '', const fileInfo = fileMap.get(t.hash) ?? []
} satisfies TorrentInfo)) 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, progress: Math.round((f.progress ?? 0) * 100) }))
: undefined,
} satisfies TorrentInfo
})
}, },
async add(config, url) { async add(config, url, options?: DownloadOptions) {
const sid = await qbLogin(config) const sid = await qbLogin(config)
// Extraire le hash depuis l'URL (si magnet) ou depuis options.magnet (si .torrent URL)
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 qbTorrentExists(config, sid, hash)
if (exists) {
// Torrent déjà présent → mettre à jour la priorité directement (async OK)
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
}
// Nouveau torrent → ajout actif avec stopCondition=MetadataReceived
// qBit fetch les métadonnées normalement via DHT/peers,
// puis se met en pause automatiquement dès qu'elles sont disponibles.
// On set les priorités sur la pause, puis on reprend. Zéro data indésirable.
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))
form.append('stopCondition', 'MetadataReceived') // qBit ≥ 4.6
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
logger.info('qbittorrent', `Torrent ajouté (stopCondition=MetadataReceived, sélection fichier ${options.file_index} en attente)`)
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
// Pas de sélection de fichier → ajout normal
const form = new FormData() const form = new FormData()
form.append('urls', url) form.append('urls', url)
if (config.category) form.append('category', String(config.category)) if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath)) if (config.savePath) form.append('savepath', String(config.savePath))
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method : 'POST', const res = await fetch(`${config.url}/api/v2/torrents/add`, {
body : form, method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
headers: { Cookie: `SID=${sid}` },
}) })
const text = await res.text() const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${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) { async remove(config, hash, deleteFiles = false) {
@@ -134,4 +285,4 @@ const QB: TorrentClientDriver = {
}, },
} }
export default QB export default QB
+109 -17
View File
@@ -5,7 +5,7 @@
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC). * Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
*/ */
import type { TorrentClientDriver, TorrentInfo } from './index.js' import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// ─── XML-RPC helpers ────────────────────────────────────────── // ─── XML-RPC helpers ──────────────────────────────────────────
@@ -118,6 +118,56 @@ async function d_multicall(
return Array.isArray(result) ? result : [] return Array.isArray(result) ? result : []
} }
async function rtTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
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 ──────────────────────────────────────────── // ─── State mapping ────────────────────────────────────────────
// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1) // rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1)
// + is_open (0/1) // + is_open (0/1)
@@ -189,17 +239,29 @@ const RT: TorrentClientDriver = {
const rows = await d_multicall(config, 'main', methods) 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='])
if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files)
} catch {}
}))
return rows return rows
.map(r => { .map(r => {
const [hash, name, isOpen, isActive, isChecking, isComplete, const [hash, name, isOpen, isActive, isChecking, isComplete,
size, downloaded, uploadedTotal, ratioRaw, size, downloaded, uploadedTotal, ratioRaw,
dlSpeed, ulSpeed, leftBytes, directory, label] = r dlSpeed, ulSpeed, leftBytes, directory, label] = r
const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0 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 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 { return {
hash : String(hash).toLowerCase(), hash : hashLower,
name : String(name), name : String(name),
state : mapState(Number(isOpen), Number(isActive), Number(isChecking), Number(isComplete)), state : mapState(Number(isOpen), Number(isActive), Number(isChecking), Number(isComplete)),
progress, progress,
@@ -212,6 +274,12 @@ const RT: TorrentClientDriver = {
eta, eta,
save_path : String(directory), save_path : String(directory),
category : String(label ?? ''), category : String(label ?? ''),
files : fileInfo.length > 0
? fileInfo.map((f: any, i: number) => ({
index : i,
progress: Number(f[1]) > 0 ? Math.round((Number(f[0]) / Number(f[1])) * 100) : 0,
}))
: undefined,
} satisfies TorrentInfo } satisfies TorrentInfo
}) })
.filter(t => { .filter(t => {
@@ -220,20 +288,44 @@ const RT: TorrentClientDriver = {
}) })
}, },
async add(config, url) { async add(config, url, options?: DownloadOptions) {
// load_start charge et démarre le torrent const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
const method = url.startsWith('magnet:') ? 'load.start' : 'load.start' ?? 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] 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})` : ''}`) logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
}, },
@@ -250,4 +342,4 @@ const RT: TorrentClientDriver = {
}, },
} }
export default RT export default RT
+3 -2
View File
@@ -2,7 +2,7 @@
* Synology Download Station Driver * Synology Download Station Driver
*/ */
import type { TorrentClientDriver, TorrentInfo } from './index.js' import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// Synology Download Station task status codes // 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 sid = await dsLogin(config)
const extra: Record<string, string> = { uri: url } const extra: Record<string, string> = { uri: url }
+34 -9
View File
@@ -2,7 +2,7 @@
* Transmission Driver * Transmission Driver
*/ */
import type { TorrentClientDriver, TorrentInfo } from './index.js' import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
function mapState(status: number): TorrentInfo['state'] { function mapState(status: number): TorrentInfo['state'] {
@@ -96,6 +96,7 @@ const TR: TorrentClientDriver = {
'hashString', 'name', 'status', 'percentDone', 'totalSize', 'hashString', 'name', 'status', 'percentDone', 'totalSize',
'downloadedEver', 'uploadedEver', 'uploadRatio', 'downloadedEver', 'uploadedEver', 'uploadRatio',
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels', 'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
'fileStats', // progression par fichier (bytesCompleted + length)
] ]
const data = await trRequest(config, 'torrent-get', { fields }) const data = await trRequest(config, 'torrent-get', { fields })
const torrents: any[] = data.arguments?.torrents ?? [] const torrents: any[] = data.arguments?.torrents ?? []
@@ -119,22 +120,46 @@ const TR: TorrentClientDriver = {
eta : t.eta ?? -1, eta : t.eta ?? -1,
save_path : t.downloadDir, save_path : t.downloadDir,
category : t.labels?.[0] ?? '', category : t.labels?.[0] ?? '',
files : Array.isArray(t.fileStats) && t.fileStats.length > 0
? t.fileStats.map((fs: any, i: number) => ({
index : i,
progress: fs.length > 0 ? Math.round((fs.bytesCompleted / fs.length) * 100) : 0,
}))
: undefined,
} satisfies TorrentInfo)) } satisfies TorrentInfo))
}, },
async add(config, url) { async add(config, url, options?: DownloadOptions) {
const args: Record<string, unknown> = { filename: url } const args: Record<string, unknown> = { filename: url }
if (config.savePath) { if (config.savePath) args['download-dir'] = String(config.savePath)
args['download-dir'] = String(config.savePath) if (config.category) args['labels'] = [String(config.category)]
// Transmission applique files-wanted après récupération des métadonnées,
// sans démarrer les autres fichiers entre-temps — natif et fiable.
if (options?.file_index != null) {
args['files-wanted'] = [options.file_index]
} }
if (config.category) { const res = await trRequest(config, 'torrent-add', args)
args['labels'] = [String(config.category)]
// Torrent déjà présent → Transmission renvoie torrent-duplicate
// On s'assure quand même que le fichier voulu est activé
if (options?.file_index != null && res.arguments?.['torrent-duplicate']) {
const dup = res.arguments['torrent-duplicate']
try {
await trRequest(config, 'torrent-set', {
ids : [dup.id],
'files-wanted': [options.file_index],
})
logger.info('transmission', `Torrent ${dup.hashString?.slice(0, 8)}… déjà présent, fichier ${options.file_index} activé`)
} catch (err) {
logger.warn('transmission', `Activation fichier sur torrent existant échouée : ${err instanceof Error ? err.message : err}`)
}
return
} }
await trRequest(config, 'torrent-add', args) logger.info('transmission', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
}, },
async remove(config, hash, deleteFiles = false) { async remove(config, hash, deleteFiles = false) {
@@ -148,4 +173,4 @@ const TR: TorrentClientDriver = {
}, },
} }
export default TR export default TR
+79 -24
View File
@@ -2,7 +2,7 @@
* uTorrent Driver * uTorrent Driver
*/ */
import type { TorrentClientDriver, TorrentInfo } from './index.js' import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// uTorrent status flags (bitmask) : // uTorrent status flags (bitmask) :
@@ -21,6 +21,56 @@ function mapState(status: number): TorrentInfo['state'] {
// uTorrent nécessite un token CSRF + cookie à chaque session // uTorrent nécessite un token CSRF + cookie à chaque session
interface UTSession { token: string; cookie: string } interface UTSession { token: string; cookie: string }
async function utTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
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> { async function utGetSession(config: Record<string, string | number>): Promise<UTSession> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const headers : Record<string, string> = { const headers : Record<string, string> = {
@@ -141,43 +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 session = await utGetSession(config)
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const form = new FormData() const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url })
form.append('torrent_url', url) if (config.savePath) 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}`, { const res = await fetch(`${config.url}/gui/?${qs}`, {
method : 'GET', method : 'GET',
headers: { headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
'Authorization': `Basic ${auth}`,
'Cookie' : session.cookie,
},
}) })
if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`) 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 // Appliquer le label/catégorie si défini
if (config.category) { 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 () => { setTimeout(async () => {
try { try {
const s2 = await utGetSession(config) const s2 = await utGetSession(config)
const data = await utRequest(config, { list: '1' }, s2) const data = await utRequest(config, { list: '1' }, s2)
const torrents: any[][] = data?.torrents ?? [] const torrents: any[][] = data?.torrents ?? []
// Trouver le torrent le plus récemment ajouté sans label
const target = torrents.find(t => !t[11]) const target = torrents.find(t => !t[11])
if (target) { if (target) {
await utRequest(config, { await utRequest(config, {
@@ -190,8 +247,6 @@ const UT: TorrentClientDriver = {
} catch {} } catch {}
}, 3000) }, 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) { async remove(config, hash, deleteFiles = false) {
+3 -124
View File
@@ -25,42 +25,7 @@
Séries Séries
</RouterLink> </RouterLink>
<!-- Titre + boutons actions --> <h1 class="text-2xl font-bold text-primary tracking-tight">{{ serie.title }}</h1>
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ serie.title }}</h1>
<button
@click="emit('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 v-if="Object.keys(organizedByEpisode).length > 0" class="relative" @click.stop>
<button
@click="unimportMenuOpen = !unimportMenuOpen"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted hover:text-red-400 hover:border-red-500/40 transition shrink-0"
>
<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>
Désimporter
</button>
<div v-if="unimportMenuOpen" class="absolute top-full left-0 mt-1 bg-card border border-border rounded-xl p-1 z-20 w-48 shadow-xl flex flex-col gap-0.5">
<button @click="emit('unimport', false); unimportMenuOpen = false" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:bg-hover transition-colors">
Retirer de la bibliothèque
</button>
<button @click="emit('unimport', true); unimportMenuOpen = false" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-red-400 hover:bg-red-500/10 transition-colors">
Supprimer les fichiers
</button>
</div>
</div>
</div>
<!-- Métadonnées --> <!-- Métadonnées -->
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@@ -77,100 +42,14 @@
<p v-if="serie.plot" class="text-sm text-secondary leading-relaxed max-w-2xl line-clamp-3"> <p v-if="serie.plot" class="text-sm text-secondary leading-relaxed max-w-2xl line-clamp-3">
{{ serie.plot }} {{ serie.plot }}
</p> </p>
<!-- Boutons intégrale / tout télécharger -->
<div class="flex flex-wrap gap-2 mt-1">
<button
v-if="hasDownloadableTorrents"
@click="emit('downloadAll')"
:disabled="downloadingAll"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-accent text-white hover:bg-accent-hover transition"
:class="downloadingAll ? 'opacity-50 cursor-not-allowed' : ''"
>
<svg width="14" height="14" 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' }}
</button>
<button
v-for="(t, i) in torrentsIntegrale"
: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',
(isAlreadyQueued(t) || isDownloaded(`integrale-${i}`))
? 'opacity-50 cursor-not-allowed' : ''
]"
:disabled="isAlreadyQueued(t) || isDownloaded(`integrale-${i}`)"
@click="emit('download', `integrale-${i}`, t.torrent_url, t.magnet)"
>
<component :is="btnIcon(`integrale-${i}`, t)" />
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : isDownloaded(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
</button>
</div>
<!-- Progression intégrale -->
<template v-for="(t, i) in torrentsIntegrale" :key="`prog-${i}`">
<div v-if="torrentProgress(extractHash(t)) && torrentProgress(extractHash(t))!.progress < 100" 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(t))!.progress}%` }" />
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, h } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { ArrowLeft, Tv, Download, Loader, Check } from 'lucide-vue-next' import { ArrowLeft, Tv } from 'lucide-vue-next'
interface ActiveTorrent { hash: string; progress: number; state: string } defineProps<{ serie: any }>()
const props = defineProps<{
serie : any
torrentsIntegrale : any[]
organizedByEpisode : Record<string, any>
hasDownloadableTorrents: boolean
downloadingAll : boolean
activeTorrents : ActiveTorrent[]
downloading : string[]
downloaded : string[]
}>()
const emit = defineEmits<{
openManualImport: []
unimport : [deleteFile: boolean]
downloadAll : []
download : [key: string, url: string | null, magnet: string | null]
}>()
const unimportMenuOpen = ref(false)
function isDownloaded(key: string) { return props.downloaded.includes(key) }
function isAlreadyQueued(torrent: any): boolean {
const hash = extractHash(torrent)
if (!hash) return false
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
function extractHash(torrent: any): 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 torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
if (!hash) return null
return props.activeTorrents.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
}
function btnIcon(key: string, torrent: any) {
if (isDownloaded(key) || isAlreadyQueued(torrent)) return h(Check, { size: 14 })
return h(Download, { size: 14 })
}
defineExpose({ closeUnimportMenu: () => { unimportMenuOpen.value = false } })
</script> </script>
+266 -31
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="bg-card border border-border rounded-xl overflow-hidden"> <div class="bg-card border border-border rounded-xl">
<!-- Header saison --> <!-- Header saison -->
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border"> <div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -31,17 +31,42 @@
<!-- Boutons saison --> <!-- Boutons saison -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <!-- Pas de pack saison : bouton "Saison" (dropdown si plusieurs packs via intégrales) -->
v-if="!season.torrent && hasDownloadable" <template v-if="season.torrents.length === 0 && hasDownloadable">
@click="emit('downloadSeason', season)" <div v-if="uniquePackOptions.length > 1" class="relative" @click.stop>
:disabled="downloadingSeason" <button
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" 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="downloadingSeason ? loaderIcon : downloadIcon" /> >
{{ downloadingSeason ? 'Envoi…' : 'Tout télécharger' }} <component :is="downloadIcon" />
</button> 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 <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="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="canDownloadSeason :class="canDownloadSeason
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20' ? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
@@ -52,6 +77,30 @@
<component :is="seasonBtnIcon" /> <component :is="seasonBtnIcon" />
{{ seasonBtnLabel }} {{ seasonBtnLabel }}
</button> </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>
</div> </div>
@@ -68,7 +117,7 @@
<div <div
v-for="ep in season.episodes" v-for="ep in season.episodes"
:key="ep.id" :key="ep.id"
class="px-5 py-3 hover:bg-hover/50 transition-colors" class="px-5 py-3 hover:bg-hover/50 transition-colors last:rounded-b-xl"
:class="{ 'opacity-40': !ep.available && !ep.organized }" :class="{ 'opacity-40': !ep.available && !ep.organized }"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -94,18 +143,58 @@
<div class="flex items-center gap-2 mt-0.5"> <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.aired" class="text-xs text-muted">{{ formatDate(ep.aired) }}</span>
<span v-if="ep.duration" class="text-xs text-muted">{{ formatDuration(ep.duration) }}</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>
</div> </div>
</div> </div>
<!-- Bouton état épisode --> <!-- Bouton état épisode (dropdown si plusieurs options) -->
<button <div class="relative shrink-0" @click.stop>
class="shrink-0 w-8 h-8 rounded-lg border flex items-center justify-center transition-colors" <button
:class="epBtnClass(ep)" class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
:disabled="!ep.torrent || ep.organized || isAlreadyQueued(ep.torrent)" :class="epBtnClass(ep)"
@click="ep.torrent && !ep.organized && !isAlreadyQueued(ep.torrent) && emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)" :disabled="epState(ep) !== 'idle'"
> @click="handleEpBtnClick(ep)"
<component :is="epStateIcon(ep)" /> >
</button> <component :is="epStateIcon(ep)" />
</button>
<!-- Dropdown quand plusieurs options de torrent -->
<div v-if="epOptionsOpen === ep.id && !ep.organized && 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), epOptionsOpen = null)"
>
<component :is="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
{{ groupLabels(ep.torrents)[i] }}
</button>
</div>
</div>
<!-- Actions épisode importé --> <!-- Actions épisode importé -->
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]"> <template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
@@ -152,10 +241,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, h } from 'vue' import { ref, computed, h, onMounted, onUnmounted } from 'vue'
import { ChevronUp, Download, Loader, Check, X } from 'lucide-vue-next' import { ChevronUp, Download, Loader, Check, X } from 'lucide-vue-next'
interface ActiveTorrent { hash: string; progress: number; state: string } interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[] }
const props = defineProps<{ const props = defineProps<{
season : any season : any
@@ -171,18 +260,23 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
toggle : [id: number] toggle : [id: number]
download : [key: string, url: string | null, magnet: string | null] download : [key: string, url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null]
downloadSeason : [season: any] downloadSeason : [season: any, packHash?: string]
renameEpisode : [ep: any, season: any] renameEpisode : [ep: any, season: any]
unimportEpisode: [ep: any, season: any, deleteFile: boolean] unimportEpisode: [ep: any, season: any, deleteFile: boolean]
}>() }>()
const epMenuOpen = ref<number | null>(null) const epMenuOpen = ref<number | null>(null)
const epOptionsOpen = ref<number | null>(null)
const seasonMenuOpen = ref(false)
const downloadIcon = h(Download, { size: 12 }) const downloadIcon = h(Download, { size: 12 })
const loaderIcon = h(Loader, { size: 12, class: 'animate-spin' }) const loaderIcon = h(Loader, { size: 12, class: 'animate-spin' })
const checkIcon = h(Check, { size: 12 })
// ── Helpers hash / progress ──────────────────────────────────── // ── Helpers hash / progress ────────────────────────────────────
function extractHash(torrent: any): string | null { 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 if (!torrent?.magnet) return null
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i) const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
return m ? m[1].toLowerCase() : null return m ? m[1].toLowerCase() : null
@@ -194,6 +288,10 @@ function torrentProgress(hash: string | null | undefined): ActiveTorrent | null
function isDownloading(key: string) { return props.downloading.includes(key) } function isDownloading(key: string) { return props.downloading.includes(key) }
function isDownloaded(key: string) { return props.downloaded.includes(key) } function isDownloaded(key: string) { return props.downloaded.includes(key) }
function isAlreadyQueued(torrent: any): boolean { 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) const hash = extractHash(torrent)
if (!hash) return false if (!hash) return false
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase()) return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
@@ -201,7 +299,16 @@ function isAlreadyQueued(torrent: any): boolean {
// ── Computed saison ──────────────────────────────────────────── // ── Computed saison ────────────────────────────────────────────
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length) const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
const seasonProgress = computed(() => torrentProgress(extractHash(props.season.torrent))) const seasonProgress = computed(() => {
if (props.season.torrents && props.season.torrents.length > 1) {
for (const t of props.season.torrents) {
const prog = torrentProgress(extractHash(t))
if (prog) return prog
}
return null
}
return torrentProgress(extractHash(props.season.torrent))
})
const hasDownloadable = computed(() => const hasDownloadable = computed(() =>
props.season.episodes.some((ep: any) => props.season.episodes.some((ep: any) =>
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`) ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)
@@ -224,14 +331,80 @@ const seasonBtnIcon = computed(() => {
}) })
// ── État épisode ─────────────────────────────────────────────── // ── État épisode ───────────────────────────────────────────────
function epProgress(ep: any) { return torrentProgress(extractHash(ep.torrent)) }
// 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,
// pas à l'état global du torrent — évite d'afficher les fichiers non sélectionnés (progress 0).
if (file != null && (sessionDl || file.progress > 0)) {
return { ...active, progress: file.progress }
}
if (!sessionDl) continue
} else {
// Pas de données par fichier : session ou torrent terminé
if (!sessionDl && active.state !== 'seeding') 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)
}
}
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' { function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
if (ep.organized || isDownloaded(`ep-${ep.id}`) || isAlreadyQueued(ep.torrent)) return 'done' // 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) const prog = epProgress(ep)
if (prog && prog.progress < 100) return 'loading' if (prog) return 'loading'
if (!ep.torrent || !ep.available) return 'unavailable' if (!ep.torrent || !ep.available) return 'unavailable'
if (isDownloading(`ep-${ep.id}`)) return 'loading'
return 'idle' return 'idle'
} }
function epStateIcon(ep: any) { function epStateIcon(ep: any) {
@@ -267,6 +440,59 @@ function epNeedsRename(ep: any): boolean {
return !!expected && expected !== entry.dest_filename return !!expected && expected !== entry.dest_filename
} }
// ── 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 { function formatDate(d: string): string {
if (!d) return '' if (!d) return ''
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' }) return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
@@ -279,5 +505,14 @@ function formatDuration(seconds: number): string {
return `${m} min` return `${m} min`
} }
defineExpose({ closeEpMenu: () => { epMenuOpen.value = null } }) function closeAllMenus() {
epMenuOpen.value = null
epOptionsOpen.value = null
seasonMenuOpen.value = false
}
onMounted(() => document.addEventListener('click', closeAllMenus))
onUnmounted(() => document.removeEventListener('click', closeAllMenus))
defineExpose({ closeEpMenu: closeAllMenus })
</script> </script>
+2 -2
View File
@@ -100,12 +100,12 @@ export const useSeriesStore = defineStore('series', () => {
} }
} }
async function download(torrent_url: string | null, magnet: string | null): Promise<{ success: boolean; error?: string }> { async function download(torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null): Promise<{ success: boolean; error?: string }> {
try { try {
const res = await fetch('/api/download', { const res = await fetch('/api/download', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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 })
}) })
const data = await res.json() const data = await res.json()
if (res.ok) return { success: true } if (res.ok) return { success: true }
+263 -62
View File
@@ -14,24 +14,88 @@
</div> </div>
<template v-else-if="data"> <template v-else-if="data">
<!-- Hero --> <!-- Hero — poster, titre, métadonnées, synopsis -->
<SerieHero <SerieHero :serie="data.serie" />
:serie="data.serie"
:torrents-integrale="data.torrents_integrale" <!-- Barre d'actions -->
:organized-by-episode="organizedByEpisode" <div class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
:has-downloadable-torrents="hasNonIntegraleDownloadables"
:downloading-all="downloadingAll" <!-- Gauche : gestion bibliothèque -->
:active-torrents="activeTorrents" <div class="flex items-center gap-2">
:downloading="downloading" <!-- Import manuel -->
:downloaded="downloaded" <button
@open-manual-import="openManualImport" @click="openManualImport"
@unimport="(del) => unimportSerie(del)" 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"
@download-all="downloadAll" >
@download="(key, url, magnet) => download(key, url, magnet)" <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>
<!-- Désimporter -->
<div v-if="Object.keys(organizedByEpisode).length > 0" class="relative" @click.stop>
<button
@click="unimportMenuOpen = !unimportMenuOpen"
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>
Désimporter
</button>
<div v-if="unimportMenuOpen" class="absolute top-full left-0 mt-1 bg-card border border-border rounded-xl p-1 z-20 w-48 shadow-xl flex flex-col gap-0.5">
<button @click="unimportSerie(false); unimportMenuOpen = false" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:bg-hover transition-colors">
Retirer de la bibliothèque
</button>
<button @click="unimportSerie(true); unimportMenuOpen = false" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-red-400 hover:bg-red-500/10 transition-colors">
Supprimer les fichiers
</button>
</div>
</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>
<!-- 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"
: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"
>
<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>
</div>
</div>
</div>
<!-- Saisons --> <!-- Saisons -->
<div class="px-4 md:px-8 pb-16 flex flex-col gap-4"> <div class="px-4 md:px-8 pb-16 flex flex-col gap-4 pt-4">
<SerieSeasonCard <SerieSeasonCard
v-for="season in data.seasons" v-for="season in data.seasons"
:key="season.id" :key="season.id"
@@ -45,8 +109,8 @@
:downloading-season="!!downloadingSeason[season.id]" :downloading-season="!!downloadingSeason[season.id]"
:nfo-support="nfoSupport" :nfo-support="nfoSupport"
@toggle="toggleSeason" @toggle="toggleSeason"
@download="(key, url, magnet) => download(key, url, magnet)" @download="(key, url, magnet, fi, fp) => download(key, url, magnet, fi, fp)"
@download-season="downloadSeason" @download-season="(s, h) => downloadSeason(s, h)"
@rename-episode="(ep, s) => renameEpisode(ep, s)" @rename-episode="(ep, s) => renameEpisode(ep, s)"
@unimport-episode="(ep, s, del) => unimportEpisode(ep, s, del)" @unimport-episode="(ep, s, del) => unimportEpisode(ep, s, del)"
/> />
@@ -91,15 +155,60 @@ const mediaPath = ref('/')
const nfoSupport = ref(false) const nfoSupport = ref(false)
const epActionLoading = ref<Record<number, boolean>>({}) const epActionLoading = ref<Record<number, boolean>>({})
const unimportMenuOpen = ref(false) const unimportMenuOpen = ref(false)
const downloadMenuOpen = ref(false)
const downloadingAll = ref(false) const downloadingAll = ref(false)
const downloadingSeason = ref<Record<number, boolean>>({}) const downloadingSeason = ref<Record<number, boolean>>({})
interface ActiveTorrent { hash: string; progress: number; state: string } export interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[]; save_path?: string; name?: string }
const activeTorrents = ref<ActiveTorrent[]>([]) const activeTorrents = ref<ActiveTorrent[]>([])
let pollTimer: ReturnType<typeof setInterval> | null = null 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) 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 ──────────────────────────────────────────── // ── Helpers d'état ────────────────────────────────────────────
function isDownloading(key: string) { return downloading.value.includes(key) } function isDownloading(key: string) { return downloading.value.includes(key) }
function isDownloaded(key: string) { return downloaded.value.includes(key) } function isDownloaded(key: string) { return downloaded.value.includes(key) }
@@ -107,11 +216,15 @@ function addDownloading(key: string) { if (!downloading.value.includes(key)) dow
function removeDownloading(key: string) { downloading.value = downloading.value.filter(k => k !== 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 extractHash(torrent: any): string | null { function extractHash(torrent: any): string | null {
if (torrent?.infohash) return torrent.infohash.toLowerCase()
if (!torrent?.magnet) return null if (!torrent?.magnet) return null
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i) const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
return m ? m[1].toLowerCase() : null return m ? m[1].toLowerCase() : null
} }
function isAlreadyQueued(torrent: any): boolean { 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) const hash = extractHash(torrent)
if (!hash) return false if (!hash) return false
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase()) return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
@@ -139,92 +252,178 @@ async function fetchActiveDownloads() {
const res = await fetch('/api/downloads', { credentials: 'include' }) const res = await fetch('/api/downloads', { credentials: 'include' })
if (!res.ok) return if (!res.ok) return
const list: any[] = await res.json() const list: any[] = await res.json()
activeTorrents.value = list.map(t => ({ hash: t.hash, progress: t.progress ?? 0, state: t.state }))
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 < 100 && f.progress === 100 && !_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,
files : t.files,
save_path: t.save_path,
name : t.name,
}))
} catch {}
}
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 {} } catch {}
} }
function openManualImport() { fetchOrganized(); manualImportOpen.value = true } function openManualImport() { fetchOrganized(); manualImportOpen.value = true }
// ── Download ────────────────────────────────────────────────── // ── Download ──────────────────────────────────────────────────
async function download(key: string, torrent_url: string | null, magnet: string | null) { async function download(key: string, torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null) {
if (isDownloading(key) || isDownloaded(key)) return if (isDownloading(key) || isDownloaded(key)) return
addDownloading(key) addDownloading(key)
const result = await store.download(torrent_url, magnet) const result = await store.download(torrent_url, magnet, file_index, file_path)
removeDownloading(key) removeDownloading(key)
if (result.success) { addDownloaded(key); toast('Téléchargement lancé ✓', 'success'); fetchActiveDownloads() } if (result.success) { addDownloaded(key); toast('Téléchargement lancé ✓', 'success'); fetchActiveDownloads() }
else toast(result.error ?? 'Erreur inconnue', 'error') else toast(result.error ?? 'Erreur inconnue', 'error')
} }
function collectDownloadables() { type DlItem = { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: 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 [] if (!data.value) return []
const result: { key: string; torrent_url: string | null; magnet: string | null }[] = [] const result: DlItem[] = []
const covered = new Set<number>() const covered = new Set<number>()
data.value.torrents_integrale.forEach((t: any, i: number) => {
if (!isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`)) { // ── 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 }) result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet })
for (const season of data.value!.seasons) for (const ep of season.episodes) covered.add(ep.id) // 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 season of data.value!.seasons)
if (!season.torrent || isAlreadyQueued(season.torrent) || isDownloaded(`season-${season.id}`) || season.organized_state === 'complete') continue for (const ep of season.episodes)
result.push({ key: `season-${season.id}`, torrent_url: season.torrent.torrent_url, magnet: season.torrent.magnet }) if (ep.torrents?.some((et: any) => (et.infohash ?? extractHash(et)) === hash))
for (const ep of season.episodes) covered.add(ep.id) covered.add(ep.id)
} }
// ── 2. Packs saison non couverts ──────────────────────────────
for (const season of data.value.seasons) { 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) { 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 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 }) 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 })
} }
}
return result return result
} }
const hasDownloadableTorrents = computed(() => collectDownloadables().length > 0) // Le bouton "Tout télécharger" s'affiche s'il y a quoi que ce soit à lancer
const hasSomethingToDownload = computed(() => {
// "Tout télécharger" ne s'affiche que s'il y a des torrents saison/épisode à lancer
// (les intégrales ont déjà leurs propres boutons dédiés dans le hero)
const hasNonIntegraleDownloadables = computed(() => {
if (!data.value) return false if (!data.value) return false
const covered = new Set<number>() if (data.value.torrents_integrale.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))) return true
data.value.torrents_integrale.forEach((t: any, i: number) => {
if (!isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
for (const season of data.value!.seasons) for (const ep of season.episodes) covered.add(ep.id)
})
for (const season of data.value.seasons) { for (const season of data.value.seasons) {
if (season.torrent && !isAlreadyQueued(season.torrent) && !isDownloaded(`season-${season.id}`) && season.organized_state !== 'complete') return true if (season.torrents?.length && season.organized_state !== 'complete' &&
for (const ep of season.episodes) { season.torrents.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`))) return true
if (ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`) && !covered.has(ep.id)) 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 return false
}) })
async function downloadAll() { async function downloadAll(integraleIndex?: number) {
downloadingAll.value = true downloadingAll.value = true
const torrents = collectDownloadables() const torrents = collectDownloadables(integraleIndex)
let sent = 0 let sent = 0
for (const t of torrents) { for (const t of torrents) {
try { const r = await store.download(t.torrent_url, t.magnet); if (r.success) { addDownloaded(t.key); sent++ } } catch {} try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
} }
downloadingAll.value = false downloadingAll.value = false
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() } if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
else toast('Aucun nouveau torrent à télécharger', 'success') else toast('Aucun nouveau torrent à télécharger', 'success')
} }
async function downloadSeason(season: Season) { async function downloadSeason(season: Season, packHash?: string) {
downloadingSeason.value[season.id] = true downloadingSeason.value[season.id] = true
const torrents: { key: string; torrent_url: string | null; magnet: string | null }[] = [] const torrents: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = []
if (season.torrent && !isAlreadyQueued(season.torrent) && !isDownloaded(`season-${season.id}`) && season.organized_state !== 'complete') { const seasonAny = season as any
torrents.push({ key: `season-${season.id}`, torrent_url: season.torrent.torrent_url, magnet: season.torrent.magnet }) 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 { } else {
for (const ep of (season as any).episodes ?? []) { for (const ep of seasonAny.episodes ?? []) {
if (!ep.torrent || !ep.available || ep.organized || isAlreadyQueued(ep.torrent) || isDownloaded(`ep-${ep.id}`)) continue // Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant
torrents.push({ key: `ep-${ep.id}`, torrent_url: ep.torrent.torrent_url, magnet: ep.torrent.magnet }) 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 })
} }
} }
let sent = 0 let sent = 0
for (const t of torrents) { for (const t of torrents) {
try { const r = await store.download(t.torrent_url, t.magnet); if (r.success) { addDownloaded(t.key); sent++ } } catch {} try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
} }
downloadingSeason.value[season.id] = false downloadingSeason.value[season.id] = false
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() } if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
@@ -274,13 +473,15 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
finally { epActionLoading.value[ep.id] = false } finally { epActionLoading.value[ep.id] = false }
} }
const closeMenus = () => { unimportMenuOpen.value = false; downloadMenuOpen.value = false }
onMounted(() => { onMounted(() => {
load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads() load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads()
pollTimer = setInterval(fetchActiveDownloads, 5000) pollTimer = setInterval(fetchActiveDownloads, 5000)
document.addEventListener('click', () => { unimportMenuOpen.value = false }) document.addEventListener('click', closeMenus)
}) })
onUnmounted(() => { onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer) if (pollTimer) clearInterval(pollTimer)
document.removeEventListener('click', () => { unimportMenuOpen.value = false }) document.removeEventListener('click', closeMenus)
}) })
</script> </script>
+52
View File
@@ -40,6 +40,50 @@
</div> </div>
</div> </div>
<!-- Tableau comparatif -->
<div>
<h3 class="text-sm font-semibold text-primary mb-1">Comparatif des clients</h3>
<p class="text-xs text-muted mb-3">Fonctionnalités supportées par chaque client.</p>
<div class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-xs">
<thead>
<tr class="border-b border-border bg-shell">
<th class="text-left px-4 py-2.5 text-muted font-medium">Client</th>
<th class="text-center px-3 py-2.5 text-muted font-medium">Sélection fichier</th>
<th class="text-center px-3 py-2.5 text-muted font-medium">Catégories</th>
<th class="text-center px-3 py-2.5 text-muted font-medium">Dossier cible</th>
<th class="text-center px-3 py-2.5 text-muted font-medium">Remap. chemin</th>
</tr>
</thead>
<tbody class="divide-y divide-border/60">
<tr v-for="row in clientMatrix" :key="row.id" class="hover:bg-hover/40 transition-colors">
<td class="px-4 py-2.5">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full shrink-0"
:class="healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === true ? 'bg-green-500' :
healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === false ? 'bg-red-500' : 'bg-transparent'" />
<span class="text-primary font-medium">{{ row.label }}</span>
</div>
</td>
<td class="text-center px-3 py-2.5">
<span :class="row.fileSelect === '✗' ? 'text-muted' : row.fileSelect.startsWith('Async') ? 'text-yellow-500' : 'text-green-400'">
{{ row.fileSelect }}
</span>
</td>
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-green-400' : 'text-muted'">{{ row.categories ? '✓' : '✗' }}</td>
<td class="text-center px-3 py-2.5 text-green-400">✓</td>
<td class="text-center px-3 py-2.5 text-green-400">✓</td>
</tr>
</tbody>
</table>
</div>
<p class="text-[11px] text-muted mt-2">
<span class="text-green-400">✓ Natif</span> — sélection à l'ajout, aucun fichier superflu ·
<span class="text-green-400">Async ✓</span> — sélection dès réception des métadonnées, avant tout téléchargement ·
<span class="text-muted">✗</span> — non supporté, torrent complet téléchargé
</p>
</div>
<!-- Modal ajout --> <!-- Modal ajout -->
<Teleport to="body"> <Teleport to="body">
<div <div
@@ -118,6 +162,14 @@ const clients = ref<any[]>([])
const availableClients = ref<any[]>([]) const availableClients = ref<any[]>([])
const healthStatus = ref<Record<string, boolean | null>>({}) 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({ const modal = ref({
open : false, open : false,
uuid : null as string | null, uuid : null as string | null,
+27 -3
View File
@@ -22,6 +22,14 @@
</svg> </svg>
Actualiser Actualiser
</button> </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 <button
v-if="totalNeedsRename > 0" v-if="totalNeedsRename > 0"
@click="renameAll()" @click="renameAll()"
@@ -129,7 +137,7 @@
<span v-else-if="!ep.needs_rename" class="text-[10px] text-green-400">✓</span> <span v-else-if="!ep.needs_rename" class="text-[10px] text-green-400">✓</span>
<button <button
v-else v-else
@click="renameSingle(ep)" @click="renameSingle(ep, serie)"
:disabled="renamingEp[ep.episode_id]" :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" class="text-xs px-2 py-1 rounded-lg border border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 transition-colors"
> >
@@ -168,6 +176,7 @@ const { add: toast } = useToast()
// ─── State ──────────────────────────────────────────────────── // ─── State ────────────────────────────────────────────────────
const loading = ref(true) const loading = ref(true)
const renamingAll = ref(false) const renamingAll = ref(false)
const purgingNfo = ref(false)
const series = ref<any[]>([]) const series = ref<any[]>([])
const nfoSupport = ref(false) const nfoSupport = ref(false)
const collapsed = ref<Set<number>>(new Set()) const collapsed = ref<Set<number>>(new Set())
@@ -225,14 +234,14 @@ async function renameAll(serieId?: number) {
} }
} }
async function renameSingle(ep: any) { async function renameSingle(ep: any, serie: any) {
renamingEp.value[ep.episode_id] = true renamingEp.value[ep.episode_id] = true
try { try {
const res = await fetch('/api/rename-episode', { const res = await fetch('/api/rename-episode', {
method : 'POST', method : 'POST',
headers : { 'Content-Type': 'application/json' }, headers : { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body : JSON.stringify({ episode_id: ep.episode_id, torrent_hash: ep.torrent_hash }), body : JSON.stringify({ serie_id: serie.serie_id, episode_id: ep.episode_id, torrent_hash: ep.torrent_hash }),
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return } if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
@@ -245,6 +254,21 @@ async function renameSingle(ep: any) {
} }
} }
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) { async function unimportSerie(serie: any, deleteFile: boolean) {
if (!confirm(`Désimporter "${serie.serie_title}"${deleteFile ? ' et supprimer les fichiers' : ''} ?`)) return if (!confirm(`Désimporter "${serie.serie_title}"${deleteFile ? ' et supprimer les fichiers' : ''} ?`)) return
renamingAll.value = true renamingAll.value = true
+1 -1
View File
@@ -1 +1 @@
3.9.2 3.11.2