Compare commits

...
96 Commits
Author SHA1 Message Date
MasutaYunikon 0453ce44fb Merge pull request #82 from Masutayunikon/release-please--branches--main
chore(main): release 3.7.0
2026-04-11 13:10:52 +02:00
MasutaYunikon 1a6b08757b chore(main): release 3.7.0 2026-04-11 13:03:29 +02:00
MasutaYunikon 8c2d61de06 fix: type 2026-04-11 13:02:55 +02:00
MasutaYunikon 87fd964374 fix: type 2026-04-11 13:01:49 +02:00
MasutaYunikon 271fc14386 fix: settings 2026-04-11 12:59:24 +02:00
MasutaYunikon 176dfed0af fix: formatage 2026-04-11 12:58:24 +02:00
MasutaYunikon 34057638ca fix: formatage 2026-04-11 12:56:19 +02:00
MasutaYunikon bb29f02000 fix: ajout de l'option de suppresion dans l'interface settings.ts 2026-04-11 12:53:19 +02:00
MasutaYunikon 392f736925 feat: ajout du bouton copie
feat: ajout de l'option supprimé le torrent si l'option déplacer est activé

feat: ajout de la suppression des torrents depuis l'interface
2026-04-11 12:51:31 +02:00
MasutaYunikon 73b401f3e7 fix: l'import a le meme comportement que radarr/sonarr en ouvrant l'import dans le dossier de la serie attendu directement 2026-04-11 12:40:28 +02:00
MasutaYunikon f7c21f5fd1 feat: edit d'episodes importer dans le menu d'importation 2026-04-11 12:33:25 +02:00
MasutaYunikon 6078aac714 fix: vue des series importer 2026-04-11 12:21:48 +02:00
MasutaYunikon c61bd05f75 fix: vue des series importer 2026-04-11 12:19:46 +02:00
MasutaYunikon 89f225b980 fix: les series sans torrents sont bien organiser si importé manuellement 2026-04-11 12:15:35 +02:00
MasutaYunikon 66b0313ae9 fix: les series sans torrents sont bien organiser si importé manuellement 2026-04-11 12:12:31 +02:00
MasutaYunikon e02650d1cc fix: les series sans torrent et non ajoutées manuellement sont bien grisé 2026-04-11 12:10:03 +02:00
MasutaYunikon 45bf17c48f fix: les series sans torrent et non ajoutées manuellement sont bien grisé 2026-04-11 11:56:43 +02:00
MasutaYunikon 854839b16a fix: ajout des series non disponible pour pouvoir les add manuellement 2026-04-11 11:43:31 +02:00
MasutaYunikon 08c4122d5e fix: log 2026-04-10 21:06:55 +02:00
MasutaYunikon ef23a7119e fix: ajout de logs lors de non match 2026-04-10 21:03:57 +02:00
MasutaYunikon 21bcb2ba23 fix: organisation utilise l'extension du fichier au lieu de tester si ça existe 2026-04-10 21:00:24 +02:00
MasutaYunikon c94ff7da3f feat: ajout d'une "notification" dans la navbar si une nouvelle versions est disponible
feat: activation de l'import manuel
2026-04-10 18:05:47 +02:00
MasutaYunikon 71961eb998 feat: ajout du ratio, upload speed, etc dans les activités 2026-04-10 17:50:00 +02:00
MasutaYunikon 9b4dc4f782 feat: ajout d'une hashmap pour matcher les titres sur synologie et ajouter les hash de torrent manuellement 2026-04-10 17:37:49 +02:00
MasutaYunikon bb6d094383 Merge remote-tracking branch 'origin/main' 2026-04-10 12:46:05 +02:00
MasutaYunikon ea7ed597de fix: changer le noms de build arm64 et windows legacy pour une meilleur comprehension 2026-04-10 12:45:56 +02:00
MasutaYunikon ba736a6fe1 Merge pull request #81 from Masutayunikon/release-please--branches--main
chore(main): release 3.6.1
2026-04-10 12:39:53 +02:00
MasutaYunikon ceb18cef3c chore(main): release 3.6.1 2026-04-10 12:38:59 +02:00
MasutaYunikon 327cd66dcc Merge remote-tracking branch 'origin/main' 2026-04-10 12:38:30 +02:00
MasutaYunikon 31779d8f7e fix: windows baseline 2026-04-10 12:38:21 +02:00
MasutaYunikon d426662fd6 Merge pull request #80 from Masutayunikon/release-please--branches--main
chore(main): release 3.6.0
2026-04-10 12:21:44 +02:00
MasutaYunikon bf0285378d chore(main): release 3.6.0 2026-04-10 12:17:42 +02:00
MasutaYunikon bde760e8b4 Merge remote-tracking branch 'origin/main' 2026-04-10 12:17:14 +02:00
MasutaYunikon a358ee2d83 feat: ajout d'un exe pour les cpu non avx2 2026-04-10 12:17:06 +02:00
MasutaYunikon 559ce91954 Merge pull request #79 from Masutayunikon/release-please--branches--main
chore(main): release 3.5.0
2026-04-09 22:48:03 +02:00
MasutaYunikon b5bd8b9b48 chore(main): release 3.5.0 2026-04-09 22:47:19 +02:00
MasutaYunikon 9576ec9b23 fix: import manuelle desactivé pour le moment 2026-04-09 22:46:49 +02:00
MasutaYunikon a582991c15 feat: manual import 2026-04-09 22:36:15 +02:00
MasutaYunikon 2d0abab222 fix: utorrent path for import 2026-04-09 18:34:36 +02:00
MasutaYunikon 8ec323dc95 fix: remove log for mapping
fix: unlink before move

fix: l'organiseur prend les fichiers renommer maintenant

feat: ajout d'information dans le fichier d'organisation pour l'organisation manuel plus tard

fix: ajout d'une verif lors de l'écriture de la config sur les items dans body

fix: ne sauvegarde plus le mots de passe "fake" quand on edit un client

feat: ajout du driver rtorrent

fix: empeche un login failed de se repeter et de se faire bloquer par les clients
2026-04-09 18:30:34 +02:00
MasutaYunikon 630374def7 fix: remove log for mapping
fix: unlink before move
2026-04-09 16:04:19 +02:00
MasutaYunikon 0e09c85efd feat: add uttorent driver
fix: organiser take correct name
2026-04-09 15:59:55 +02:00
MasutaYunikon 89c0db5f36 fix: add log for candidate path 2026-04-08 22:07:19 +02:00
MasutaYunikon debf61f714 fix: polling des torrents à 60s
feat: ajout d'un bouton refresh
2026-04-08 19:39:31 +02:00
MasutaYunikon 0c53c1d32b Merge remote-tracking branch 'origin/main' 2026-04-08 19:25:28 +02:00
MasutaYunikon d563ed4c68 feat: ajout des parametres dossier de téléchargements pour les clients torrent
feat: ajout de synology downloader comme client

fix: augmentation du header size 8kb -> 32kb

feat: Ajout des mapping sur les clients

feat: auto update des fichiers nfo
2026-04-08 19:25:19 +02:00
MasutaYunikon 3b39a5a8c4 Merge pull request #78 from Masutayunikon/release-please--branches--main
chore(main): release 3.4.0
2026-04-08 15:33:40 +02:00
MasutaYunikon 50144c70dc chore(main): release 3.4.0 2026-04-08 15:14:07 +02:00
MasutaYunikon 482743bf3a fix: typo 2026-04-08 15:13:35 +02:00
MasutaYunikon f3a36af629 fix: le driver de Transmission n'est plus basé sur android
fix: les torrent inconnue en états seront mis dans la catégorie terminée
feat: ajout d'un retour visuel si on a un path par defaut sur /
fix: utilisation de nfo_filename au lieu de original_filename
2026-04-08 15:07:59 +02:00
MasutaYunikon bc960ba585 Merge pull request #77 from Nackophilz/main
feat: Ajout du support pour l'édition/mise à jour des clients torrent
2026-04-08 14:55:17 +02:00
ElPouki e648764715 Merge branch 'Masutayunikon:main' into main 2026-04-08 14:34:04 +02:00
ElPouki 15fac6c472 feat: Ajout du support pour l'édition/mise à jour des clients torrent
Nouveau helper updateClient et un endpoint PUT /api/torrent-clients/:uuid qui enregistre les modifications, journalise les mises à jour et renvoie le client.
Mise à jour de l'UI des paramètres pour ajouter un bouton "Modifier" et réutiliser la modale d'ajout de client pour les éditions (afin d'assurer le suivi de l'uuid ), bascule méthode PUT pour les requêtes de mise à jour, et actualisation de la liste des clients ainsi que des notifications en cas de modification réussie.
2026-04-08 14:33:48 +02:00
MasutaYunikon 61591020be Merge pull request #76 from Nackophilz/main
Fix : Saison 00 et double slash chemin
2026-04-08 14:21:07 +02:00
ElPouki f92b3c5fb9 Fix : Saison 00 et double slash chemin
Organize worker :  Traiter la saison 0 comme "Spéciaux" au lieu de "Saison 0".

FolderPicker : Ajouter un bouton "accueil" pour naviguer vers la racine, nettoyer le chemin sélectionné en supprimant les slashes de fin (avec un repli sur "/" pour la racine), et fiabiliser joinPath pour éviter les doubles slashes lorsque la base se termine par un slash
2026-04-08 14:12:01 +02:00
MasutaYunikon a39a98615b Merge pull request #69 from Masutayunikon/release-please--branches--main
chore(main): release 3.3.3
2026-04-08 10:04:19 +02:00
MasutaYunikon 2a3defdb34 chore(main): release 3.3.3 2026-04-07 20:07:35 +02:00
MasutaYunikon 733f8790ca fix: path 2026-04-07 20:07:03 +02:00
MasutaYunikon 4a9bbd9402 Merge remote-tracking branch 'origin/main' 2026-04-07 19:53:19 +02:00
MasutaYunikon 1562ca32f9 fix: MediaManagementView.vue api call is now in mounted 2026-04-07 19:53:10 +02:00
MasutaYunikon 7b780e4fa1 Merge pull request #68 from Masutayunikon/release-please--branches--main
chore(main): release 3.3.2
2026-04-07 19:42:14 +02:00
MasutaYunikon 444b30ef3d chore(main): release 3.3.2 2026-04-07 19:41:28 +02:00
MasutaYunikon 42b46496d1 Merge remote-tracking branch 'origin/main' 2026-04-07 19:40:57 +02:00
MasutaYunikon f092da4108 fix: use fetch instead of useFetch 2026-04-07 19:40:49 +02:00
MasutaYunikon 8e635c1d28 Merge pull request #67 from Masutayunikon/release-please--branches--main
chore(main): release 3.3.1
2026-04-07 19:24:53 +02:00
MasutaYunikon 3b8b7a0454 chore(main): release 3.3.1 2026-04-07 19:24:23 +02:00
MasutaYunikon 9ef1516b0b Merge remote-tracking branch 'origin/main' 2026-04-07 19:23:57 +02:00
MasutaYunikon 1af210058c fix: add driver register for transmission 2026-04-07 19:23:49 +02:00
MasutaYunikon 6caad83081 Merge pull request #66 from Masutayunikon/release-please--branches--main
chore(main): release 3.3.0
2026-04-07 19:05:43 +02:00
MasutaYunikon 520bfcfae1 chore(main): release 3.3.0 2026-04-07 19:05:07 +02:00
MasutaYunikon 2e5bc8953a Merge remote-tracking branch 'origin/main' 2026-04-07 19:04:34 +02:00
MasutaYunikon f304562f01 feat: add transmission client
feat: set defaultPath for any system
2026-04-07 19:04:26 +02:00
MasutaYunikon c9690d9a1b Merge pull request #65 from Masutayunikon/release-please--branches--main
chore(main): release 3.2.1
2026-04-07 16:18:47 +02:00
MasutaYunikon 5daface279 chore(main): release 3.2.1 2026-04-07 16:17:52 +02:00
MasutaYunikon 5687aaa5d9 Merge remote-tracking branch 'origin/main' 2026-04-07 16:17:22 +02:00
MasutaYunikon ba67817ea0 fix: bun target android 2026-04-07 16:17:13 +02:00
MasutaYunikon e0452dbd0c Merge pull request #64 from Masutayunikon/release-please--branches--main
chore(main): release 3.2.0
2026-04-07 16:14:55 +02:00
MasutaYunikon c376061333 chore(main): release 3.2.0 2026-04-07 16:14:30 +02:00
MasutaYunikon bb02ea6cf8 fix: android build name 2026-04-07 16:13:57 +02:00
MasutaYunikon 887beae614 Merge remote-tracking branch 'origin/main' 2026-04-07 16:11:07 +02:00
MasutaYunikon 1290d5c925 feat: add build for arm64
feat: add developer settings on settings and dashboard debug
2026-04-07 16:10:57 +02:00
MasutaYunikon dffb340250 Merge pull request #63 from Masutayunikon/release-please--branches--main
chore(main): release 3.1.1
2026-03-28 10:45:16 +01:00
MasutaYunikon 059298f537 chore(main): release 3.1.1 2026-03-28 10:44:47 +01:00
MasutaYunikon 0ba2144f2f Merge remote-tracking branch 'origin/main' 2026-03-28 10:44:27 +01:00
MasutaYunikon 83a2fb9860 fix: remove plex settings 2026-03-28 10:44:13 +01:00
MasutaYunikon 5445fadf94 Merge remote-tracking branch 'origin/main' 2026-03-28 10:36:26 +01:00
MasutaYunikon 6d110b86dd fix: torrent for multiple seasons is now working 2026-03-28 10:36:16 +01:00
MasutaYunikon 2ef6d4738a Merge pull request #62 from Masutayunikon/release-please--branches--main
chore(main): release 3.1.0
2026-03-27 22:42:34 +01:00
MasutaYunikon 65f7d4e109 chore(main): release 3.1.0 2026-03-27 22:40:50 +01:00
MasutaYunikon 6166c370ef Merge remote-tracking branch 'origin/main' 2026-03-27 22:40:28 +01:00
MasutaYunikon ad1cade6bf feat: use original_name and formated_name from scraper to bypass timeout 2026-03-27 22:40:21 +01:00
MasutaYunikon 803519062a Merge pull request #61 from Masutayunikon/release-please--branches--main
chore(main): release 3.0.0
2026-03-27 07:03:30 +01:00
MasutaYunikon 6246c44994 chore(main): release 3.0.0 2026-03-27 07:03:00 +01:00
MasutaYunikon 9780aee40d feat!: refonte complète du frontend et amélioration des logs serveur
BREAKING CHANGE: nouvelle architecture frontend avec layout sidebar, système de thèmes et routes restructurées

### Frontend
- nouveau layout global `AppLayout.vue` avec sidebar de navigation inspirée Radarr/Sonarr
- sidebar configurable via un objet `NavItem[]` avec support parents/enfants cliquables
- 3 thèmes CSS (Indigo Électrique, Crimson Night, Teal Froid) persistés via localStorage
- switcher de thème en bas de sidebar avec pastilles colorées
- logo FanKarr cliquable, bouton déconnexion discret dans le header sidebar
- badge animé sur "Activité" affichant le nombre de téléchargements en cours
- migration complète vers Tailwind v4 avec variables CSS `@theme`
- classes utilitaires globales : `btn-primary`, `btn-secondary`, `settings-card`, `settings-input`

### Vues
- `SeriesView` : barre colorée en bas des affiches (vert/bleu/orange), dropdown filtres, boutons taille S/M/L/XL persistés
- `SerieView` : bouton état unique par épisode (↓/⟳/✓/✗), badges simplifiés, toast via `useToast` global
- `ActivityView` : onglets pill, stats, toggle "masquer importés", polling silencieux
- `DashboardView` : stats catalogue/importées/en cours/erreurs, torrents actifs, derniers imports
- `SettingsView` découpée en 3 vues indépendantes : `DownloadClientView`, `MediaManagementView`, `CatalogueView`
- `LogsView` déplacée dans `src/views/settings/`
- `AuthView` : migré au système de thème

### Composants
- `SidebarNavItem` : support badge, chevron masqué si badge présent
- `SettingsToggle` : composant réutilisable `v-model`
- `FolderPicker` : migré au thème, `Teleport` intégré
- `ToastContainer` : déplacé en bas droite, groupement des toasts similaires avec badge count

### Router
- `AppLayout` comme layout racine pour toutes les routes protégées
- `/auth` reste hors layout
- redirections `/` → `/series`, `/downloads` → `/activity`
- `SettingsLayout` avec titre dynamique par route
- scroll géré sur `<main id="main-scroll">` dans `AppLayout`

### Stores & composables
- nouveau store `downloads` partagé pour le badge sidebar
- `usePosterSize` : taille des affiches persistée en localStorage
- `useToast` : groupement des toasts du même type, timer reset au lieu d'empilement

### Serveur
- logs structurés via `logger` dans `auth.ts`, `organize.ts`, `organize-worker.ts`, `torrent-clients/index.ts`, `qbittorrent.ts`, `index.ts`
- suppression des `console.log/error/warn` en faveur de `logger.info/warn/error/debug`
- messages worker routés vers `logger` via `organize.ts`
- logs `debug` pour les événements verbeux (healthcheck, skip, worker terminé)
2026-03-27 07:02:31 +01:00
MasutaYunikon e5e8baf1fd Merge remote-tracking branch 'origin/main' 2026-03-26 07:06:02 +01:00
MasutaYunikon 79e73b603f feat: hors-fankai badge and fix series datetime and typo 2026-03-26 07:05:53 +01:00
48 changed files with 5688 additions and 2524 deletions

No files matched your search

+14 -5
View File
@@ -48,11 +48,21 @@ jobs:
bun-target: bun-windows-x64
binary: binaries/fankarr.exe
archive: fankarr-windows-x64.zip
- target: windows-x64-legacy
os: ubuntu-latest
bun-target: bun-windows-x64-baseline
binary: binaries/fankarr.exe
archive: fankarr-windows-x64-legacy.zip
- target: linux-x64
os: ubuntu-latest
bun-target: bun-linux-x64
binary: binaries/fankarr
archive: fankarr-linux-x64.zip
- target: linux-arm64
os: ubuntu-latest
bun-target: bun-linux-arm64
binary: binaries/fankarr
archive: fankarr-linux-arm64.zip
- target: macos
os: macos-latest
bun-target: bun-darwin-arm64
@@ -101,15 +111,14 @@ jobs:
run: bun build server/organize-worker.ts --target node --outfile organize-worker.js
- name: Package archive (Windows)
if: matrix.target == 'windows-x64'
run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public -DestinationPath ${{ matrix.archive }}
if: matrix.target == 'windows-x64' || matrix.target == 'windows-x64-legacy'
run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public, version.txt -DestinationPath ${{ matrix.archive }}
shell: pwsh
- name: Package archive (Unix)
if: matrix.target != 'windows-x64'
if: matrix.target != 'windows-x64' && matrix.target != 'windows-x64-legacy'
run: |
chmod +x ${{ matrix.binary }}
zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/
zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/ version.txt
- name: Upload archive to Release
uses: softprops/action-gh-release@v2
+21 -1
View File
@@ -9,8 +9,28 @@ name: release-please
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.FINED_TOKEN }}
release-type: simple
release-type: simple
# Quand une release est créée, on écrit version.txt et on le committe
- uses: actions/checkout@v4
if: ${{ steps.release.outputs.release_created }}
with:
token: ${{ secrets.FINED_TOKEN }}
- name: Write version.txt
if: ${{ steps.release.outputs.release_created }}
run: |
echo "${{ steps.release.outputs.tag_name }}" > version.txt
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add version.txt
git commit -m "chore: update version.txt to ${{ steps.release.outputs.tag_name }}"
git push
+177
View File
@@ -1,5 +1,182 @@
# Changelog
## [3.7.0](https://github.com/Masutayunikon/FanKarr/compare/v3.6.1...v3.7.0) (2026-04-11)
### Features
* activation de l'import manuel ([9712644](https://github.com/Masutayunikon/FanKarr/commit/971264446c45401f5aaa3b3acae0fab6cae15264))
* ajout d'une "notification" dans la navbar si une nouvelle versions est disponible ([9712644](https://github.com/Masutayunikon/FanKarr/commit/971264446c45401f5aaa3b3acae0fab6cae15264))
* ajout d'une hashmap pour matcher les titres sur synologie et ajouter les hash de torrent manuellement ([3b8c448](https://github.com/Masutayunikon/FanKarr/commit/3b8c448ea0b0917e40d493ad4bf0e81cd35dff5a))
* ajout de l'option supprimé le torrent si l'option déplacer est activé ([3e83dc0](https://github.com/Masutayunikon/FanKarr/commit/3e83dc0d79107e46cac14f73dd3ef04794ad824d))
* ajout de la suppression des torrents depuis l'interface ([3e83dc0](https://github.com/Masutayunikon/FanKarr/commit/3e83dc0d79107e46cac14f73dd3ef04794ad824d))
* ajout du bouton copie ([3e83dc0](https://github.com/Masutayunikon/FanKarr/commit/3e83dc0d79107e46cac14f73dd3ef04794ad824d))
* ajout du ratio, upload speed, etc dans les activités ([ee34a53](https://github.com/Masutayunikon/FanKarr/commit/ee34a5350086dcd7e783f9c14ca60e09c60eb032))
* edit d'episodes importer dans le menu d'importation ([3017cac](https://github.com/Masutayunikon/FanKarr/commit/3017cac67a5d3f5ccb9f153f233a446b0afaaec8))
### Bug Fixes
* ajout de l'option de suppresion dans l'interface settings.ts ([a70e65f](https://github.com/Masutayunikon/FanKarr/commit/a70e65f0e4323369d1fd7401665fb3033352a286))
* ajout de logs lors de non match ([0c6c802](https://github.com/Masutayunikon/FanKarr/commit/0c6c802cbfdf297df40aa5944eacf16f58489bd1))
* ajout des series non disponible pour pouvoir les add manuellement ([9e0664a](https://github.com/Masutayunikon/FanKarr/commit/9e0664aa49d6f99be8ca7e7bbd7c8e7cec0f9e7c))
* changer le noms de build arm64 et windows legacy pour une meilleur comprehension ([47d81e6](https://github.com/Masutayunikon/FanKarr/commit/47d81e692d866beedf80a6c46317dfe79213547d))
* formatage ([82a61eb](https://github.com/Masutayunikon/FanKarr/commit/82a61ebe74b8196891a50c1380ffaa89178c1fd1))
* formatage ([f6b384e](https://github.com/Masutayunikon/FanKarr/commit/f6b384ec49bf018e23eb0a0b15917aee4578d66e))
* l'import a le meme comportement que radarr/sonarr en ouvrant l'import dans le dossier de la serie attendu directement ([1485efe](https://github.com/Masutayunikon/FanKarr/commit/1485efe053d3281521978bb2d29a6c9ce219c2fd))
* les series sans torrent et non ajoutées manuellement sont bien grisé ([2bdafd8](https://github.com/Masutayunikon/FanKarr/commit/2bdafd8e377d1a957903f228c61c1f3ab3a45955))
* les series sans torrent et non ajoutées manuellement sont bien grisé ([d854247](https://github.com/Masutayunikon/FanKarr/commit/d854247b7fe6d5e01b0803ff7d415aba431c4f0d))
* les series sans torrents sont bien organiser si importé manuellement ([6ed77c1](https://github.com/Masutayunikon/FanKarr/commit/6ed77c1196c45e57dbd989cc049f42403f522821))
* les series sans torrents sont bien organiser si importé manuellement ([bc10889](https://github.com/Masutayunikon/FanKarr/commit/bc1088903053b7b98cabbe24c9cdcb8ac4c489b2))
* log ([ef4b364](https://github.com/Masutayunikon/FanKarr/commit/ef4b3643c2852500f048ee330b92d60fed4b5ba5))
* organisation utilise l'extension du fichier au lieu de tester si ça existe ([2b7bf48](https://github.com/Masutayunikon/FanKarr/commit/2b7bf4808e4a11d450403f477ea3031add8a2ebc))
* settings ([60a548f](https://github.com/Masutayunikon/FanKarr/commit/60a548f6bd658fe9986d94c094cad0fbed103882))
* type ([f4305b5](https://github.com/Masutayunikon/FanKarr/commit/f4305b537f2ab0a1370445eae9e9150a34e44e86))
* type ([bdfe74b](https://github.com/Masutayunikon/FanKarr/commit/bdfe74bb8132b6bff0b14ccb135bb3b90f3441b6))
* vue des series importer ([6786cb5](https://github.com/Masutayunikon/FanKarr/commit/6786cb50f4c50b95f1dcd48d15742b1ae6602ed5))
* vue des series importer ([50efd74](https://github.com/Masutayunikon/FanKarr/commit/50efd74a5b918c4ff61e7d4feac2643693db1861))
## [3.6.1](https://github.com/Masutayunikon/FanKarr/compare/v3.6.0...v3.6.1) (2026-04-10)
### Bug Fixes
* windows baseline ([0469031](https://github.com/Masutayunikon/FanKarr/commit/0469031ff05c8794b20f73bc7c3ac417ab473e5d))
## [3.6.0](https://github.com/Masutayunikon/FanKarr/compare/v3.5.0...v3.6.0) (2026-04-10)
### Features
* ajout d'un exe pour les cpu non avx2 ([51aacaf](https://github.com/Masutayunikon/FanKarr/commit/51aacafbcb0de1ca6a8b496c0905aa4e831d6008))
## [3.5.0](https://github.com/Masutayunikon/FanKarr/compare/v3.4.0...v3.5.0) (2026-04-09)
### Features
* add uttorent driver ([712db87](https://github.com/Masutayunikon/FanKarr/commit/712db87a77be4dec157c3addb5bc12b1ac2bc124))
* ajout d'information dans le fichier d'organisation pour l'organisation manuel plus tard ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* ajout d'un bouton refresh ([48f86ea](https://github.com/Masutayunikon/FanKarr/commit/48f86ea9cc36ded75f02fa3879a7d3bd2640fb70))
* ajout de synology downloader comme client ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* Ajout des mapping sur les clients ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* ajout des parametres dossier de téléchargements pour les clients torrent ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* ajout du driver rtorrent ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* auto update des fichiers nfo ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* manual import ([b65e3e7](https://github.com/Masutayunikon/FanKarr/commit/b65e3e7cd8563466725e7eaf2f8aad7afd927296))
### Bug Fixes
* add log for candidate path ([9c6465b](https://github.com/Masutayunikon/FanKarr/commit/9c6465bc45f9e08e67c53f4e0c83b6a625ea25f1))
* ajout d'une verif lors de l'écriture de la config sur les items dans body ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* augmentation du header size 8kb -&gt; 32kb ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* empeche un login failed de se repeter et de se faire bloquer par les clients ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* import manuelle desactivé pour le moment ([fc7fd3a](https://github.com/Masutayunikon/FanKarr/commit/fc7fd3a0ecfba3a82f851fdcc7a278fdeb43febe))
* l'organiseur prend les fichiers renommer maintenant ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* ne sauvegarde plus le mots de passe "fake" quand on edit un client ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* organiser take correct name ([712db87](https://github.com/Masutayunikon/FanKarr/commit/712db87a77be4dec157c3addb5bc12b1ac2bc124))
* polling des torrents à 60s ([48f86ea](https://github.com/Masutayunikon/FanKarr/commit/48f86ea9cc36ded75f02fa3879a7d3bd2640fb70))
* remove log for mapping ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* remove log for mapping ([4d963e5](https://github.com/Masutayunikon/FanKarr/commit/4d963e5bf3ab00e798089e93cdc846fbd2c31de6))
* unlink before move ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* unlink before move ([4d963e5](https://github.com/Masutayunikon/FanKarr/commit/4d963e5bf3ab00e798089e93cdc846fbd2c31de6))
* utorrent path for import ([eba2552](https://github.com/Masutayunikon/FanKarr/commit/eba2552e1dba67eec615c2a17e0ca1b896fc1a3b))
## [3.4.0](https://github.com/Masutayunikon/FanKarr/compare/v3.3.3...v3.4.0) (2026-04-08)
### Features
* ajout d'un retour visuel si on a un path par defaut sur / ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
* Ajout du support pour l'édition/mise à jour des clients torrent ([dda8a03](https://github.com/Masutayunikon/FanKarr/commit/dda8a03403b46027a23b4a2812ca3797a2628673))
* Ajout du support pour l'édition/mise à jour des clients torrent ([a3e1c34](https://github.com/Masutayunikon/FanKarr/commit/a3e1c34e7547bab75c45ddd3eace0d211409ccd5))
### Bug Fixes
* le driver de Transmission n'est plus basé sur android ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
* les torrent inconnue en états seront mis dans la catégorie terminée ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
* typo ([18ec548](https://github.com/Masutayunikon/FanKarr/commit/18ec548d76c929afac11cd16d88e734480ee2755))
* utilisation de nfo_filename au lieu de original_filename ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
## [3.3.3](https://github.com/Masutayunikon/FanKarr/compare/v3.3.2...v3.3.3) (2026-04-07)
### Bug Fixes
* MediaManagementView.vue api call is now in mounted ([8c5b650](https://github.com/Masutayunikon/FanKarr/commit/8c5b650ca6eeac75f33e8c5423f6d4fd24f9871b))
* path ([58ae88a](https://github.com/Masutayunikon/FanKarr/commit/58ae88a01c063318407134cd7351572220acd892))
## [3.3.2](https://github.com/Masutayunikon/FanKarr/compare/v3.3.1...v3.3.2) (2026-04-07)
### Bug Fixes
* use fetch instead of useFetch ([af550ec](https://github.com/Masutayunikon/FanKarr/commit/af550eccfbeeb116491e8ea4973eac493ed962d3))
## [3.3.1](https://github.com/Masutayunikon/FanKarr/compare/v3.3.0...v3.3.1) (2026-04-07)
### Bug Fixes
* add driver register for transmission ([7d2096e](https://github.com/Masutayunikon/FanKarr/commit/7d2096ec1972079a2e0e5bdfe01c4cc702e6b185))
## [3.3.0](https://github.com/Masutayunikon/FanKarr/compare/v3.2.1...v3.3.0) (2026-04-07)
### Features
* add transmission client ([a2b88de](https://github.com/Masutayunikon/FanKarr/commit/a2b88de3a123aeb9c140e83605901dd5cdad041a))
* set defaultPath for any system ([a2b88de](https://github.com/Masutayunikon/FanKarr/commit/a2b88de3a123aeb9c140e83605901dd5cdad041a))
## [3.2.1](https://github.com/Masutayunikon/FanKarr/compare/v3.2.0...v3.2.1) (2026-04-07)
### Bug Fixes
* bun target android ([a167a87](https://github.com/Masutayunikon/FanKarr/commit/a167a870188bdcd6b26644e304c2059ad5f05e96))
## [3.2.0](https://github.com/Masutayunikon/FanKarr/compare/v3.1.1...v3.2.0) (2026-04-07)
### Features
* add build for arm64 ([96acc13](https://github.com/Masutayunikon/FanKarr/commit/96acc134698ca09842d57d906e4a28ab8e94ba56))
* add developer settings on settings and dashboard debug ([96acc13](https://github.com/Masutayunikon/FanKarr/commit/96acc134698ca09842d57d906e4a28ab8e94ba56))
### Bug Fixes
* android build name ([0cfe6eb](https://github.com/Masutayunikon/FanKarr/commit/0cfe6eb81055b841a67c08e02129709c0214d87d))
## [3.1.1](https://github.com/Masutayunikon/FanKarr/compare/v3.1.0...v3.1.1) (2026-03-28)
### Bug Fixes
* remove plex settings ([8744ade](https://github.com/Masutayunikon/FanKarr/commit/8744adeff5f2db1ab648412d50495690d9e68d0b))
* torrent for multiple seasons is now working ([2514008](https://github.com/Masutayunikon/FanKarr/commit/25140086c13ce5a497500659bdd6cea2aeff96ed))
## [3.1.0](https://github.com/Masutayunikon/FanKarr/compare/v3.0.0...v3.1.0) (2026-03-27)
### Features
* use original_name and formated_name from scraper to bypass timeout ([1b693fc](https://github.com/Masutayunikon/FanKarr/commit/1b693fc53b9ca3a830c952c3f2c101bf1da7d27e))
## [3.0.0](https://github.com/Masutayunikon/FanKarr/compare/v2.4.1...v3.0.0) (2026-03-27)
### ⚠ BREAKING CHANGES
* nouvelle architecture frontend avec layout sidebar, système de thèmes et routes restructurées
### Features
* hors-fankai badge and fix series datetime and typo ([9d4500a](https://github.com/Masutayunikon/FanKarr/commit/9d4500a3834455b5dd72663c8eeb2d1c54b240e2))
* refonte complète du frontend et amélioration des logs serveur ([ed38a2d](https://github.com/Masutayunikon/FanKarr/commit/ed38a2d07d977bd3d3bb41da6f2aad8b70e7f359))
## [2.4.1](https://github.com/Masutayunikon/FanKarr/compare/v2.4.0...v2.4.1) (2026-03-24)
+6 -4
View File
@@ -17,17 +17,20 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"@vueuse/core": "^14.2.1",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"lucide-vue-next": "^1.0.0",
"node-fetch": "^3.3.2",
"pinia": "^3.0.4",
"tailwindcss": "^4.2.1",
"vue": "^3.5.25",
"vue-router": "^5.0.3",
"bcryptjs": "^3.0.3"
"vue-router": "^5.0.3"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.9",
@@ -38,7 +41,6 @@
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5",
"@types/bcrypt": "^5.0.2"
"vue-tsc": "^3.1.5"
}
}
+46
View File
@@ -11,6 +11,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.2.1
version: 4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
'@vueuse/core':
specifier: ^14.2.1
version: 14.2.1(vue@3.5.29(typescript@5.9.3))
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
@@ -23,6 +26,9 @@ importers:
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
lucide-vue-next:
specifier: ^1.0.0
version: 1.0.0(vue@3.5.29(typescript@5.9.3))
node-fetch:
specifier: ^3.3.2
version: 3.3.2
@@ -536,6 +542,9 @@ packages:
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@vitejs/plugin-vue@6.0.4':
resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -622,6 +631,19 @@ packages:
vue:
optional: true
'@vueuse/core@14.2.1':
resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@14.2.1':
resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==}
'@vueuse/shared@14.2.1':
resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==}
peerDependencies:
vue: ^3.5.0
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -1039,6 +1061,11 @@ packages:
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lucide-vue-next@1.0.0:
resolution: {integrity: sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==}
peerDependencies:
vue: '>=3.0.1'
magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
engines: {node: '>=20.19.0'}
@@ -1758,6 +1785,8 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/node': 24.12.0
'@types/web-bluetooth@0.0.21': {}
'@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2
@@ -1886,6 +1915,19 @@ snapshots:
typescript: 5.9.3
vue: 3.5.29(typescript@5.9.3)
'@vueuse/core@14.2.1(vue@3.5.29(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.2.1
'@vueuse/shared': 14.2.1(vue@3.5.29(typescript@5.9.3))
vue: 3.5.29(typescript@5.9.3)
'@vueuse/metadata@14.2.1': {}
'@vueuse/shared@14.2.1(vue@3.5.29(typescript@5.9.3))':
dependencies:
vue: 3.5.29(typescript@5.9.3)
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -2303,6 +2345,10 @@ snapshots:
lodash.once@4.1.1: {}
lucide-vue-next@1.0.0(vue@3.5.29(typescript@5.9.3)):
dependencies:
vue: 3.5.29(typescript@5.9.3)
magic-string-ast@1.0.3:
dependencies:
magic-string: 0.30.21
+22 -9
View File
@@ -5,12 +5,13 @@ import jwt from 'jsonwebtoken'
import type { Request, Response, NextFunction } from 'express'
import { JWT_SECRET } from './secret.js'
import { DATA_DIR } from './config.js'
import { logger } from './logger.js'
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10
interface AuthData {
username: string
username : string
passwordHash: string
}
@@ -32,25 +33,25 @@ function writeAuth(data: AuthData): void {
// GET /api/auth/status
export function authStatus(req: Request, res: Response): void {
const existing = readAuth()
const token = req.cookies?.fankarr_token
const token = req.cookies?.fankarr_token
let loggedIn = false
if (token) {
try {
jwt.verify(token, JWT_SECRET)
loggedIn = true
} catch {}
} catch {
logger.debug('auth', 'Token invalide ou expiré')
}
}
res.json({
setup: !!existing,
loggedIn
})
res.json({ setup: !!existing, loggedIn })
}
// POST /api/auth/setup
export function authSetup(req: Request, res: Response): void {
if (readAuth()) {
logger.warn('auth', 'Tentative de setup alors qu\'un compte existe déjà')
res.status(400).json({ error: 'Un compte existe déjà' })
return
}
@@ -63,6 +64,7 @@ export function authSetup(req: Request, res: Response): void {
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
writeAuth({ username, passwordHash })
logger.info('auth', `Compte créé pour "${username}"`)
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
@@ -84,17 +86,26 @@ export function authLogin(req: Request, res: Response): void {
}
if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) {
logger.warn('auth', `Échec de connexion pour "${username}"`)
res.status(401).json({ error: 'Identifiants incorrects' })
return
}
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
logger.info('auth', `Connexion réussie pour "${username}"`)
res.json({ success: true })
}
// POST /api/auth/logout
export function authLogout(_req: Request, res: Response): void {
export function authLogout(req: Request, res: Response): void {
const token = req.cookies?.fankarr_token
if (token) {
try {
const payload = jwt.verify(token, JWT_SECRET) as any
logger.info('auth', `Déconnexion de "${payload.username}"`)
} catch {}
}
res.clearCookie('fankarr_token')
res.json({ success: true })
}
@@ -103,6 +114,7 @@ export function authLogout(_req: Request, res: Response): void {
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const token = req.cookies?.fankarr_token
if (!token) {
logger.debug('auth', `Accès refusé — non authentifié (${req.method} ${req.path})`)
res.status(401).json({ error: 'Non authentifié' })
return
}
@@ -110,6 +122,7 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
jwt.verify(token, JWT_SECRET)
next()
} catch {
logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`)
res.status(401).json({ error: 'Token invalide' })
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* gitlab-map.ts
* =============
* Map des titres API → titres GitLab pour le téléchargement des NFO.
* Partagé entre organize-worker.ts et index.ts.
*/
export const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
'Tower Of God Henshū' : 'Tower of God Henshū',
}
export function getGitlabTitle(title: string): string {
return SERIE_TITLE_GITLAB_MAP[title] ?? title
}
+585 -416
View File
File diff suppressed because it is too large. Load diff
+154 -126
View File
@@ -2,13 +2,6 @@
* organize-worker.ts
* ==================
* Worker thread dédié à l'organisation des fichiers.
* Tourne dans un thread séparé pour ne pas bloquer le event loop Express.
*
* Messages reçus : { type: 'run', torrents: TorrentInfo[], seriesData: SerieData[] }
* Messages envoyés :
* { type: 'log', level: 'info'|'warn'|'error', msg: string }
* { type: 'result', hash: string, name: string, done, skipped, errors }
* { type: 'done' }
*/
import fs from 'fs'
@@ -16,6 +9,7 @@ import fsp from 'fs/promises'
import path from 'path'
import { parentPort } from 'worker_threads'
import { DATA_DIR } from './config.js'
import { getGitlabTitle } from './gitlab-map.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
@@ -25,61 +19,80 @@ function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'war
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
// ─── Types ────────────────────────────────────────────────────
export interface OrgEntry {
at : string
season : number
episode : number
episode_id : number
src_filename : string
dest_filename: string
dest_path : string
}
// organized.json : Record<hash, Record<episode_id_string, OrgEntry>>
type Organized = Record<string, Record<string, OrgEntry>>
// ─── Settings ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean; usePlexTitles: boolean } {
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
try {
const p = path.join(DATA_DIR, 'settings.json')
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, usePlexTitles: false }
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, usePlexTitles: false } }
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
}
// ─── Organized log ────────────────────────────────────────────
function loadOrganized(): Record<string, Record<string, string>> {
function loadOrganized(): Organized {
try {
if (!fs.existsSync(ORGANIZED_PATH)) return {}
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch { return {} }
}
function markOrganized(hash: string, filename: string) {
function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
const data = loadOrganized()
if (!data[hash]) data[hash] = {}
data[hash][filename] = new Date().toISOString()
data[hash][String(episodeId)] = entry
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
function isOrganized(hash: string, filename: string): boolean {
return !!loadOrganized()[hash]?.[filename]
function isOrganized(hash: string, episodeId: number): boolean {
return !!loadOrganized()[hash]?.[String(episodeId)]
}
// ─── Lookup torrent dans les seriesData passées via message ───
// ─── Lookup torrent dans les seriesData ───────────────────────
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
const h = hash.toLowerCase()
for (const sd of seriesData) {
// Niveau série
for (const t of sd.torrents ?? []) {
if (t.infohash?.toLowerCase() === hash.toLowerCase())
if (t.infohash?.toLowerCase() === h)
return { torrent: t, serieData: sd }
}
// Niveau saison
const seasonsWithHash = (sd.seasons ?? []).filter((s: any) =>
s.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
)
if (seasonsWithHash.length === 1) {
const season = seasonsWithHash[0]
const t = season.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: { ...t, _season: season }, serieData: sd }
} else if (seasonsWithHash.length > 1) {
const t = seasonsWithHash[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: t, serieData: sd }
}
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
if (t.infohash?.toLowerCase() === hash.toLowerCase())
return { torrent: { ...t, _season: season }, serieData: sd }
}
// Niveau épisode — compter combien d'épisodes partagent ce torrent
const matchingEpisodes = season.episodes?.filter((ep: any) =>
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
) ?? []
if (matchingEpisodes.length === 1) {
// Torrent propre à un seul épisode → cas épisode unique
const ep = matchingEpisodes[0]
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
} else if (matchingEpisodes.length > 1) {
// Même torrent partagé entre plusieurs épisodes → traiter comme pack_saison
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: { ...t, _season: season }, serieData: sd }
}
}
@@ -87,16 +100,13 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
return null
}
// Construit la map filename → { season_number, original_filename, fullPath }
// Supporte les deux formats : { infohash, path } et string (legacy)
// destFilename : original_filename si nfoSupport, formatted_name sinon
function buildFileMap(
serieData : any,
hash : string,
nfoSupport : boolean,
seasonFilter?: number,
): Map<string, { season_number: number; original_filename: string; fullPath: string }> {
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
): Map<string, { season_number: number; episode_number: number; episode_id: number; nfo_filename: string; fullPath: string }> {
const map = new Map<string, { season_number: number; episode_number: number; episode_id: number; nfo_filename: string; fullPath: string }>()
const h = hash.toLowerCase()
for (const season of serieData.seasons ?? []) {
@@ -117,31 +127,33 @@ function buildFileMap(
if (!matchedPath) continue
const filename = matchedPath.split('/').pop() ?? matchedPath
// nfoSupport → original_filename (correspond aux .nfo GitLab)
// sinon → formatted_name de l'API, ou fallback original_filename
const fmtName = ep.formatted_name?.trim()
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: null
if (!nfoSupport && !fmtName) {
warn(`[organize] formatted_name manquant pour ep ${ep.id} (ep ${ep.episode_number}) — fallback original_filename`)
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`)
}
const destName = nfoSupport
? (ep.original_filename ?? filename)
: (fmtName ?? ep.original_filename ?? filename)
const srcExt = path.extname(filename)
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? filename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
const destName = swapExtension(resolvedName, srcExt)
map.set(filename, {
season_number : season.season_number,
original_filename: destName,
fullPath : matchedPath,
season_number : season.season_number,
episode_number: ep.episode_number,
episode_id : ep.id,
nfo_filename : destName,
fullPath : matchedPath,
})
}
}
return map
}
// ─── Dossiers exclus ─────────────────────────────────────────
// ─── Dossiers exclus ──────────────────────────────────────────
const EXCLUDED_FOLDERS = new Set([
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
@@ -159,23 +171,6 @@ function isInExcludedFolder(filePath: string[]): boolean {
return false
}
// ─── Map correction titres API → titres GitLab ───────────────
const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
'Tower Of God Henshū' : 'Tower of God Henshū',
}
function getGitlabSerieTitle(serieTitle: string): string {
return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle
}
// ─── GitLab NFO downloader ────────────────────────────────────
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
@@ -183,21 +178,21 @@ const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
async function fetchJson(url: string): Promise<any> {
const { default: nodeFetch } = await import('node-fetch')
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
async function fetchBinary(url: string): Promise<Buffer> {
const { default: nodeFetch } = await import('node-fetch')
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return Buffer.from(await res.arrayBuffer())
}
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
const gitlabTitle = getGitlabSerieTitle(serieTitle)
const gitlabTitle = getGitlabTitle(serieTitle)
const folderPath = `pack/${gitlabTitle}`
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
log(`Téléchargement NFO/images depuis GitLab pour "${gitlabTitle}"`)
let files: any[] = []
try {
@@ -212,50 +207,59 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
page++
}
} catch (err) {
warn(`[nfo] Dossier GitLab introuvable pour "${serieTitle}": ${err instanceof Error ? err.message : err}`)
warn(`Dossier GitLab introuvable pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
return
}
if (files.length === 0) { warn(`[nfo] Aucun fichier trouvé pour "${serieTitle}"`); return }
if (files.length === 0) { warn(`Aucun fichier NFO trouvé pour "${gitlabTitle}"`); return }
const fileEntries = files.filter((f: any) => f.type === 'blob')
log(`[nfo] ${fileEntries.length} fichiers à télécharger`)
log(`${fileEntries.length} fichiers NFO/images à télécharger pour "${gitlabTitle}"`)
let downloaded = 0, skipped = 0, failed = 0
for (const entry of fileEntries) {
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
const destPath = path.join(destRoot, relativePath)
if (fs.existsSync(destPath)) continue
if (fs.existsSync(destPath)) { skipped++; continue }
try {
fs.mkdirSync(path.dirname(destPath), { recursive: true })
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
const data = await fetchBinary(rawUrl)
fs.writeFileSync(destPath, data)
log(`[nfo] ✓ ${relativePath}`)
downloaded++
} catch (err) {
warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`)
warn(`Échec téléchargement NFO "${relativePath}" : ${err instanceof Error ? err.message : err}`)
failed++
}
}
log(`[nfo] GitLab synchronisé pour "${gitlabTitle}"`)
log(`NFO "${gitlabTitle}" — ${downloaded} téléchargés, ${skipped} déjà présents${failed > 0 ? `, ${failed} échecs` : ''}`)
}
// ─── Filesystem ops ───────────────────────────────────────────
function seasonFolder(n: number): string { return `Saison ${n}` }
function swapExtension(filename: string, ext: string): string {
const currentExt = path.extname(filename)
if (!currentExt || currentExt === ext) return filename
return filename.slice(0, -currentExt.length) + ext
}
function seasonFolder(n: number): string {
return n === 0 ? 'Specials' : `Saison ${n}`
}
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
catch (err) {
warn(`[organize] hardlink échoué (${err instanceof Error ? err.message : err})`)
debug(`Hardlink échoué (${err instanceof Error ? err.message : err}), fallback copie`)
return false
}
}
// ─── Sanitise un nom de dossier ──────────────────────────────
// Remplace les caractères interdits sur les systèmes de fichiers communs
function sanitizeDirName(name: string): string {
return name
.replace(/:/g, ' -') // "Kaguya-sama : Love" → "Kaguya-sama - Love"
.replace(/[<>"/\\|?*]/g, '') // autres caractères interdits
.replace(/\s+/g, ' ') // espaces multiples
.replace(/:/g, ' -')
.replace(/[<>"/\\|?*]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
@@ -264,27 +268,18 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
log(`[organize] ── ${name} ──`)
log(`[organize] hash=${hash} save_path=${savePath} mode=${organizeMode}`)
const found = findTorrentByHash(hash, seriesData)
if (!found) {
error(`[organize] ✗ Hash ${hash} introuvable dans les données série`)
error(`Hash ${hash} introuvable dans les données série`)
throw new Error(`Torrent introuvable : ${hash}`)
}
const { torrent, serieData } = found
const { usePlexTitles } = readSettings()
const rawTitle = serieData.title ?? serieData.show_title ?? name
const serieTitle = sanitizeDirName(
usePlexTitles && serieData.title_for_plex
? serieData.title_for_plex
: rawTitle
)
const torrentTitle = torrent.title ?? name
log(`[organize] ✓ Trouvé : ${torrentTitle} — série: ${serieTitle}`)
const serieTitle = sanitizeDirName(rawTitle)
debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
// ── NFO/images depuis GitLab ──────────────────────────────
if (nfoSupport) {
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
}
@@ -303,13 +298,17 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
const fmtName = ep.formatted_name?.trim()
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: null
const destName = nfoSupport
? (ep.original_filename ?? filename)
: (fmtName ?? ep.original_filename ?? filename)
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
const dest = path.join(destDir, destName)
if (isOrganized(hash, filename)) return { ...result, total: 1, skipped: 1 }
const srcExt = path.extname(filename)
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? filename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
const destName = swapExtension(resolvedName, srcExt)
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
const dest = path.join(destDir, destName)
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
const candidates = filePath ? [
path.join(savePath, filePath),
@@ -325,7 +324,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
error(`[organize] ✗ ${filename} introuvable`)
error(`Fichier source introuvable : ${filename}`)
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] }
}
@@ -335,30 +334,40 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
} else {
await fsp.copyFile(src, dest)
markOrganized(hash, ep.id, {
at: new Date().toISOString(), season: season.season_number ?? 1,
episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest,
})
await fsp.unlink(src)
log(`${destName} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 }
}
}
markOrganized(hash, filename)
log(`[organize] ✓ ${destName} → Saison ${season.season_number}`)
markOrganized(hash, ep.id, {
at: new Date().toISOString(), season: season.season_number ?? 1,
episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest,
})
log(`${destName} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 }
}
// ── Pack saison / intégrale ───────────────────────────────
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter)
const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter)
if (fileMap.size === 0) {
warn(`[organize] Aucun fichier mappé pour ${name}`)
warn(`Aucun fichier mappé pour "${name}"`)
return result
}
result.total = fileMap.size
for (const [filename, { season_number, original_filename, fullPath }] of fileMap) {
if (isOrganized(hash, filename)) { result.skipped++; continue }
for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) {
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
// Source : on utilise save_path + fullPath (chemin relatif exact dans le torrent)
// save_path vient de qBittorrent → résiste aux renommages de dossier
const candidates = [
path.join(savePath, fullPath),
path.join(completePath, fullPath),
@@ -369,71 +378,90 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
error(`[organize] ✗ ${filename} introuvable`)
error(`Fichier source introuvable : ${filename}`)
result.errors.push({ file: filename, error: 'Source introuvable' })
continue
}
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number))
const dest = path.join(destDir, original_filename)
const dest = path.join(destDir, nfo_filename)
if (fs.existsSync(dest)) { markOrganized(hash, filename); result.skipped++; continue }
if (fs.existsSync(dest)) {
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
})
result.skipped++
continue
}
try {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
})
} else {
await fsp.copyFile(src, dest)
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
})
await fsp.unlink(src)
}
markOrganized(hash, filename)
result.done++
log(`[organize] ✓ ${original_filename} → Saison ${season_number}`)
debug(`${nfo_filename} → Saison ${season_number}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
error(`[organize] ✗ ${filename}: ${msg}`)
error(`Échec import "${filename}" : ${msg}`)
result.errors.push({ file: filename, error: msg })
}
}
log(`[organize] ── ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
return result
}
// ─── Boucle principale du worker ─────────────────────────────
// ─── Boucle principale ────────────────────────────────────────
parentPort?.on('message', async (msg: any) => {
if (msg.type !== 'run') return
const torrents: any[] = msg.torrents
const seriesData: any[] = msg.seriesData ?? []
const organized = loadOrganized()
const { nfoSupport } = readSettings()
const organized = loadOrganized()
const { nfoSupport } = readSettings()
for (const t of torrents) {
if (t.state !== 'seeding') continue
const found = findTorrentByHash(t.hash, seriesData)
if (!found) {
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé dans les données série`)
debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
continue
}
// Vérifier si déjà tout organisé
const { torrent, serieData } = found
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
const allDone = fileMap.size > 0
? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
: !!organized[t.hash]?.[t.name]
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
const orgHash = organized[t.hash] ?? {}
const allDone = fileMap.size > 0
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
: Object.keys(orgHash).length > 0
if (allDone) continue
if (allDone) {
debug(`"${t.name}" déjà entièrement importé — skip`)
continue
}
try {
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
} catch (err) {
error(`[organize] Erreur ${t.name}: ${err instanceof Error ? err.message : err}`)
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
}
}
+184 -68
View File
@@ -4,8 +4,8 @@
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
*/
import fs from 'fs'
import path from 'path'
import fs from 'fs'
import path from 'path'
import { Worker } from 'worker_threads'
import { logger } from './logger.js'
import { DATA_DIR } from './config.js'
@@ -13,10 +13,7 @@ import { readSettings } from './settings.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// Un seul worker à la fois
let workerRunning = false
// État précédent des torrents pour détecter les passages downloading → seeding
export let workerRunning = false
const _prevStates = new Map<string, string>()
function resolveWorkerPath(): string {
@@ -33,10 +30,26 @@ export interface OrganizeResult {
errors : { file: string; error: string }[]
}
/**
* Scan initial du mediaPath au démarrage.
* Construit l'index filename → infohash depuis les seriesData passées en paramètre.
*/
interface OrgEntry {
at : string
season : number
episode : number
episode_id : number
src_filename : string
dest_filename: string
dest_path : string
}
type Organized = Record<string, Record<string, OrgEntry>>
// ── Scan initial ──────────────────────────────────────────────
function swapExt(filename: string, ext: string): string {
const cur = path.extname(filename)
if (!cur || cur === ext) return filename
return filename.slice(0, -cur.length) + ext
}
export async function scanMediaPath(
mediaPath : string,
organizedPath: string,
@@ -45,37 +58,72 @@ export async function scanMediaPath(
const result = { found: 0, added: 0 }
if (!fs.existsSync(mediaPath)) {
logger.warn('organize', `scanMediaPath: ${mediaPath} inexistant, skip`)
logger.warn('organize', `Dossier médiathèque introuvable : ${mediaPath}`)
return result
}
// Construire index filename → infohash depuis les seriesData
const filenameIndex = new Map<string, string>() // filename → infohash
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
// Map : nom_sans_extension → { hash, srcFilename, episodeId, season, episode }
// On indexe par nom sans extension pour matcher quelle que soit l'extension sur le disque
const filenameIndex = new Map<string, {
hash : string
srcFilename : string
episodeId : number
season : number
episode : number
destFilename: string
}>()
for (const sd of seriesData) {
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
// Helper : indexer par nom sans extension
const idx = (filename: string, hash: string, destFilename?: string) => {
const base = filename.replace(/\.[^.]+$/, '')
if (!base) return
filenameIndex.set(base, {
hash,
srcFilename : filename,
episodeId : ep.id,
season : season.season_number,
episode : ep.episode_number,
destFilename: destFilename ?? filename,
})
}
// 1. Noms depuis les paths (avec vrai infohash)
for (const p of ep.paths ?? []) {
if (typeof p === 'string') {
// Legacy : pas d'infohash disponible, on skip pour le scan
continue
}
const hash = p.infohash?.toLowerCase()
if (!hash || !p.path) continue
const filename = p.path.replace(/\\/g, '/').split('/').pop()
if (filename) filenameIndex.set(filename, hash)
if (typeof p !== 'object' || !p.infohash || !p.path) continue
const hash = p.infohash.toLowerCase()
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
if (!srcFilename) continue
idx(srcFilename, hash)
if (ep.nfo_filename) idx(ep.nfo_filename, hash, ep.nfo_filename)
}
// 2. original_filename, nfo_filename et formatted_name — indexés même si paths est vide
const fallbackHash = ep.paths?.[0]?.infohash?.toLowerCase() ?? 'manual'
if (ep.original_filename) idx(ep.original_filename, fallbackHash, ep.original_filename)
if (ep.nfo_filename) idx(ep.nfo_filename, fallbackHash, ep.nfo_filename)
if (ep.formatted_name?.trim()) {
const fmtBase = ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim()
idx(fmtBase, fallbackHash, fmtBase)
}
}
}
}
// Charger organized.json existant
let organized: Record<string, Record<string, string>> = {}
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch {}
// Track les paires hash:episodeId présentes
const presentFiles = new Set<string>()
let noMatch = 0
function walk(dir: string) {
let entries: fs.Dirent[]
@@ -85,27 +133,67 @@ export async function scanMediaPath(
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full)
} else if (entry.isFile() && entry.name.endsWith('.mkv')) {
} else if (entry.isFile() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) {
result.found++
const hash = filenameIndex.get(entry.name)
if (!hash) continue
presentFiles.add(`${hash}:${entry.name}`)
if (organized[hash]?.[entry.name]) continue
const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '')
let match = filenameIndex.get(nameWithoutExt)
// Fallback : chercher si un candidat de l'index est un préfixe du nom du fichier
// ex: index a "Wind Breaker.S01E01.MULTI.1080p" et le fichier est "Wind Breaker.S01E01.MULTI.1080p.x264-FANKAI"
if (!match) {
const lower = nameWithoutExt.toLowerCase()
for (const [key, val] of filenameIndex.entries()) {
if (lower.startsWith(key.toLowerCase() + '.') || lower === key.toLowerCase()) {
match = val
break
}
}
}
if (!match) {
noMatch++
const prefix = nameWithoutExt.slice(0, 20).toLowerCase()
const close = [...filenameIndex.keys()].filter(k => k.toLowerCase().startsWith(prefix)).slice(0, 3)
const closeStr = close.length > 0 ? ` | candidats : ${close.map(c => `"${c}"`).join(', ')}` : ' | aucun candidat proche'
logger.warn('organize', `Scan no match : "${nameWithoutExt}"${closeStr}`)
continue
}
const { hash, srcFilename, episodeId, season, episode } = match
// Le destFilename prend l'extension réelle du fichier sur le disque
const realExt = path.extname(entry.name)
const destBase = match.destFilename.replace(/\.[^.]+$/, '')
const destFilename = destBase + realExt
presentFiles.add(`${hash}:${episodeId}`)
if (organized[hash]?.[String(episodeId)]) continue
if (!organized[hash]) organized[hash] = {}
organized[hash][entry.name] = new Date().toISOString()
organized[hash][String(episodeId)] = {
at : new Date().toISOString(),
season,
episode,
episode_id : episodeId,
src_filename : srcFilename,
dest_filename: destFilename,
dest_path : full,
}
result.added++
logger.debug('organize', `Scan match : "${entry.name}" → ep ${episodeId} (S${season}E${episode})`)
}
}
}
walk(mediaPath)
// Nettoyer les entrées orphelines
// Supprimer uniquement les entrées connues dans l'index et absentes du disque
const allEpisodeIds = new Set([...filenameIndex.values()].map(v => `${v.hash}:${v.episodeId}`))
let removed = 0
for (const [hash, files] of Object.entries(organized)) {
for (const filename of Object.keys(files)) {
if (!presentFiles.has(`${hash}:${filename}`) && filenameIndex.get(filename) === hash) {
delete organized[hash][filename]
for (const [hash, episodes] of Object.entries(organized)) {
for (const episodeId of Object.keys(episodes)) {
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
delete organized[hash][episodeId]
removed++
}
}
@@ -114,28 +202,28 @@ export async function scanMediaPath(
if (result.added > 0 || removed > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
logger.info('organize', `scanMediaPath: ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`)
} else {
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
}
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
return result
}
/**
* Auto-import — appelée depuis index.ts avec les seriesData du cache GitHub.
*/
// ── Auto-import ───────────────────────────────────────────────
export async function autoOrganizeAll(
listFn : () => Promise<any[]>,
seriesData : any[],
onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
): Promise<void> {
// Vérifier si l'import auto est activé
const { autoImport } = readSettings()
if (!autoImport) return
if (!autoImport) {
logger.debug('organize', 'Import automatique désactivé — skip')
return
}
if (workerRunning) {
console.log('[organize] Worker déjà en cours, skip')
logger.debug('organize', 'Worker déjà en cours — skip')
return
}
@@ -143,7 +231,7 @@ export async function autoOrganizeAll(
try {
torrents = await listFn()
} catch (err) {
console.error('[organize] Impossible de récupérer la liste des torrents:', err)
logger.error('organize', `Impossible de récupérer la liste des torrents : ${err instanceof Error ? err.message : err}`)
return
}
@@ -159,12 +247,10 @@ export async function autoOrganizeAll(
for (const t of torrents) _prevStates.set(t.hash, t.state)
// Charger organized.json pour vérifier s'il reste des torrents non organisés
let organized: Record<string, Record<string, string>> = {}
let organized: Organized = {}
try {
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
if (fs.existsSync(ORGANIZED_PATH))
organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch {}
const hasUnorganized = torrents.some(t =>
@@ -175,21 +261,31 @@ export async function autoOrganizeAll(
if (seedingCount === 0) return
if (newlySeeding.length > 0 && !isFirstRun) {
console.log(`[organize] ${newlySeeding.length} nouveau(x) torrent(s) terminé(s) → lancement worker`)
logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) → lancement de l'import automatique`)
for (const t of newlySeeding) {
logger.debug('organize', `Nouveau seeding : "${t.name}"`)
}
} else {
console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`)
logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
}
workerRunning = true
const worker = new Worker(resolveWorkerPath())
workerRunning = true
const worker = new Worker(resolveWorkerPath())
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
if (msg.level === 'error') console.error(msg.msg)
else if (msg.level === 'warn') console.warn(msg.msg)
else console.log(msg.msg)
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'result') {
console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
if (msg.errors.length > 0) {
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) {
logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
}
} else if (msg.done > 0) {
logger.info('organize', `"${msg.name}" — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
} else {
logger.debug('organize', `"${msg.name}" — rien à importer (${msg.skipped} skippé(s))`)
}
onResult?.({
hash : msg.hash,
name : msg.name,
@@ -199,27 +295,35 @@ export async function autoOrganizeAll(
errorFiles: msg.errors,
})
} else if (msg.type === 'done') {
console.log('[organize] Worker terminé')
logger.debug('organize', 'Worker terminé')
workerRunning = false
worker.terminate()
}
})
worker.on('error', (err) => { console.error('[organize] Worker erreur:', err); workerRunning = false })
worker.on('exit', (code) => { if (code !== 0) console.error(`[organize] Worker exit ${code}`); workerRunning = false })
worker.on('error', (err) => {
logger.error('organize', `Worker erreur : ${err.message}`)
workerRunning = false
})
worker.on('exit', (code) => {
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
else logger.debug('organize', `Worker terminé (code ${code})`)
workerRunning = false
})
worker.postMessage({ type: 'run', torrents, seriesData })
}
/**
* Organisation manuelle d'un torrent.
*/
// ── Import manuel ─────────────────────────────────────────────
export async function organizeTorrent(
hash : string,
name : string,
savePath : string,
seriesData: any[]
): Promise<OrganizeResult> {
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
return new Promise((resolve, reject) => {
const worker = new Worker(resolveWorkerPath())
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
@@ -227,22 +331,34 @@ export async function organizeTorrent(
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
if (msg.level === 'error') console.error(msg.msg)
else if (msg.level === 'warn') console.warn(msg.msg)
else console.log(msg.msg)
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'result') {
result.total = msg.total
result.skipped = msg.skipped
result.done = msg.done
result.errors = msg.errors
if (msg.errors.length > 0) {
logger.warn('organize', `Import "${name}" — ${msg.done} OK, ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) {
logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
}
} else {
logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
}
} else if (msg.type === 'done') {
worker.terminate()
resolve(result)
}
})
worker.on('error', (err) => { worker.terminate(); reject(err) })
worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) })
worker.on('error', (err) => {
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err.message}`)
worker.terminate()
reject(err)
})
worker.on('exit', (code) => {
if (code > 1) reject(new Error(`Worker exit inattendu: code ${code}`))
})
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
})
+16 -14
View File
@@ -5,23 +5,25 @@ import { DATA_DIR } from './config.js'
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
export interface Settings {
mediaPath : string
completePath : string
organizeMode : 'hardlink' | 'move'
category : string
nfoSupport : boolean
autoImport : boolean
usePlexTitles : boolean // utilise title_for_plex pour les noms de dossiers
mediaPath : string
completePath : string
organizeMode : 'hardlink' | 'copy' | 'move' // ← ajouter 'copy'
category : string
nfoSupport : boolean
autoImport : boolean
devMode : boolean
deleteTorrentOnMove : boolean // ← ajouter
}
const defaults: Settings = {
mediaPath : '',
completePath : '',
organizeMode : 'hardlink',
category : 'fankai',
nfoSupport : false,
autoImport : true,
usePlexTitles: false,
mediaPath : '',
completePath: '',
organizeMode: 'hardlink',
category : 'fankai',
nfoSupport : false,
autoImport : true,
devMode : false,
deleteTorrentOnMove: false,
}
export function readSettings(): Settings {
+15
View File
@@ -0,0 +1,15 @@
import fs from 'fs'
import os from 'os'
import type { Request, Response } from 'express'
// GET /api/system/info
export function systemInfo(_req: Request, res: Response): void {
const isTermux = fs.existsSync('/data/data/com.termux')
const platform = os.platform()
res.json({
platform,
isTermux,
defaultPath: isTermux ? '/sdcard/' : '/',
})
}
+182 -35
View File
@@ -2,14 +2,13 @@
* Torrent Client Registry
* =======================
* Interface commune + registry de tous les clients disponibles.
* Chaque client expose son schema de configuration (FieldDef[])
* pour que le frontend génère le formulaire dynamiquement.
*/
import { randomUUID } from 'crypto'
import fs from 'fs'
import path from 'path'
import { DATA_DIR } from '../config.js'
import { logger } from '../logger.js'
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -22,42 +21,42 @@ export interface FieldDef {
default? : string | number
}
/** Définition statique d'un type de client (ce qu'il expose au front) */
export interface TorrentClientDefinition {
id : string // "qbittorrent", "transmission", etc.
label : string // "qBittorrent"
fields : FieldDef[] // schema du formulaire
id : string
label : string
fields : FieldDef[]
}
/** Instance sauvegardée d'un client configuré par l'utilisateur */
export interface SavedClient {
uuid : string // identifiant unique de cette instance
name : string // nom custom donné par l'utilisateur
type : string // id du TorrentClientDefinition
uuid : string
name : string
type : string
config: Record<string, string | number>
}
/** État normalisé d'un torrent (commun à tous les clients) */
export interface TorrentInfo {
hash : string
name : string
state : 'downloading' | 'seeding' | 'paused' | 'checking' | 'error' | 'unknown'
progress : number // 0-100
size : number // bytes
downloaded: number // bytes
speed : number // bytes/s
eta : number // secondes, -1 si inconnu
save_path : string // dossier de complétion
progress : number
size : number
downloaded: number
uploaded : number
ratio : number
speed : number
upspeed : number
eta : number
save_path : string
category : string
}
/** Interface que chaque client doit implémenter */
export interface TorrentClientDriver {
definition : TorrentClientDefinition
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
add : (config: Record<string, string | number>, url: string) => Promise<void>
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
}
// ─── Registry ─────────────────────────────────────────────────────────────────
@@ -66,6 +65,7 @@ const drivers = new Map<string, TorrentClientDriver>()
export function registerDriver(driver: TorrentClientDriver) {
drivers.set(driver.definition.id, driver)
logger.debug('torrent-clients', `Driver enregistré : ${driver.definition.label}`)
}
export function getDriver(type: string): TorrentClientDriver | undefined {
@@ -76,7 +76,7 @@ export function getAvailableClients(): TorrentClientDefinition[] {
return [...drivers.values()].map(d => d.definition)
}
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
// ─── Persistence ──────────────────────────────────────────────────────────────
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
@@ -103,14 +103,17 @@ export function addClient(name: string, type: string, config: Record<string, str
const client: SavedClient = { uuid: randomUUID(), name, type, config }
clients.push(client)
saveClients(clients)
logger.info('torrent-clients', `Client ajouté : "${name}" (${type})`)
return client
}
export function removeClient(uuid: string): boolean {
const clients = loadClients()
const clients = loadClients()
const target = clients.find(c => c.uuid === uuid)
const filtered = clients.filter(c => c.uuid !== uuid)
if (filtered.length === clients.length) return false
saveClients(filtered)
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
return true
}
@@ -118,7 +121,16 @@ export function getClient(uuid: string): SavedClient | undefined {
return loadClients().find(c => c.uuid === uuid)
}
/** Masque les champs password pour l'envoi au front */
export function updateClient(uuid: string, name: string, type: string, config: Record<string, string | number>): SavedClient | null {
const clients = loadClients()
const index = clients.findIndex(c => c.uuid === uuid)
if (index === -1) return null
clients[index] = { ...clients[index], name, type, config }
saveClients(clients)
logger.info('torrent-clients', `Client modifié : "${name}" (${type})`)
return clients[index]
}
export function sanitizeClient(client: SavedClient, definition?: TorrentClientDefinition): SavedClient {
if (!definition) definition = getDriver(client.type)?.definition
if (!definition) return client
@@ -131,52 +143,187 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
return { ...client, config }
}
// ─── Dispatch download vers tous les clients ──────────────────────────────────
// ─── Backoff par client ───────────────────────────────────────────────────────
// Si un client échoue à l'auth, on le met en cooldown pour éviter le ban IP
interface BackoffEntry {
failCount : number
nextRetry : number // timestamp ms
}
const _backoff = new Map<string, BackoffEntry>()
// Délais progressifs : 1min, 5min, 15min, 30min, 60min (plafonné)
const BACKOFF_DELAYS = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
function isInCooldown(uuid: string): boolean {
const entry = _backoff.get(uuid)
if (!entry) return false
return Date.now() < entry.nextRetry
}
function cooldownRemaining(uuid: string): string {
const entry = _backoff.get(uuid)
if (!entry) return ''
const remaining = Math.max(0, entry.nextRetry - Date.now())
const m = Math.ceil(remaining / 60_000)
return m > 0 ? `${m}min` : ''
}
function registerFailure(uuid: string, clientName: string) {
const entry = _backoff.get(uuid) ?? { failCount: 0, nextRetry: 0 }
entry.failCount = Math.min(entry.failCount + 1, BACKOFF_DELAYS.length - 1) + (entry.failCount === 0 ? 0 : 0)
const delay = BACKOFF_DELAYS[Math.min(entry.failCount - 1, BACKOFF_DELAYS.length - 1)]
entry.nextRetry = Date.now() + delay
_backoff.set(uuid, entry)
const min = Math.round(delay / 60_000)
logger.warn('torrent-clients', `Client "${clientName}" en cooldown ${min}min après ${entry.failCount} échec(s)`)
}
function registerSuccess(uuid: string) {
_backoff.delete(uuid)
}
// Exposé pour l'API de status
export function getClientCooldowns(): Record<string, { failCount: number; nextRetry: number }> {
const result: Record<string, { failCount: number; nextRetry: number }> = {}
for (const [uuid, entry] of _backoff.entries()) {
if (Date.now() < entry.nextRetry) result[uuid] = entry
}
return result
}
// ─── Remote path remapping ────────────────────────────────────────────────────
// FIX : remplace le préfixe remotePath par localPath dans save_path
// ex: remotePath=/downloads, localPath=/mnt/nas/downloads
// /downloads/fankai/Serie → /mnt/nas/downloads/fankai/Serie
function remapSavePath(savePath: string, remotePath: string, localPath: string): string {
const remote = remotePath.replace(/\/+$/, '')
const local = localPath.replace(/\/+$/, '')
if (!remote || !local) return savePath
if (!savePath.startsWith(remote)) return savePath
return local + savePath.slice(remote.length)
}
// ─── Dispatch download ─────────────────────────────────────────────────────────
export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
const clients = loadClients()
if (clients.length === 0) {
logger.warn('torrent-clients', 'Aucun client configuré — téléchargement impossible')
return []
}
const results = []
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver) {
logger.error('torrent-clients', `Driver "${client.type}" introuvable pour le client "${client.name}"`)
results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Driver "${client.type}" introuvable` })
continue
}
try {
await driver.add(client.config, url)
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
results.push({ uuid: client.uuid, name: client.name, ok: true })
} catch (err) {
results.push({
uuid : client.uuid,
name : client.name,
ok : false,
error: err instanceof Error ? err.message : 'Erreur inconnue'
})
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('torrent-clients', `Échec envoi vers "${client.name}" : ${msg}`)
results.push({ uuid: client.uuid, name: client.name, ok: false, error: msg })
}
}
return results
}
// ─── Dispatch list vers tous les clients ─────────────────────────────────────
// ─── Dispatch remove ───────────────────────────────────────────────────────────
export async function dispatchList(category?: string): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
export async function dispatchRemove(hash: string, deleteFiles = false): Promise<{ ok: boolean; error?: string }> {
const clients = loadClients()
const results: (TorrentInfo & { client_uuid: string; client_name: string })[] = []
let found = false
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver) continue
try {
// La catégorie du client est prioritaire sur la catégorie globale
await driver.remove(client.config, hash, deleteFiles)
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
found = true
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('torrent-clients', `Impossible de supprimer le torrent de "${client.name}" : ${msg}`)
}
}
return found ? { ok: true } : { ok: false, error: 'Torrent introuvable dans les clients' }
}
// Map inversée : titre normalisé → infohash (pour matcher les torrents Synology)
function buildTitleIndex(infohashMap: Record<string, string>): Map<string, string> {
const index = new Map<string, string>()
for (const [hash, title] of Object.entries(infohashMap)) {
index.set(title.toLowerCase().trim(), hash.toLowerCase())
}
return index
}
export async function dispatchList(
category? : string,
infohashMap? : Record<string, string>,
): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
const clients = loadClients()
const results: (TorrentInfo & { client_uuid: string; client_name: string })[] = []
const titleIndex = infohashMap ? buildTitleIndex(infohashMap) : null
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver) continue
if (isInCooldown(client.uuid)) {
const remaining = cooldownRemaining(client.uuid)
logger.debug('torrent-clients', `Client "${client.name}" en cooldown — retry dans ${remaining}`)
continue
}
try {
const clientCategory = (client.config.category as string) || category
const torrents = await driver.list(client.config, clientCategory)
registerSuccess(client.uuid)
const remotePath = String(client.config.remotePath ?? '')
const localPath = String(client.config.localPath ?? '')
const hasRemap = remotePath && localPath
for (const t of torrents) {
results.push({ ...t, client_uuid: client.uuid, client_name: client.name })
const save_path = hasRemap
? remapSavePath(t.save_path, remotePath, localPath)
: t.save_path
// Pour Synology (hash = dbid_xxx), on essaie de résoudre le vrai infohash
// en matchant le titre du torrent dans la infohashMap
let hash = t.hash
if (titleIndex && t.hash.startsWith('dbid_')) {
const resolved = titleIndex.get(t.name.toLowerCase().trim())
if (resolved) {
logger.debug('torrent-clients', `Synology hash résolu : "${t.name}" → ${resolved.slice(0, 8)}…`)
hash = resolved
}
}
results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
}
} catch (err) {
console.error(`[torrent-clients] list() échoué pour ${client.name}:`, err)
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('torrent-clients', `Impossible de récupérer la liste depuis "${client.name}" : ${msg}`)
const isAuthError = msg.toLowerCase().includes('login') ||
msg.toLowerCase().includes('auth') ||
msg.toLowerCase().includes('ban') ||
msg.toLowerCase().includes('forbidden') ||
msg.toLowerCase().includes('401') ||
msg.toLowerCase().includes('403')
if (isAuthError) registerFailure(client.uuid, client.name)
}
}
+41 -14
View File
@@ -3,8 +3,8 @@
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import { logger } from '../logger.js'
// Mapping état qBittorrent → état normalisé
function mapState(state: string): TorrentInfo['state'] {
if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading'
if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding'
@@ -29,7 +29,7 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
const cookie = res.headers.get('set-cookie') ?? ''
const sid = cookie.match(/SID=([^;]+)/)?.[1]
const sid = cookie.match(/SID=([^;]+)/)?.[1]
if (!sid) throw new Error('Cookie SID introuvable')
return sid
}
@@ -39,19 +39,25 @@ const QB: TorrentClientDriver = {
id : 'qbittorrent',
label : 'qBittorrent',
fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
],
},
async test(config) {
try {
await qbLogin(config)
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : 'Erreur inconnue' }
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('qbittorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -59,23 +65,26 @@ const QB: TorrentClientDriver = {
try {
const sid = await qbLogin(config)
const res = await fetch(`${config.url}/api/v2/app/version`, {
headers: { Cookie: `SID=${sid}` }
headers: { Cookie: `SID=${sid}` },
})
if (!res.ok) return { online: false }
return { online: true, version: (await res.text()).trim() }
} catch {
const version = (await res.text()).trim()
logger.debug('qbittorrent', `Healthcheck OK — version ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('qbittorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const sid = await qbLogin(config)
const sid = await qbLogin(config)
const params = new URLSearchParams()
if (category) params.set('category', category)
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
headers: { Cookie: `SID=${sid}` }
headers: { Cookie: `SID=${sid}` },
})
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()
return data.map(t => ({
hash : t.hash,
@@ -84,7 +93,10 @@ const QB: TorrentClientDriver = {
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 ?? '',
@@ -92,7 +104,7 @@ const QB: TorrentClientDriver = {
},
async add(config, url) {
const sid = await qbLogin(config)
const sid = await qbLogin(config)
const form = new FormData()
form.append('urls', url)
if (config.category) form.append('category', String(config.category))
@@ -104,6 +116,21 @@ const QB: TorrentClientDriver = {
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
logger.info('qbittorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
const sid = await qbLogin(config)
const form = new FormData()
form.append('hashes', hash)
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
method : 'POST',
body : form,
headers: { Cookie: `SID=${sid}` },
})
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
+253
View File
@@ -0,0 +1,253 @@
/**
* rTorrent Driver
* ===============
* Communique via XML-RPC (HTTP POST).
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import { logger } from '../logger.js'
// ─── XML-RPC helpers ──────────────────────────────────────────
function xmlValue(val: any): string {
if (typeof val === 'string') return `<value><string>${val}</string></value>`
if (typeof val === 'number') return `<value><i8>${val}</i8></value>`
if (typeof val === 'boolean') return `<value><boolean>${val ? 1 : 0}</boolean></value>`
if (Array.isArray(val)) return `<value><array><data>${val.map(xmlValue).join('')}</data></array></value>`
return `<value><string>${String(val)}</string></value>`
}
function xmlCall(method: string, params: any[] = []): string {
return `<?xml version="1.0"?>
<methodCall>
<methodName>${method}</methodName>
<params>${params.map(p => `<param>${xmlValue(p)}</param>`).join('')}</params>
</methodCall>`
}
function parseXmlValue(node: Element): any {
const child = node.firstElementChild
if (!child) return node.textContent ?? ''
switch (child.tagName) {
case 'string' : return child.textContent ?? ''
case 'int' :
case 'i4' :
case 'i8' : return parseInt(child.textContent ?? '0', 10)
case 'double' : return parseFloat(child.textContent ?? '0')
case 'boolean' : return child.textContent === '1'
case 'array' : {
const data = child.querySelector('data')
if (!data) return []
return Array.from(data.children).map(parseXmlValue)
}
case 'struct' : {
const result: Record<string, any> = {}
for (const member of Array.from(child.querySelectorAll(':scope > member'))) {
const name = member.querySelector(':scope > name')?.textContent ?? ''
const value = member.querySelector(':scope > value')
if (name && value) result[name] = parseXmlValue(value)
}
return result
}
default: return child.textContent ?? ''
}
}
async function rpcCall(
config : Record<string, string | number>,
method : string,
params : any[] = [],
): Promise<any> {
const body = xmlCall(method, params)
const headers : Record<string, string> = { 'Content-Type': 'text/xml' }
if (config.username && config.password) {
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
}
const rpcPath = String(config.rpcPath || '/RPC2')
const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}`
const res = await fetch(url, { method: 'POST', headers, body })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const text = await res.text()
// Parse XML — Node.js n'a pas DOMParser natif, on fait un parse minimaliste
const faultMatch = text.match(/<name>faultString<\/name>\s*<value><string>([^<]*)<\/string>/)
if (faultMatch) throw new Error(`rTorrent fault : ${faultMatch[1]}`)
const valueMatch = text.match(/<methodResponse>\s*<params>\s*<param>\s*<value>([\s\S]*?)<\/value>\s*<\/param>/)
if (!valueMatch) return null
// Parse simplifié pour les types courants
const inner = valueMatch[1].trim()
// Array
if (inner.startsWith('<array>')) {
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
return items.map(m => parseInnerValue(m[1].trim()))
}
return parseInnerValue(inner)
}
function parseInnerValue(inner: string): any {
if (inner.startsWith('<array>')) {
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
return items.map(m => parseInnerValue(m[1].trim()))
}
const strMatch = inner.match(/^<string>([\s\S]*?)<\/string>$/)
if (strMatch) return strMatch[1]
const intMatch = inner.match(/^<(?:i4|i8|int)>([\s\S]*?)<\/(?:i4|i8|int)>$/)
if (intMatch) return parseInt(intMatch[1], 10)
// Valeur brute sans tag
if (!inner.includes('<')) return inner
return inner
}
// multicall pour récupérer plusieurs champs en une requête
async function d_multicall(
config : Record<string, string | number>,
view : string,
methods: string[],
): Promise<any[][]> {
const params = [view, ...methods.map(m => `${m}=`)]
const result = await rpcCall(config, 'd.multicall2', ['', ...params])
return Array.isArray(result) ? result : []
}
// ─── State mapping ────────────────────────────────────────────
// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1)
// + is_open (0/1)
function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] {
if (isChecking) return 'checking'
if (!isOpen) return 'paused'
if (!isActive) return 'paused'
if (isComplete) return 'seeding'
return 'downloading'
}
const RT: TorrentClientDriver = {
definition: {
id : 'rtorrent',
label : 'rTorrent',
fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'rpcPath', label: 'Chemin RPC', type: 'text', placeholder: '/RPC2', required: false, default: '/RPC2' },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie (label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
],
},
async test(config) {
try {
const version = await rpcCall(config, 'system.client_version')
logger.info('rtorrent', `Test de connexion réussi sur ${config.url} — version ${version}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('rtorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const version = await rpcCall(config, 'system.client_version')
logger.debug('rtorrent', `Healthcheck OK — version ${version}`)
return { online: true, version: String(version) }
} catch (err) {
logger.debug('rtorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const methods = [
'd.hash',
'd.name',
'd.is_open',
'd.is_active',
'd.is_hash_checking',
'd.complete',
'd.size_bytes',
'd.bytes_done',
'd.up.total', // total uploadé
'd.ratio', // ratio x1000
'd.down.rate',
'd.up.rate',
'd.left_bytes',
'd.directory',
'd.custom1',
]
const rows = await d_multicall(config, 'main', methods)
return rows
.map(r => {
const [hash, name, isOpen, isActive, isChecking, isComplete,
size, downloaded, uploadedTotal, ratioRaw,
dlSpeed, ulSpeed, leftBytes, directory, label] = r
const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0
const eta = Number(dlSpeed) > 0 && Number(leftBytes) > 0 ? Math.round(Number(leftBytes) / Number(dlSpeed)) : -1
return {
hash : String(hash).toLowerCase(),
name : String(name),
state : mapState(Number(isOpen), Number(isActive), Number(isChecking), Number(isComplete)),
progress,
size : Number(size),
downloaded: Number(downloaded),
uploaded : Number(uploadedTotal ?? 0),
ratio : Math.round((Number(ratioRaw ?? 0) / 1000) * 100) / 100,
speed : Number(dlSpeed),
upspeed : Number(ulSpeed ?? 0),
eta,
save_path : String(directory),
category : String(label ?? ''),
} satisfies TorrentInfo
})
.filter(t => {
if (!category) return true
return t.category === category
})
},
async add(config, url) {
// load_start charge et démarre le torrent
const method = url.startsWith('magnet:') ? 'load.start' : 'load.start'
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, method, args)
logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
},
async remove(config, hash, deleteFiles = false) {
const h = hash.toUpperCase()
if (deleteFiles) {
// Supprimer les fichiers puis effacer
await rpcCall(config, 'd.delete_tied', [h])
await rpcCall(config, 'd.erase', [h])
} else {
await rpcCall(config, 'd.erase', [h])
}
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
export default RT
+177
View File
@@ -0,0 +1,177 @@
/**
* Synology Download Station Driver
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import { logger } from '../logger.js'
// Synology Download Station task status codes
function mapState(status: string): TorrentInfo['state'] {
if (status === 'downloading') return 'downloading'
if (status === 'seeding' || status === 'finished') return 'seeding'
if (status === 'paused') return 'paused'
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
if (status === 'hash_checking') return 'checking'
if (status === 'error') return 'error'
return 'unknown'
}
// Authentification DSM — retourne un SID
async function dsLogin(config: Record<string, string | number>): Promise<string> {
const params = new URLSearchParams({
api : 'SYNO.API.Auth',
version: '3',
method : 'login',
account: String(config.username ?? ''),
passwd : String(config.password ?? ''),
session: 'DownloadStation',
format : 'sid',
})
const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`Auth échouée : ${JSON.stringify(data.error)}`)
return data.data.sid
}
async function dsRequest(
config : Record<string, string | number>,
api : string,
method : string,
version: string,
extra : Record<string, string> = {},
sid : string,
): Promise<any> {
const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra })
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`)
return data.data
}
const DS: TorrentClientDriver = {
definition: {
id : 'synology-ds',
label : 'Synology Download Station',
fields: [
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
],
},
async test(config) {
try {
await dsLogin(config)
logger.info('synology-ds', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('synology-ds', `Test de connexion échoué sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const sid = await dsLogin(config)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Info',
version: '2',
method : 'getinfo',
_sid : sid,
})
const res = await fetch(`${config.url}/webapi/DownloadStation/info.cgi?${params}`)
if (!res.ok) return { online: false }
const data = await res.json()
if (!data.success) return { online: false }
const version = data.data?.version_string ?? 'inconnue'
logger.debug('synology-ds', `Healthcheck OK — version ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('synology-ds', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
{ additional: 'transfer,detail' }, sid)
const tasks: any[] = data?.tasks ?? []
return tasks
.map(t => {
const transfer = t.additional?.transfer ?? {}
const size = t.size ?? 0
const dl = transfer.size_downloaded ?? 0
return {
hash : t.id,
name : t.title,
state : mapState(t.status),
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
size,
downloaded: dl,
uploaded : transfer.size_uploaded ?? 0,
ratio : size > 0 ? Math.round(((transfer.size_uploaded ?? 0) / size) * 100) / 100 : 0,
speed : transfer.speed_download ?? 0,
upspeed : transfer.speed_upload ?? 0,
eta : -1,
save_path : t.additional?.detail?.destination ?? '',
category : category ?? '',
} satisfies TorrentInfo
})
},
async add(config, url) {
const sid = await dsLogin(config)
const extra: Record<string, string> = { uri: url }
if (config.savePath) extra.destination = String(config.savePath)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'create',
_sid : sid,
...extra,
})
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
},
async remove(config, hash, deleteFiles = false) {
const sid = await dsLogin(config)
// Synology utilise son ID interne — on cherche via la liste
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid)
const tasks: any[] = data?.tasks ?? []
// Matcher par hash si résolu, sinon par id
const found = tasks.find(t => t.id?.toLowerCase() === hash.toLowerCase())
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'delete',
id : found.id,
force_complete: deleteFiles ? 'false' : 'false',
_sid : sid,
})
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
logger.info('synology-ds', `Torrent ${found.id} supprimé`)
},
}
export default DS
+151
View File
@@ -0,0 +1,151 @@
/**
* Transmission Driver
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import { logger } from '../logger.js'
function mapState(status: number): TorrentInfo['state'] {
// Transmission status codes :
// 0 = stopped, 1 = check queued, 2 = checking, 3 = download queued,
// 4 = downloading, 5 = seed queued, 6 = seeding
if (status === 4 || status === 3) return 'downloading'
if (status === 6 || status === 5) return 'seeding'
if (status === 0) return 'paused'
if (status === 1 || status === 2) return 'checking'
return 'unknown'
}
async function trRequest(
config: Record<string, string | number>,
method: string,
args: Record<string, unknown> = {},
sessionId = ''
): Promise<{ result: string; arguments?: any; sessionId?: string }> {
const headers: Record<string, string> = {
'Content-Type' : 'application/json',
'X-Transmission-Session-Id': sessionId,
}
if (config.username && config.password) {
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
}
const res = await fetch(`${config.url}/transmission/rpc`, {
method : 'POST',
headers,
body : JSON.stringify({ method, arguments: args }),
})
// Transmission renvoie 409 avec le session ID à la première requête
if (res.status === 409) {
const newSessionId = res.headers.get('X-Transmission-Session-Id') ?? ''
if (!newSessionId) throw new Error('Session ID introuvable dans la réponse 409')
return trRequest(config, method, args, newSessionId)
}
if (!res.ok) throw new Error(`Transmission HTTP ${res.status}`)
const data = await res.json()
if (data.result !== 'success') throw new Error(`Transmission erreur : ${data.result}`)
return { ...data, sessionId }
}
const TR: TorrentClientDriver = {
definition: {
id : 'transmission',
label : 'Transmission',
fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'transmission', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
],
},
async test(config) {
try {
await trRequest(config, 'session-get')
logger.info('transmission', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('transmission', `Test de connexion échoué sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const data = await trRequest(config, 'session-get')
const version = data.arguments?.version ?? 'inconnue'
logger.debug('transmission', `Healthcheck OK — version ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('transmission', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const fields = [
'hashString', 'name', 'status', 'percentDone', 'totalSize',
'downloadedEver', 'uploadedEver', 'uploadRatio',
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
]
const data = await trRequest(config, 'torrent-get', { fields })
const torrents: any[] = data.arguments?.torrents ?? []
return torrents
.filter(t => {
if (!category) return true
return t.labels?.includes(category)
})
.map(t => ({
hash : t.hashString,
name : t.name,
state : mapState(t.status),
progress : Math.round(t.percentDone * 100),
size : t.totalSize,
downloaded: t.downloadedEver,
uploaded : t.uploadedEver ?? 0,
ratio : Math.round((t.uploadRatio ?? 0) * 100) / 100,
speed : t.rateDownload,
upspeed : t.rateUpload ?? 0,
eta : t.eta ?? -1,
save_path : t.downloadDir,
category : t.labels?.[0] ?? '',
} satisfies TorrentInfo))
},
async add(config, url) {
const args: Record<string, unknown> = { filename: url }
if (config.savePath) {
args['download-dir'] = String(config.savePath)
}
if (config.category) {
args['labels'] = [String(config.category)]
}
await trRequest(config, 'torrent-add', args)
logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
// Transmission identifie les torrents par ID numérique — on cherche via la liste
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
const torrents: any[] = data.arguments?.torrents ?? []
const found = torrents.find(t => t.hashString?.toLowerCase() === hash.toLowerCase())
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
await trRequest(config, 'torrent-remove', { ids: [found.id], 'delete-local-data': deleteFiles })
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
export default TR
+208
View File
@@ -0,0 +1,208 @@
/**
* uTorrent Driver
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import { logger } from '../logger.js'
// uTorrent status flags (bitmask) :
// 1 = Started, 2 = Checking, 4 = Start after check, 8 = Checked,
// 16 = Error, 32 = Paused, 64 = Queued, 128 = Loaded
function mapState(status: number): TorrentInfo['state'] {
if (status & 16) return 'error'
if (status & 32) return 'paused'
if (status & 2) return 'checking'
// Démarré + chargé + vérifié → on regarde le ratio de progression
if (status & 1 && status & 8) return 'downloading'
if (status & 64) return 'downloading' // queued
return 'unknown'
}
// uTorrent nécessite un token CSRF + cookie à chaque session
interface UTSession { token: string; cookie: string }
async function utGetSession(config: Record<string, string | number>): Promise<UTSession> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const headers : Record<string, string> = {
'Authorization': `Basic ${auth}`,
}
const res = await fetch(`${config.url}/gui/token.html`, { headers })
if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`)
const text = await res.text()
const match = text.match(/<div[^>]+id=['"]token['"][^>]*>([^<]+)</)
if (!match) throw new Error('Token CSRF introuvable dans la réponse')
const token = match[1].trim()
const cookie = res.headers.get('set-cookie') ?? ''
return { token, cookie }
}
async function utRequest(
config : Record<string, string | number>,
params : Record<string, string>,
session: UTSession,
): Promise<any> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const qs = new URLSearchParams({ token: session.token, ...params })
const res = await fetch(`${config.url}/gui/?${qs}`, {
headers: {
'Authorization': `Basic ${auth}`,
'Cookie' : session.cookie,
},
})
if (!res.ok) throw new Error(`uTorrent HTTP ${res.status}`)
return res.json()
}
function mapTorrent(t: any[]): TorrentInfo {
// uTorrent retourne un tableau par torrent :
// [hash, status, name, size, progress, downloaded, uploaded, ratio,
// ul_speed, dl_speed, eta, label, peers_connected, peers_swarm,
// seeds_connected, seeds_swarm, availability, queue_order, remaining]
const [hash, status, name, size, progress, downloaded, uploaded, ratio, ulSpeed, dlSpeed, eta, label] = t
return {
hash : hash.toLowerCase(),
name,
state : progress >= 1000 ? 'seeding' : mapState(status),
progress : Math.min(100, Math.round(progress / 10)),
size,
downloaded,
uploaded : uploaded ?? 0,
ratio : Math.round(((ratio ?? 0) / 1000) * 100) / 100, // uTorrent ratio est x1000
speed : dlSpeed ?? 0,
upspeed : ulSpeed ?? 0,
eta : eta ?? -1,
save_path : '',
category : label ?? '',
}
}
const UT: TorrentClientDriver = {
definition: {
id : 'utorrent',
label : 'uTorrent',
fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
],
},
async test(config) {
try {
await utGetSession(config)
logger.info('utorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('utorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getsettings' }, session)
const version = data?.['build']?.toString() ?? 'inconnue'
logger.debug('utorrent', `Healthcheck OK — build ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('utorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const session = await utGetSession(config)
const data = await utRequest(config, { list: '1' }, session)
const torrents: any[][] = data?.torrents ?? []
return torrents
.filter(t => {
if (!category) return true
return t[11] === category // index 11 = label
})
.map(t => {
const info = mapTorrent(t)
// Récupérer le save_path depuis les properties si disponible
const props = data?.props?.find((p: any) => p[0]?.toLowerCase() === info.hash)
if (props) info.save_path = props[1] ?? ''
return info
})
},
async add(config, url) {
const session = await utGetSession(config)
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const form = new FormData()
form.append('torrent_url', url)
const qs = new URLSearchParams({
token : session.token,
action: 'add-url',
s : url,
})
if (config.savePath) {
qs.set('download_dir', '1')
qs.set('path', String(config.savePath))
}
const res = await fetch(`${config.url}/gui/?${qs}`, {
method : 'GET',
headers: {
'Authorization': `Basic ${auth}`,
'Cookie' : session.cookie,
},
})
if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`)
// Appliquer le label/catégorie si défini
if (config.category) {
// On ne peut pas set le label au moment de l'ajout, il faut attendre
// que le torrent soit chargé — on le fait via setprops après un délai
setTimeout(async () => {
try {
const s2 = await utGetSession(config)
const data = await utRequest(config, { list: '1' }, s2)
const torrents: any[][] = data?.torrents ?? []
// Trouver le torrent le plus récemment ajouté sans label
const target = torrents.find(t => !t[11])
if (target) {
await utRequest(config, {
action: 'setprops',
hash : target[0],
s : 'label',
v : String(config.category),
}, s2)
}
} catch {}
}, 3000)
}
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
const s = await utGetSession(config)
await utRequest(config, {
action: deleteFiles ? 'removedata' : 'remove',
hash : hash.toUpperCase(),
}, s)
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
export default UT
+2 -8
View File
@@ -1,12 +1,6 @@
<template>
<div class="min-h-screen bg-[#080c10] text-[#c8d8e8] font-mono">
<RouterView v-slot="{ Component }">
<keep-alive include="CatalogView">
<component :is="Component" />
</keep-alive>
</RouterView>
<ToastContainer />
</div>
<RouterView />
<ToastContainer />
</template>
<script setup lang="ts">
+149 -1
View File
@@ -1 +1,149 @@
@import "tailwindcss";
@import "tailwindcss";
/* ─── Tokens custom consommables via Tailwind v4 ─── */
@theme {
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-dim: var(--accent-dim);
--color-accent-muted: var(--accent-muted);
--color-shell: var(--bg-shell);
--color-sidebar: var(--bg-sidebar);
--color-main: var(--bg-main);
--color-card: var(--bg-card);
--color-hover: var(--bg-hover);
--color-active: var(--bg-active);
--color-border: var(--border);
--color-border-light: var(--border-light);
--color-primary: var(--text-primary);
--color-secondary: var(--text-secondary);
--color-muted: var(--text-muted);
--color-gold: var(--gold);
}
/* ─── Thème Indigo Électrique (défaut) ─── */
:root,
[data-theme="indigo"] {
--accent: #7B7EF8;
--accent-hover: #9496fa;
--accent-dim: #1c1c38;
--accent-muted: rgba(123, 126, 248, 0.15);
--bg-shell: #111116;
--bg-sidebar: #0d0d14;
--bg-main: #16161e;
--bg-card: #111116;
--bg-hover: #1c1c28;
--bg-active: #22223a;
--border: #2a2a3a;
--border-light: #1e1e2e;
--text-primary: #e8e8f8;
--text-secondary: #9898b8;
--text-muted: #55556a;
--gold: #f5c842;
}
/* ─── Thème Crimson Night ─── */
[data-theme="crimson"] {
--accent: #E74C3C;
--accent-hover: #f06050;
--accent-dim: #2a0e0e;
--accent-muted: rgba(231, 76, 60, 0.15);
--bg-shell: #110e0e;
--bg-sidebar: #0d0a0a;
--bg-main: #161010;
--bg-card: #110e0e;
--bg-hover: #1e1414;
--bg-active: #2a1616;
--border: #2e2020;
--border-light: #201616;
--text-primary: #f0e8e8;
--text-secondary: #b89898;
--text-muted: #6a5555;
--gold: #f5c842;
}
/* ─── Thème Teal Froid ─── */
[data-theme="teal"] {
--accent: #00CDBF;
--accent-hover: #1de8da;
--accent-dim: #001e1c;
--accent-muted: rgba(0, 205, 191, 0.15);
--bg-shell: #0a0f0f;
--bg-sidebar: #060c0c;
--bg-main: #0e1616;
--bg-card: #0a0f0f;
--bg-hover: #101e1e;
--bg-active: #142828;
--border: #1a2e2e;
--border-light: #121e1e;
--text-primary: #e0f0f0;
--text-secondary: #88b0b0;
--text-muted: #4a6868;
--gold: #f5c842;
}
/* ─── Base globale ─── */
*, *::before, *::after { box-sizing: border-box; }
html, body, #app {
height: 100%;
margin: 0;
padding: 0;
}
body {
background: var(--bg-main);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ─── Composants réutilisables ─── */
@layer components {
.btn-primary {
@apply bg-accent text-white px-4 py-2 text-sm rounded-lg
hover:bg-accent-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer;
}
.btn-secondary {
@apply border border-border text-secondary px-4 py-2 text-sm rounded-lg
hover:border-accent hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer;
}
.btn-ghost {
@apply text-muted px-2 py-1 text-sm rounded-md
hover:text-primary hover:bg-hover transition-colors cursor-pointer;
}
.settings-card {
@apply bg-card border border-border rounded-xl p-4;
}
.settings-label {
@apply text-xs text-muted block;
}
.settings-input {
@apply w-full bg-shell border border-border text-primary text-sm px-3 py-2.5 rounded-lg
outline-none focus:border-accent transition-colors;
}
}
+82 -70
View File
@@ -1,74 +1,82 @@
<template>
<!-- Overlay -->
<div class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')">
<div class="bg-[#0d1219] border border-[#1e2d3d] w-full max-w-lg flex flex-col" style="max-height: 70vh">
<Teleport to="body">
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')">
<div class="bg-card border border-border rounded-xl w-full max-w-lg flex flex-col" style="max-height: 70vh">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-[#1e2d3d] shrink-0">
<div>
<div class="text-[10px] text-[#e8513a] tracking-widest mb-0.5">CHOISIR UN DOSSIER</div>
<div class="text-xs text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</div>
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
<div>
<p class="text-xs text-accent font-medium mb-0.5">Choisir un dossier</p>
<p class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</p>
</div>
<button @click="$emit('cancel')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
</div>
<button @click="$emit('cancel')" class="text-[#5a7a94] hover:text-white transition-colors cursor-pointer text-lg leading-none">✕</button>
</div>
<!-- Chemin éditable -->
<div class="px-5 py-3 border-b border-[#1e2d3d] shrink-0 flex gap-2">
<input
v-model="inputPath"
@keydown.enter="navigateTo(inputPath)"
class="flex-1 bg-[#121920] border border-[#1e2d3d] text-xs text-white px-3 py-2 font-mono outline-none focus:border-[#e8513a] transition-colors"
placeholder="/"
/>
<button @click="navigateTo(inputPath)"
class="border border-[#1e2d3d] text-[#5a7a94] px-3 py-2 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
OK
</button>
</div>
<!-- Navigation retour -->
<div v-if="parent !== null" class="px-5 shrink-0">
<button @click="navigateTo(parent)"
class="flex items-center gap-2 w-full py-2.5 text-xs text-[#5a7a94] hover:text-white transition-colors cursor-pointer border-b border-[#1e2d3d]">
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
..
</button>
</div>
<!-- Liste dossiers -->
<div class="overflow-y-auto flex-1">
<div v-if="loading" class="flex items-center justify-center py-10 text-[#5a7a94] text-xs">
<div class="w-4 h-4 border border-white/10 border-t-[#e8513a] rounded-full animate-spin mr-2"/>
Chargement...
<!-- Chemin éditable -->
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" title="Retour à la racine">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</button>
<input
v-model="inputPath"
@keydown.enter="navigateTo(inputPath)"
class="settings-input flex-1 font-mono text-xs"
placeholder="/"
/>
<button @click="navigateTo(inputPath)" class="btn-secondary px-3 text-xs">
OK
</button>
</div>
<div v-else-if="error" class="px-5 py-4 text-xs text-red-400">{{ error }}</div>
<div v-else-if="dirs.length === 0" class="px-5 py-4 text-xs text-[#5a7a94]">Aucun sous-dossier</div>
<button
v-for="dir in dirs"
:key="dir"
@click="navigateTo(joinPath(current, dir))"
class="flex items-center gap-3 w-full px-5 py-2.5 text-xs text-[#c8d8e8] hover:bg-white/5 transition-colors cursor-pointer border-b border-[#1e2d3d]/50 text-left"
>
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
</svg>
{{ dir }}
</button>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-5 py-4 border-t border-[#1e2d3d] shrink-0">
<span class="text-[10px] text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</span>
<button @click="$emit('select', current)"
class="bg-[#e8513a] text-white px-5 py-2 text-xs tracking-widest hover:opacity-85 transition-opacity cursor-pointer">
CHOISIR
</button>
</div>
<!-- Retour parent -->
<div v-if="parent !== null" class="px-5 shrink-0">
<button
@click="navigateTo(parent)"
class="flex items-center gap-2 w-full py-2.5 text-xs text-muted hover:text-primary transition-colors border-b border-border"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
..
</button>
</div>
<!-- Liste dossiers -->
<div class="overflow-y-auto flex-1">
<div v-if="loading" class="flex items-center justify-center py-10 text-muted text-xs gap-2">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
</div>
<div v-else-if="error" class="px-5 py-4 text-xs text-red-400">{{ error }}</div>
<div v-else-if="dirs.length === 0" class="px-5 py-4 text-xs text-muted">Aucun sous-dossier</div>
<button
v-for="dir in dirs"
:key="dir"
@click="navigateTo(joinPath(current, dir))"
class="flex items-center gap-3 w-full px-5 py-2.5 text-xs text-primary hover:bg-hover transition-colors border-b border-border/50 text-left"
>
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" fill="none">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
</svg>
{{ dir }}
</button>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-5 py-4 border-t border-border shrink-0">
<span class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</span>
<!-- FIX : regex sortie dans selectCurrent() pour éviter l'erreur de parsing Vue -->
<button @click="selectCurrent()" class="btn-primary">
Choisir
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
@@ -84,8 +92,15 @@ const parent = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
function selectCurrent() {
emit('select', current.value.replace(/\/+$/, '') || '/')
}
function joinPath(base: string, name: string): string {
return base === '/' ? `/${name}` : `${base}/${name}`
if (base === '/') return `/${name}`
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base
return `${cleanBase}/${name}`
}
async function navigateTo(p: string) {
@@ -95,11 +110,10 @@ async function navigateTo(p: string) {
try {
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
if (!res.ok) {
const data = await res.json()
error.value = data.error ?? 'Erreur'
error.value = (await res.json()).error ?? 'Erreur'
return
}
const data = await res.json()
const data = await res.json()
current.value = data.path
inputPath.value = data.path
parent.value = data.parent
@@ -111,7 +125,5 @@ async function navigateTo(p: string) {
}
}
watch(() => props.initialPath, v => {
if (v) navigateTo(v)
}, { immediate: true })
watch(() => props.initialPath, v => { if (v) navigateTo(v) }, { immediate: true })
</script>
+336
View File
@@ -0,0 +1,336 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="$emit('close')">
<div class="bg-card border border-border rounded-xl w-full max-w-3xl flex flex-col" style="max-height: 85vh">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div>
<h3 class="text-sm font-semibold text-primary">Import manuel — {{ serieName }}</h3>
<p class="text-xs text-muted font-mono mt-0.5 truncate">{{ serieFolder }}</p>
</div>
<button @click="$emit('close')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
</div>
<!-- Loading -->
<div v-if="scanning" class="flex flex-col items-center justify-center gap-3 py-16 text-muted">
<div class="w-5 h-5 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-sm">Scan du dossier…</p>
</div>
<!-- Dossier vide / introuvable -->
<div v-else-if="!scanning && items.length === 0 && !scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
<p class="text-sm text-muted">Aucun fichier vidéo trouvé dans ce dossier.</p>
<p class="text-xs text-muted">Placez vos fichiers dans <span class="font-mono text-primary">{{ serieFolder }}</span> puis réessayez.</p>
<button @click="scan" class="btn-secondary mt-2">Rescanner</button>
</div>
<!-- Erreur -->
<div v-else-if="scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
<p class="text-sm text-red-400">{{ scanError }}</p>
<button @click="scan" class="btn-secondary">Réessayer</button>
</div>
<!-- Liste fichiers -->
<template v-else-if="items.length > 0">
<!-- Toolbar -->
<div class="flex items-center justify-between px-6 py-3 border-b border-border shrink-0 gap-3">
<p class="text-xs text-muted">
<span class="text-primary font-medium">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }}
<span v-if="alreadyImportedCount > 0"> · <span class="text-blue-400">{{ alreadyImportedCount }} déjà importé{{ alreadyImportedCount > 1 ? 's' : '' }}</span></span>
· <span class="text-green-400">{{ newMatchedCount }} nouveau{{ newMatchedCount > 1 ? 'x' : '' }}</span>
<span v-if="unmatchedCount > 0"> · <span class="text-yellow-500">{{ unmatchedCount }} non associé{{ unmatchedCount > 1 ? 's' : '' }}</span></span>
</p>
<button @click="scan" class="text-xs text-muted hover:text-primary transition-colors">↺ Rescanner</button>
</div>
<!-- Fichiers -->
<div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2">
<div
v-for="(item, i) in items"
:key="item.file.path"
class="flex items-center gap-3 p-3 rounded-lg border transition-colors"
:class="item.alreadyImported
? 'border-blue-500/20 bg-blue-500/5'
: item.episode_id
? 'border-green-500/20 bg-green-500/5'
: 'border-border bg-shell'"
>
<!-- Icône -->
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0"
:class="item.alreadyImported ? 'bg-blue-500/15 text-blue-400' :
item.episode_id ? 'bg-green-500/15 text-green-400' : 'bg-border text-muted'">
<svg v-if="item.alreadyImported || item.episode_id" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2" fill="none"><line x1="6" y1="2" x2="6" y2="7"/><circle cx="6" cy="9.5" r="0.5" fill="currentColor"/></svg>
</div>
<!-- Nom + taille -->
<div class="flex-1 min-w-0">
<p class="text-xs text-primary font-mono truncate">{{ item.file.name }}</p>
<p class="text-[11px] text-muted mt-0.5">
{{ formatSize(item.file.size) }}
<span v-if="item.alreadyImported" class="text-blue-400 ml-1">— déjà importé</span>
<span v-else-if="item.willRename" class="text-accent ml-1">— sera renommé</span>
</p>
</div>
<!-- Sélecteur épisode -->
<select
v-model="items[i].episode_id"
class="settings-input text-xs py-1 max-w-[260px] shrink-0"
@change="onEpisodeChange(i)"
>
<option :value="null">— Non associé —</option>
<optgroup v-for="season in seasons" :key="season.id" :label="season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}`">
<option
v-for="ep in season.episodes"
:key="ep.id"
:value="ep.id"
:disabled="isEpisodeAlreadyUsed(ep.id, i)"
>
E{{ String(ep.episode_number).padStart(2, '0') }} — {{ ep.title || `Épisode ${ep.episode_number}` }}
{{ isEpisodeOrganized(ep.id) ? '✓' : '' }}
</option>
</optgroup>
</select>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-6 py-4 border-t border-border shrink-0">
<p v-if="importError" class="text-xs text-red-400">{{ importError }}</p>
<p v-else class="text-xs text-muted">
Les fichiers dans ce dossier seront <span class="text-accent">renommés sur place</span>.
</p>
<div class="flex gap-2">
<button @click="$emit('close')" class="btn-secondary">Annuler</button>
<button
@click="doImport"
:disabled="newMatchedCount === 0 || importing"
class="btn-primary"
>
{{ importing ? 'Import…' : `Importer ${newMatchedCount} fichier${newMatchedCount > 1 ? 's' : ''}` }}
</button>
</div>
</div>
</template>
<!-- Résultat -->
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center">
<div class="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 24 24" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<div>
<p class="text-sm font-semibold text-primary">Import terminé</p>
<p class="text-xs text-muted mt-1">
{{ importResult.done }} fichier{{ importResult.done > 1 ? 's' : '' }} importé{{ importResult.done > 1 ? 's' : '' }}
<span v-if="importResult.errors.length > 0" class="text-red-400">
· {{ importResult.errors.length }} erreur{{ importResult.errors.length > 1 ? 's' : '' }}
</span>
</p>
</div>
<div v-if="importResult.errors.length > 0" class="w-full text-left bg-red-500/5 border border-red-500/20 rounded-lg p-3">
<p class="text-xs text-red-400 font-medium mb-1.5">Erreurs</p>
<div v-for="e in importResult.errors" :key="e.file" class="text-xs text-muted mb-1">
<span class="text-primary font-mono">{{ e.file }}</span> — {{ e.error }}
</div>
</div>
<button @click="$emit('close'); $emit('imported')" class="btn-primary">Fermer</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const props = defineProps<{
serieId : number
serieName : string
seasons : any[]
organized : Record<string, any>
initialPath: string
}>()
const emit = defineEmits<{
close : []
imported: []
}>()
// ─── State ────────────────────────────────────────────────────
const step = ref<'list' | 'done'>('list')
const scanning = ref(false)
const importing = ref(false)
const importError = ref('')
const scanError = ref('')
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
// Dossier de la série dans la médiathèque
const serieTitle = computed(() =>
props.serieName.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
)
const serieFolder = computed(() => `${props.initialPath}/${serieTitle.value}`)
interface FileItem {
file : { name: string; path: string; size: number }
episode_id : number | null
hash : string | null
alreadyImported: boolean
willRename : boolean
}
const items = ref<FileItem[]>([])
// ─── Computed ──────────────────────────────────────────────────
const alreadyImportedCount = computed(() => items.value.filter(i => i.alreadyImported).length)
const newMatchedCount = computed(() => items.value.filter(i => i.episode_id !== null && !i.alreadyImported).length)
const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
// ─── Helpers ──────────────────────────────────────────────────
function isEpisodeOrganized(epId: number): boolean {
return !!props.organized?.[String(epId)]
}
function isEpisodeAlreadyUsed(epId: number, currentIndex: number): boolean {
return items.value.some((item, i) => i !== currentIndex && item.episode_id === epId)
}
function getHashForEpisode(episodeId: number): string | null {
for (const season of props.seasons) {
for (const ep of season.episodes) {
if (ep.id !== episodeId) continue
for (const p of ep.paths ?? []) {
if (typeof p === 'object' && p.infohash) return p.infohash.toLowerCase()
}
}
}
return null
}
function buildOrganizedByPath(): Map<string, number> {
const index = new Map<string, number>()
for (const [epIdStr, entry] of Object.entries(props.organized)) {
if (entry?.dest_path) index.set(entry.dest_path, Number(epIdStr))
}
return index
}
function autoMatch(filename: string): { episode_id: number | null; hash: string | null } {
const nameNoExt = filename.replace(/\.[^.]+$/, '').toLowerCase()
for (const season of props.seasons) {
for (const ep of season.episodes) {
const candidates = [
ep.original_filename,
ep.nfo_filename?.replace(/\.nfo$/, ''),
ep.formatted_name?.replace(/[<>:"/\\|?*]/g, '').trim(),
].filter(Boolean).map((s: string) => s.toLowerCase().replace(/\.[^.]+$/, ''))
if (candidates.some(c => c === nameNoExt || nameNoExt.startsWith(c + '.'))) {
return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
}
}
}
// Fallback numéro d'épisode
const numMatch = filename.match(/[.\s_-]0*(\d{1,3})[.\s_-]/i)
if (numMatch) {
const num = parseInt(numMatch[1], 10)
for (const season of props.seasons) {
const ep = season.episodes.find((e: any) => e.episode_number === num)
if (ep) return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
}
}
return { episode_id: null, hash: null }
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
function onEpisodeChange(i: number) {
items.value[i].hash = getHashForEpisode(Number(items.value[i].episode_id))
items.value[i].alreadyImported = false
}
// ─── Actions ──────────────────────────────────────────────────
async function scan() {
scanning.value = true
scanError.value = ''
items.value = []
try {
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(serieFolder.value)}`, { credentials: 'include' })
if (!res.ok) {
const data = await res.json()
scanError.value = data.error ?? 'Erreur lors du scan'
return
}
const data = await res.json()
const files: { name: string; path: string; size: number }[] = data.files ?? []
const organizedByPath = buildOrganizedByPath()
items.value = files.map(f => {
// Vérifier si déjà importé via dest_path
const existingEpId = organizedByPath.get(f.path) ?? null
if (existingEpId) {
return { file: f, episode_id: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
}
const match = autoMatch(f.name)
return { file: f, ...match, alreadyImported: false, willRename: match.episode_id !== null }
})
} catch {
scanError.value = 'Impossible de contacter le serveur'
} finally {
scanning.value = false
}
}
async function doImport() {
importing.value = true
importError.value = ''
const payload = items.value
.filter(i => i.episode_id !== null && !i.alreadyImported)
.map(i => ({ file_path: i.file.path, episode_id: i.episode_id, hash: i.hash }))
if (payload.length === 0) {
importError.value = 'Aucun nouveau fichier à importer'
importing.value = false
return
}
try {
const res = await fetch('/api/manual-import', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ serie_id: props.serieId, items: payload }),
})
const data = await res.json()
if (!res.ok) { importError.value = data.error ?? 'Erreur inconnue'; return }
importResult.value = { done: data.done, errors: data.errors ?? [] }
step.value = 'done'
} catch {
importError.value = 'Impossible de contacter le serveur'
} finally {
importing.value = false
}
}
onMounted(scan)
</script>
+32 -12
View File
@@ -1,22 +1,42 @@
<template>
<Teleport to="body">
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2">
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
<TransitionGroup name="toast">
<div
v-for="t in toasts"
:key="t.id"
@click="remove(t.id)"
class="flex items-center gap-3 px-4 py-3 rounded-xl border text-sm cursor-pointer min-w-64 max-w-sm"
:class="{
'border-[#3ae87a] text-[#3ae87a]': t.type === 'success',
'border-[#e8513a] text-[#e8513a]': t.type === 'error',
'border-[#3a8fe8] text-[#3a8fe8]': t.type === 'info',
'bg-card border-green-500/30 text-green-400' : t.type === 'success',
'bg-card border-red-500/30 text-red-400' : t.type === 'error',
'bg-card border-accent/30 text-accent' : t.type === 'info',
}"
class="bg-[#0d1219] border px-4 py-3 text-xs tracking-wider cursor-pointer flex items-center gap-3 min-w-64"
>
<span v-if="t.type === 'success'">✓</span>
<span v-else-if="t.type === 'error'">✕</span>
<span v-else>·</span>
{{ t.message }}
<svg v-if="t.type === 'success'" width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
<polyline points="2 7 5.5 10.5 12 3"/>
</svg>
<svg v-else-if="t.type === 'error'" width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
<line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
<circle cx="7" cy="7" r="5"/><line x1="7" y1="5" x2="7" y2="7.5"/><circle cx="7" cy="9.5" r="0.5" fill="currentColor"/>
</svg>
<span class="flex-1 text-xs leading-snug">{{ t.message }}</span>
<!-- Badge count si groupé -->
<span
v-if="t.count && t.count > 1"
class="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full"
:class="{
'bg-green-500/20 text-green-400' : t.type === 'success',
'bg-red-500/20 text-red-400' : t.type === 'error',
'bg-accent/20 text-accent' : t.type === 'info',
}"
>
×{{ t.count }}
</span>
</div>
</TransitionGroup>
</div>
@@ -30,7 +50,7 @@ const { toasts, remove } = useToast()
<style scoped>
.toast-enter-active { transition: all 0.2s ease; }
.toast-leave-active { transition: all 0.2s ease; }
.toast-enter-from { opacity: 0; transform: translateX(20px); }
.toast-leave-to { opacity: 0; transform: translateX(20px); }
.toast-leave-active { transition: all 0.15s ease; }
.toast-enter-from { opacity: 0; transform: translateY(8px) scale(0.97); }
.toast-leave-to { opacity: 0; transform: translateY(4px) scale(0.97); }
</style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<aside class="flex flex-col h-full bg-sidebar border-r border-border">
<!-- Logo + logout -->
<div class="flex items-center gap-2.5 px-4 py-3.5 border-b border-border min-h-[56px]">
<svg class="w-7 h-7 shrink-0" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
</svg>
<RouterLink to="/series" class="text-[15px] font-semibold text-primary tracking-wide flex-1 hover:text-accent transition-colors">
FanKarr
</RouterLink>
<button
@click="logout"
title="Se déconnecter"
class="text-muted hover:text-red-400 transition-colors p-1 rounded-md hover:bg-hover"
>
<LogOut :size="15" />
</button>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 flex flex-col gap-0.5">
<template v-for="(item, i) in items" :key="i">
<hr v-if="item.separator" class="border-border my-2 mx-1" />
<SidebarNavItem v-else :item="item" />
</template>
</nav>
<!-- Nouvelle version disponible -->
<a
v-if="updateAvailable"
:href="latestReleaseUrl"
target="_blank"
rel="noopener"
class="mx-2 mb-2 flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-accent/30 bg-accent/5 hover:bg-accent/10 transition-colors group"
>
<div class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-xs font-medium text-accent">Mise à jour disponible</p>
<p class="text-[11px] text-muted truncate">{{ latestVersion }} est disponible</p>
</div>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
class="shrink-0 text-muted group-hover:text-accent transition-colors">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
<!-- Version actuelle -->
<div class="px-3 pb-1 pt-0.5">
<p class="text-[10px] text-muted/50 font-mono">{{ currentVersion }}</p>
</div>
<!-- Thème -->
<ThemeSwitcher />
</aside>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { LogOut } from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
import SidebarNavItem from './SidebarNavItem.vue'
import ThemeSwitcher from './ThemeSwitcher.vue'
import type { NavItem } from '@/types/nav'
defineProps<{ items: NavItem[] }>()
const router = useRouter()
const auth = useAuthStore()
const currentVersion = ref('dev')
const latestVersion = ref('')
const latestReleaseUrl = ref('https://github.com/masutayunikon/FanKarr/releases/latest')
const updateAvailable = ref(false)
const GITHUB_REPO = 'masutayunikon/FanKarr'
function parseVersion(v: string): number[] {
return v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
}
function isNewer(latest: string, current: string): boolean {
if (current === 'dev') return false
const l = parseVersion(latest)
const c = parseVersion(current)
for (let i = 0; i < Math.max(l.length, c.length); i++) {
const lv = l[i] ?? 0
const cv = c[i] ?? 0
if (lv > cv) return true
if (lv < cv) return false
}
return false
}
async function checkForUpdates() {
try {
// Version locale
const vRes = await fetch('/api/version', { credentials: 'include' })
if (vRes.ok) {
const { version } = await vRes.json()
currentVersion.value = version
}
if (currentVersion.value === 'dev') return
// Dernière release GitHub
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
headers: { Accept: 'application/vnd.github.v3+json' },
})
if (!gRes.ok) return
const release = await gRes.json()
latestVersion.value = release.tag_name ?? ''
latestReleaseUrl.value = release.html_url ?? latestReleaseUrl.value
if (isNewer(latestVersion.value, currentVersion.value)) {
updateAvailable.value = true
}
} catch {}
}
async function logout() {
await auth.logout()
router.push('/auth')
}
onMounted(() => {
checkForUpdates()
// Revérifier toutes les 6h
setInterval(checkForUpdates, 6 * 60 * 60 * 1000)
})
</script>
+102
View File
@@ -0,0 +1,102 @@
<template>
<div>
<component
:is="item.to ? RouterLink : 'button'"
:to="item.to ?? undefined"
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors duration-100 text-left group"
:class="isActive
? 'bg-active text-primary'
: 'text-secondary hover:bg-hover hover:text-primary'"
@click="item.children && !item.to && toggleOpen()"
>
<component
:is="iconComponent"
:size="17"
class="shrink-0 transition-colors"
:class="isActive ? 'text-accent' : 'text-muted group-hover:text-secondary'"
/>
<span class="flex-1 text-sm truncate">{{ item.label }}</span>
<!-- Badge animé (ex: downloads actifs) -->
<span
v-if="item.badge"
class="min-w-[18px] h-[18px] px-1 rounded-full bg-accent text-white text-[10px] font-medium flex items-center justify-center"
>
{{ item.badge }}
</span>
<!-- Chevron (seulement si pas de badge) -->
<ChevronRight
v-else-if="item.children"
:size="13"
class="shrink-0 text-muted transition-transform duration-200"
:class="isOpen ? 'rotate-90' : ''"
/>
</component>
<Transition name="slide">
<div v-if="item.children && isOpen" class="ml-[34px] mt-0.5 flex flex-col gap-0.5">
<RouterLink
v-for="child in item.children"
:key="child.to"
:to="child.to"
class="flex items-center gap-2 px-2.5 py-1.5 rounded-md transition-colors duration-100"
:class="route.path === child.to
? 'text-accent'
: 'text-muted hover:text-secondary'"
>
<span
class="w-1 h-1 rounded-full shrink-0 transition-colors"
:class="route.path === child.to ? 'bg-accent' : 'bg-current opacity-40'"
/>
<span class="text-xs">{{ child.label }}</span>
</RouterLink>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { ChevronRight } from 'lucide-vue-next'
import * as Icons from 'lucide-vue-next'
import type { NavItem } from '@/types/nav'
const props = defineProps<{ item: NavItem }>()
const route = useRoute()
const iconComponent = computed(() => {
if (!props.item.icon) return Icons.Circle
return (Icons as Record<string, unknown>)[props.item.icon] ?? Icons.Circle
})
const isActive = computed(() =>
props.item.to
? route.path.startsWith(props.item.to)
: props.item.children?.some(c => route.path.startsWith(c.to))
)
const isOpen = ref(isActive.value)
watch(isActive, (val) => { if (val) isOpen.value = true })
function toggleOpen() {
isOpen.value = !isOpen.value
}
</script>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: max-height 0.2s ease, opacity 0.15s ease;
max-height: 300px;
overflow: hidden;
}
.slide-enter-from,
.slide-leave-to {
max-height: 0;
opacity: 0;
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<template>
<div class="px-3 py-3 border-t border-border">
<p class="text-xs text-muted mb-2 uppercase tracking-widest">Thème</p>
<div class="flex gap-2">
<button
v-for="theme in themes"
:key="theme.id"
:title="theme.label"
@click="setTheme(theme.id)"
class="w-6 h-6 rounded-full border-2 transition-all duration-150 flex items-center justify-center"
:style="{ backgroundColor: theme.color }"
:class="current === theme.id
? 'border-primary scale-110'
: 'border-transparent opacity-50 hover:opacity-80'"
>
<span v-if="current === theme.id" class="block w-1.5 h-1.5 rounded-full bg-white/80" />
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const STORAGE_KEY = 'fankarr-theme'
const themes = [
{ id: 'indigo', label: 'Indigo Électrique', color: '#7B7EF8' },
{ id: 'crimson', label: 'Crimson Night', color: '#E74C3C' },
{ id: 'teal', label: 'Teal Froid', color: '#00CDBF' },
]
const current = ref('indigo')
function setTheme(id) {
current.value = id
document.documentElement.setAttribute('data-theme', id)
localStorage.setItem(STORAGE_KEY, id)
}
onMounted(() => {
const saved = localStorage.getItem(STORAGE_KEY) ?? 'indigo'
setTheme(saved)
})
</script>
@@ -0,0 +1,23 @@
<template>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm text-primary">{{ label }}</p>
<p class="text-xs text-muted mt-0.5">{{ description }}</p>
</div>
<button
@click="$emit('update:modelValue', !modelValue)"
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200 mt-0.5"
:class="modelValue ? 'bg-accent' : 'bg-border'"
>
<span
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="modelValue ? 'translate-x-[18px]' : 'translate-x-0'"
/>
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{ modelValue: boolean; label: string; description?: string }>()
defineEmits(['update:modelValue'])
</script>
+26
View File
@@ -0,0 +1,26 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'fankarr-poster-size'
const sizes = {
S : '120px',
M : '150px',
L : '180px',
XL : '220px',
} as const
export type PosterSize = keyof typeof sizes
const current = ref<PosterSize>(
(localStorage.getItem(STORAGE_KEY) as PosterSize) ?? 'M'
)
watch(current, (val) => localStorage.setItem(STORAGE_KEY, val))
export function usePosterSize() {
return {
current,
sizes,
minmax: () => `minmax(${sizes[current.value]}, 1fr)`,
}
}
+43 -14
View File
@@ -1,25 +1,54 @@
import { ref } from 'vue'
interface Toast {
id: number
message: string
type: 'success' | 'error' | 'info'
id : number
message : string
type : 'success' | 'error' | 'info'
count : number
timer : ReturnType<typeof setTimeout>
}
const toasts = ref<Toast[]>([])
let nextId = 0
const DURATION = 3500
const GROUP_WINDOW = 800 // ms — toasts du même type dans cette fenêtre sont groupés
function add(message: string, type: Toast['type'] = 'info') {
const now = Date.now()
// Cherche un toast récent du même type à grouper
const existing = toasts.value.find(t => t.type === type)
if (existing) {
// Grouper — incrémenter le count et mettre à jour le message
clearTimeout(existing.timer)
existing.count++
existing.message = buildGroupMessage(type, existing.count)
existing.timer = setTimeout(() => remove(existing.id), DURATION)
return
}
// Nouveau toast
const id = nextId++
const timer = setTimeout(() => remove(id), DURATION)
toasts.value.push({ id, message, type, count: 1, timer })
}
function buildGroupMessage(type: Toast['type'], count: number): string {
if (type === 'success') return `${count} opérations réussies`
if (type === 'error') return `${count} erreurs`
return `${count} notifications`
}
function remove(id: number) {
const i = toasts.value.findIndex(t => t.id === id)
if (i !== -1) {
clearTimeout(toasts.value[i].timer)
toasts.value.splice(i, 1)
}
}
export function useToast() {
function add(message: string, type: Toast['type'] = 'info', duration = 3000) {
const id = nextId++
toasts.value.push({ id, message, type })
setTimeout(() => remove(id), duration)
}
function remove(id: number) {
const idx = toasts.value.findIndex(t => t.id === id)
if (idx !== -1) toasts.value.splice(idx, 1)
}
return { toasts, add, remove }
}
+88
View File
@@ -0,0 +1,88 @@
<template>
<div class="flex h-screen bg-shell overflow-hidden">
<div class="hidden md:flex w-56 shrink-0">
<SidebarNav :items="navItems" class="w-full" />
</div>
<Transition name="fade">
<div v-if="mobileOpen" class="fixed inset-0 bg-black/60 z-40 md:hidden" @click="mobileOpen = false" />
</Transition>
<Transition name="slide-left">
<div v-if="mobileOpen" class="fixed top-0 left-0 h-full w-56 z-50 md:hidden">
<SidebarNav :items="navItems" class="w-full" />
</div>
</Transition>
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-sidebar shrink-0">
<button @click="mobileOpen = true" class="text-muted hover:text-primary transition-colors">
<Menu :size="20" />
</button>
<span class="text-sm font-medium text-primary">FanKarr</span>
</header>
<main id="main-scroll" class="flex-1 overflow-y-auto bg-main">
<RouterView v-slot="{ Component }">
<keep-alive include="SeriesView">
<component :is="Component" class="h-full" />
</keep-alive>
</RouterView>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Menu } from 'lucide-vue-next'
import SidebarNav from '@/components/nav/SidebarNav.vue'
import { useDownloadsStore } from '@/stores/downloads'
import type { NavItem } from '@/types/nav'
const mobileOpen = ref(false)
const dlStore = useDownloadsStore()
const navItems = computed<NavItem[]>(() => [
{
label: 'Dashboard',
icon : 'LayoutDashboard',
to : '/dashboard',
},
{
label : 'Médiathèque',
icon : 'Tv',
to : '/series',
children: [
{ label: 'Séries', to: '/series' },
],
},
{
label: 'Activité',
icon : 'Activity',
to : '/activity',
badge: dlStore.activeCount > 0 ? dlStore.activeCount : undefined,
},
{ separator: true },
{
label : 'Paramètres',
icon : 'Settings',
to : '/settings',
children: [
{ label: 'Clients de téléchargement', to: '/settings/download-client' },
{ label: 'Gestion des médias', to: '/settings/media-management' },
{ label: 'Catalogue Fankai', to: '/settings/catalogue' },
{ label: 'Journaux', to: '/settings/logs' },
{ label: 'Avancé', to: '/settings/advanced' },
],
},
])
</script>
<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-left-enter-active, .slide-left-leave-active { transition: transform 0.25s ease; }
.slide-left-enter-from, .slide-left-leave-to { transform: translateX(-100%); }
</style>
+79 -23
View File
@@ -1,42 +1,98 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AppLayout from '@/layouts/AppLayout.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
// ─── Page publique hors layout ───────────────────────────
{
path: '/auth',
component: () => import('@/views/AuthView.vue'),
meta: { public: true }
meta: { public: true },
},
// ─── App principale avec layout sidebar ──────────────────
{
path: '/',
name: 'catalog',
component: () => import('@/views/CatalogView.vue')
component: AppLayout,
children: [
// Redirections des anciennes URLs
{ path: '', redirect: '/series' },
{ path: 'downloads', redirect: '/activity' },
// Dashboard
{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
},
// Médiathèque
{
path: 'series',
name: 'series',
component: () => import('@/views/SeriesView.vue'),
},
{
path: 'series/:id',
name: 'serie-detail',
component: () => import('@/views/SerieView.vue'),
},
// Activité (ex-downloads)
{
path: 'activity',
name: 'activity',
component: () => import('@/views/DownloadsView.vue'),
},
// Paramètres — layout imbriqué avec sous-routes
{
path: 'settings',
component: () => import('@/views/settings/SettingsLayout.vue'),
children: [
{ path: '', redirect: '/settings/download-client' },
{
path: 'download-client',
name: 'settings-download',
component: () => import('@/views/settings/DownloadClientView.vue'),
},
{
path: 'media-management',
name: 'settings-media',
component: () => import('@/views/settings/MediaManagementView.vue'),
},
{
path: 'catalogue',
name: 'settings-catalogue',
component: () => import('@/views/settings/CatalogueView.vue'),
},
{
path: 'logs',
name: 'settings-logs',
component: () => import('@/views/settings/LogsView.vue'), // ← nouveau chemin
},
{
path: 'advanced',
name: 'settings-advanced',
component: () => import('@/views/settings/AdvancedView.vue'),
},
],
},
],
},
{
path: '/series/:id',
component: () => import('@/views/SerieView.vue')
},
{
path: '/downloads',
component: () => import('@/views/DownloadsView.vue')
},
{
path: '/settings',
component: () => import('@/views/SettingsView.vue')
},
{
path: '/logs',
component: () => import('@/views/LogsView.vue')
}
],
scrollBehavior(to, _from, savedPosition) {
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
return { top: 0 }
}
// Retour depuis le détail vers la liste — ne pas scroller en haut
if (to.name === 'series' && from.name === 'serie-detail') return false
return { el: '#main-scroll', top: 0 }
},
})
// ─── Guard auth (inchangé) ────────────────────────────────────
router.beforeEach(async (to) => {
const auth = useAuthStore()
@@ -49,7 +105,7 @@ router.beforeEach(async (to) => {
}
if (to.path === '/auth' && auth.loggedIn) {
return '/'
return '/series'
}
})
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useDownloadsStore = defineStore('downloads', () => {
const activeCount = ref(0)
async function refresh() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (!res.ok) return
const torrents = await res.json()
activeCount.value = torrents.filter((t: any) => t.state === 'downloading').length
} catch {
activeCount.value = 0
}
}
return { activeCount, refresh }
})
+13
View File
@@ -0,0 +1,13 @@
export interface NavChild {
label: string
to: string
}
export interface NavItem {
label? : string
icon? : string
to? : string
children? : NavChild[]
separator?: true
badge? : string | number // ← badge optionnel
}
+34 -68
View File
@@ -1,69 +1,49 @@
<template>
<div class="min-h-screen bg-[#080c10] flex items-center justify-center px-4">
<div class="min-h-screen bg-shell flex items-center justify-center px-4">
<div class="w-full max-w-sm">
<!-- Logo -->
<div class="flex items-baseline gap-1 mb-8 justify-center">
<span class="font-sans text-3xl font-black text-white tracking-tight">Fank</span>
<span class="font-sans text-3xl font-black text-[#e8513a] tracking-tight">Arr</span>
<div class="flex items-center justify-center gap-2.5 mb-8">
<svg class="w-8 h-8" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
</svg>
<span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span>
</div>
<!-- Card -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-8">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">
{{ isSetup ? 'PREMIÈRE CONNEXION' : 'CONNEXION' }}
<div class="settings-card p-8 flex flex-col gap-5">
<div>
<p class="text-xs text-accent font-medium mb-1">
{{ isSetup ? 'Première connexion' : 'Connexion' }}
</p>
<h1 class="text-lg font-semibold text-primary">
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
</h1>
</div>
<h1 class="text-lg font-black text-white font-sans mb-6">
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
</h1>
<!-- Form -->
<div class="flex flex-col gap-4">
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">IDENTIFIANT</label>
<input
v-model="username"
type="text"
placeholder="admin"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
@keyup.enter="submit"
/>
<label class="settings-label mb-1.5">Identifiant</label>
<input v-model="username" type="text" placeholder="admin" class="settings-input" @keyup.enter="submit" />
</div>
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">MOT DE PASSE</label>
<input
v-model="password"
type="password"
placeholder="••••••••"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
@keyup.enter="submit"
/>
<label class="settings-label mb-1.5">Mot de passe</label>
<input v-model="password" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
</div>
<div v-if="isSetup">
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">CONFIRMER</label>
<input
v-model="confirm"
type="password"
placeholder="••••••••"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
@keyup.enter="submit"
/>
<label class="settings-label mb-1.5">Confirmer</label>
<input v-model="confirm" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
</div>
<!-- Error -->
<div v-if="error" class="text-[#e8513a] text-xs">{{ error }}</div>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<!-- Submit -->
<button
@click="submit"
:disabled="submitting"
class="w-full bg-[#e8513a] text-white py-2.5 text-xs tracking-widest font-bold hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer mt-2"
>
{{ submitting ? '...' : isSetup ? 'CRÉER LE COMPTE' : 'SE CONNECTER' }}
<button @click="submit" :disabled="submitting" class="btn-primary w-full py-2.5 mt-1">
{{ submitting ? '...' : isSetup ? 'Créer le compte' : 'Se connecter' }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -74,42 +54,28 @@ import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const auth = useAuthStore()
const username = ref('')
const password = ref('')
const confirm = ref('')
const error = ref<string | null>(null)
const username = ref('')
const password = ref('')
const confirm = ref('')
const error = ref<string | null>(null)
const submitting = ref(false)
const isSetup = computed(() => !auth.setup)
async function submit() {
error.value = null
if (!username.value || !password.value) {
error.value = 'Tous les champs sont requis'
return
}
if (isSetup.value && password.value !== confirm.value) {
error.value = 'Les mots de passe ne correspondent pas'
return
}
if (!username.value || !password.value) { error.value = 'Tous les champs sont requis'; return }
if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas'; return }
submitting.value = true
const err = isSetup.value
? await auth.setupAccount(username.value, password.value)
: await auth.login(username.value, password.value)
submitting.value = false
if (err) {
error.value = err
return
}
if (err) { error.value = err; return }
await router.push('/')
}
</script>
-300
View File
@@ -1,300 +0,0 @@
<template>
<div class="min-h-screen bg-zinc-950 text-zinc-100">
<!-- Header -->
<header class="sticky top-0 z-50 bg-zinc-950/90 backdrop-blur border-b border-white/5">
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<router-link to="/" class="text-2xl font-serif tracking-tight hover:opacity-80 transition">
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
</router-link>
<div class="flex items-center gap-2">
<router-link to="/logs"
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition">
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
</router-link>
<router-link to="/downloads"
class="relative p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition">
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<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>
<span v-if="activeDownloads > 0"
class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 bg-orange-500 text-white text-[9px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
{{ activeDownloads }}
</span>
</router-link>
<router-link to="/settings"
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition">
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</router-link>
<button class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition" @click="handleLogout">
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
</svg>
</button>
</div>
</div>
<!-- Search + Filters + Sort -->
<div class="max-w-7xl mx-auto px-6 pb-3 flex flex-wrap items-center gap-3">
<div class="relative flex-1 min-w-48 max-w-sm">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600 pointer-events-none" width="15" height="15" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input v-model="search" type="text" placeholder="Rechercher…"
class="w-full bg-white/5 border border-white/[0.08] rounded-lg pl-9 pr-4 py-2 text-sm placeholder-zinc-600 outline-none focus:border-orange-500/50 transition"/>
</div>
<div class="flex items-center gap-1">
<button v-for="f in filtersGroupe1" :key="f.value"
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
:class="activeFilter === f.value
? 'bg-orange-500 border-orange-500 text-white'
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
@click="activeFilter = f.value">{{ f.label }}</button>
<span class="w-px h-5 bg-white/10 mx-1" />
<button v-for="f in filtersGroupe2" :key="f.value"
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
:class="activeFilter === f.value
? 'bg-blue-500 border-blue-500 text-white'
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
@click="activeFilter = f.value">{{ f.label }}</button>
</div>
<!-- Tri -->
<div class="flex items-center gap-1 ml-auto">
<button v-for="s in sortOptions" :key="s.value"
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
:class="activeSort === s.value
? 'bg-white/10 border-white/20 text-zinc-100'
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
@click="activeSort = s.value">{{ s.label }}</button>
</div>
<span class="text-xs text-zinc-600">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
</div>
</header>
<!-- Main -->
<main class="max-w-7xl mx-auto px-6 py-8">
<div v-if="store.loadingSeries" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
<p class="text-sm">Chargement du catalogue…</p>
</div>
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
<svg width="36" height="36" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
</svg>
<p class="text-sm">{{ store.error }}</p>
<button class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm hover:bg-orange-600 transition" @click="store.fetchSeries()">
Réessayer
</button>
</div>
<div v-else-if="filtered.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-96 text-zinc-600">
<p class="text-sm">Aucune série trouvée</p>
</div>
<div v-else class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-5">
<router-link
v-for="serie in filtered"
:key="serie.id"
:to="`/series/${serie.id}`"
class="group flex flex-col gap-2 transition-transform duration-200 hover:-translate-y-1"
:class="{ 'opacity-40': !serie.has_torrents }"
>
<div class="relative aspect-[2/3] rounded-xl overflow-hidden bg-zinc-900 border border-white/5">
<img v-if="serie.poster_image" :src="serie.poster_image" :alt="serie.title"
loading="lazy" class="w-full h-full object-cover"/>
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700">
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="20" rx="2"/>
<path d="m9 9 6 6M15 9l-6 6"/>
</svg>
</div>
<!-- Badge état -->
<div v-if="serie.download_state !== 'none'"
class="absolute bottom-2 left-2 flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[9px] font-bold leading-none backdrop-blur-sm"
:class="serie.download_state === 'complete' ? 'bg-green-500/90 text-white' :
serie.download_state === 'partial' ? 'bg-orange-500/90 text-white' :
serie.download_state === 'downloading' ? 'bg-blue-500/90 text-white' : ''">
<svg v-if="serie.download_state === 'complete'" width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
<svg v-else-if="serie.download_state === 'downloading'" width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3" class="animate-pulse">
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<svg v-else width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{{ serie.download_state === 'complete' ? 'IMPORTÉ' : serie.download_state === 'downloading' ? 'EN COURS' : 'PARTIEL' }}
</div>
<div class="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<span class="text-xs text-white border border-white/40 px-3 py-1.5 rounded-md">Voir</span>
</div>
</div>
<div>
<p class="text-xs font-medium truncate leading-snug">{{ serie.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5">
<span v-if="serie.year" class="text-[11px] text-zinc-600">{{ serie.year }}</span>
<span v-if="serie.status" class="text-[10px] px-1.5 py-0.5 rounded"
:class="serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/10 text-green-400'
: 'bg-white/5 text-zinc-600'">{{ serie.status }}</span>
</div>
</div>
</router-link>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import { useSeriesStore } from '@/stores/series'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
const store = useSeriesStore()
const auth = useAuthStore()
const router = useRouter()
const { add: toast } = useToast()
const search = ref('')
const activeFilter = ref('all')
const activeSort = ref('alpha')
const activeDownloads = ref(0)
const filtersGroupe1 = [
{ label: 'Toutes', value: 'all' },
{ label: 'Disponibles', value: 'available' },
{ label: 'Sans torrent', value: 'unavailable' },
]
const filtersGroupe2 = [
{ label: 'Importés', value: 'complete' },
{ label: 'En cours', value: 'downloading' },
]
const sortOptions = [
{ label: 'A→Z', value: 'alpha' },
{ label: 'Z→A', value: 'alpha-desc' },
{ label: 'Récents', value: 'recent' },
{ label: 'Importés', value: 'imported' },
]
const filtered = computed(() => {
let list = [...store.series]
// Filtres
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
if (activeFilter.value === 'downloading') list = list.filter(s => s.download_state === 'downloading' || s.download_state === 'partial')
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(s => s.title.toLowerCase().includes(q))
}
// Tri
switch (activeSort.value) {
case 'alpha':
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
break
case 'alpha-desc':
list.sort((a, b) => b.title.localeCompare(a.title, 'fr'))
break
case 'recent':
list.sort((a, b) => (b.year ?? 0) - (a.year ?? 0))
break
case 'imported':
// Importés en premier, puis partiel, puis reste
const order: Record<string, number> = { complete: 0, partial: 1, downloading: 2, none: 3 }
list.sort((a, b) => (order[a.download_state] ?? 3) - (order[b.download_state] ?? 3))
break
}
return list
})
async function handleLogout() {
await auth.logout()
router.push('/auth')
}
// ── Polling downloads + notifs import ─────────────────────────
let dlInterval: ReturnType<typeof setInterval> | null = null
const seenNotifs = new Set<string>()
async function fetchActiveDownloads() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (!res.ok) return
const torrents = await res.json()
activeDownloads.value = torrents.filter((t: any) => t.state === 'downloading').length
} catch {}
}
async function fetchOrganizeNotifs() {
try {
const res = await fetch('/api/organize/recent', { credentials: 'include' })
if (!res.ok) return
const notifs: any[] = await res.json()
let hasNew = false
for (const n of notifs) {
const key = `${n.hash}-${n.at}`
if (seenNotifs.has(key)) continue
seenNotifs.add(key)
hasNew = true
if (n.done > 0) {
const msg = n.errors > 0
? `${n.name} — ${n.done} fichier(s) importé(s), ${n.errors} erreur(s)`
: `${n.name} — ${n.done} fichier(s) importé(s) ✓`
toast(msg, n.errors > 0 ? 'error' : 'success')
}
}
// Rafraîchir le catalogue si de nouveaux imports ont eu lieu
if (hasNew) await store.fetchSeries()
} catch {}
}
onMounted(async () => {
if (store.series.length === 0) await store.fetchSeries()
fetchActiveDownloads()
fetchOrganizeNotifs()
dlInterval = setInterval(() => {
fetchActiveDownloads()
fetchOrganizeNotifs()
}, 10000)
})
onActivated(async () => {
await store.fetchSeries()
fetchActiveDownloads()
})
onUnmounted(() => {
if (dlInterval) clearInterval(dlInterval)
})
</script>
+234
View File
@@ -0,0 +1,234 @@
<template>
<div class="px-8 py-8 max-w-4xl">
<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Catalogue</span>
<span class="text-2xl font-semibold text-primary">{{ stats.catalogue }}</span>
<span class="text-xs text-muted">séries disponibles</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Importées</span>
<span class="text-2xl font-semibold text-green-500">{{ stats.imported }}</span>
<span class="text-xs text-muted">{{ stats.catalogue > 0 ? Math.round(stats.imported / stats.catalogue * 100) : 0 }}% du catalogue</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">En cours</span>
<span class="text-2xl font-semibold text-accent">{{ stats.downloading }}</span>
<span class="text-xs text-muted">téléchargements</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Erreurs</span>
<span class="text-2xl font-semibold" :class="stats.errors > 0 ? 'text-red-400' : 'text-muted'">{{ stats.errors }}</span>
<span class="text-xs text-muted">{{ stats.errors === 0 ? 'aucune erreur' : 'à corriger' }}</span>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4 mb-6">
<!-- Téléchargements actifs -->
<div class="settings-card flex flex-col gap-3">
<h2 class="text-sm font-medium text-secondary">Téléchargements actifs</h2>
<div v-if="loadingTorrents" class="flex items-center gap-2 text-muted text-xs py-4">
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
</div>
<div v-else-if="activeTorrents.length === 0" class="text-xs text-muted py-4">Aucun téléchargement en cours</div>
<div v-else class="flex flex-col gap-3">
<div v-for="t in activeTorrents" :key="t.hash" class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-primary truncate">{{ t.name }}</span>
<span class="text-xs text-muted shrink-0">{{ t.progress }}%</span>
</div>
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="t.state === 'seeding' ? 'bg-green-500' : 'bg-accent'" :style="{ width: `${t.progress}%` }" />
</div>
<div class="flex items-center justify-between">
<span class="text-[11px] text-muted">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</span>
<span v-if="t.state === 'downloading' && t.eta > 0" class="text-[11px] text-muted">{{ formatEta(t.eta) }}</span>
</div>
</div>
</div>
</div>
<!-- Derniers imports -->
<div class="settings-card flex flex-col gap-3">
<h2 class="text-sm font-medium text-secondary">Derniers imports</h2>
<div v-if="loadingNotifs" class="flex items-center gap-2 text-muted text-xs py-4">
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
</div>
<div v-else-if="recentNotifs.length === 0" class="text-xs text-muted py-4">Aucun import récent</div>
<div v-else class="flex flex-col divide-y divide-border/50">
<div v-for="n in recentNotifs" :key="n.hash + n.at" class="flex items-start gap-3 py-2.5 first:pt-0 last:pb-0">
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0 mt-0.5" :class="n.errors > 0 ? 'bg-red-500/10' : 'bg-green-500/10'">
<svg v-if="n.errors === 0" width="10" height="10" viewBox="0 0 12 12" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="#f87171" stroke-width="2.5" fill="none"><line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/></svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-primary truncate">{{ n.name }}</p>
<p class="text-[11px] text-muted mt-0.5">
{{ n.done }} fichier{{ n.done > 1 ? 's' : '' }} importé{{ n.done > 1 ? 's' : '' }}
<span v-if="n.errors > 0" class="text-red-400"> · {{ n.errors }} erreur{{ n.errors > 1 ? 's' : '' }}</span>
</p>
</div>
<span class="text-[11px] text-muted shrink-0">{{ formatRelative(n.at) }}</span>
</div>
</div>
</div>
</div>
<!-- Panneau debug -->
<div v-if="devMode" class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<h2 class="text-sm font-medium text-secondary">Debug</h2>
<span class="text-[10px] px-1.5 py-0.5 rounded border border-accent/30 text-accent">DEV</span>
<button @click="fetchDebug" class="ml-auto text-xs text-muted hover:text-primary transition-colors">↺ Rafraîchir</button>
</div>
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Heap utilisé</span>
<span class="text-xl font-semibold text-primary">{{ debug.memory.heapUsed }} MB</span>
<span class="text-xs text-muted">/ {{ debug.memory.heapTotal }} MB total</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">RSS (process)</span>
<span class="text-xl font-semibold text-primary">{{ debug.memory.rss }} MB</span>
<span class="text-xs text-muted">mémoire système</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Uptime</span>
<span class="text-xl font-semibold text-primary">{{ debug.uptime }}</span>
<span class="text-xs text-muted">depuis le démarrage</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Cache GitHub</span>
<span class="text-xl font-semibold text-primary">{{ debug.cache.entries }}</span>
<span class="text-xs text-muted">entrées (TTL {{ debug.cache.ttlHours }}h)</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Fichiers trackés</span>
<span class="text-xl font-semibold text-primary">{{ debug.organized.trackedFiles }}</span>
<span class="text-xs text-muted">dans organized.json</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Worker organize</span>
<span class="text-xl font-semibold" :class="debug.worker.running ? 'text-accent' : 'text-muted'">
{{ debug.worker.running ? 'Actif' : 'Inactif' }}
</span>
<span class="text-xs text-muted">{{ debug.requests.notifs }} notif(s) en mémoire</span>
</div>
</div>
<div v-else class="settings-card text-xs text-muted py-4 text-center">Chargement des stats...</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const loadingTorrents = ref(true)
const loadingNotifs = ref(true)
const devMode = ref(false)
const debug = ref<any>(null)
const stats = ref({ catalogue: 0, imported: 0, downloading: 0, errors: 0 })
const activeTorrents = ref<any[]>([])
const recentNotifs = ref<any[]>([])
let interval: ReturnType<typeof setInterval> | null = null
async function fetchSettings() {
const res = await fetch('/api/settings', { credentials: 'include' })
if (res.ok) {
const s = await res.json()
devMode.value = s.devMode ?? false
if (devMode.value) fetchDebug()
}
}
async function fetchDebug() {
const res = await fetch('/api/debug/stats', { credentials: 'include' })
if (res.ok) debug.value = await res.json()
}
async function fetchStats() {
try {
const [statusRes, seriesRes, downloadsRes] = await Promise.all([
fetch('/api/torrents/status', { credentials: 'include' }),
fetch('/api/series', { credentials: 'include' }),
fetch('/api/downloads', { credentials: 'include' }),
])
if (statusRes.ok) { const s = await statusRes.json(); stats.value.catalogue = s.count ?? 0 }
if (seriesRes.ok) {
const s = await seriesRes.json()
const list = Array.isArray(s) ? s : (s.series ?? [])
stats.value.imported = list.filter((s: any) => s.download_state === 'complete').length
}
if (downloadsRes.ok) {
const torrents = await downloadsRes.json()
stats.value.downloading = torrents.filter((t: any) => t.state === 'downloading').length
stats.value.errors = torrents.filter((t: any) => t.state === 'error').length
}
} catch {}
}
async function fetchTorrents() {
const isFirst = loadingTorrents.value
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (res.ok) {
const list = await res.json()
activeTorrents.value = list.filter((t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state)).slice(0, 5)
}
} catch {} finally {
if (isFirst) loadingTorrents.value = false
}
}
async function fetchNotifs() {
loadingNotifs.value = true
try {
const res = await fetch('/api/organize/recent', { credentials: 'include' })
if (res.ok) recentNotifs.value = (await res.json()).slice(0, 6)
} catch {} finally {
loadingNotifs.value = false
}
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
function formatEta(seconds: number): string {
if (seconds < 0 || seconds > 86400 * 7) return '∞'
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`
if (m > 0) return `${m}m ${s.toString().padStart(2, '0')}s`
return `${s}s`
}
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000), h = Math.floor(diff / 3600000), d = Math.floor(diff / 86400000)
if (m < 1) return "à l'instant"
if (m < 60) return `il y a ${m}min`
if (h < 24) return `il y a ${h}h`
return `il y a ${d}j`
}
onMounted(() => {
Promise.all([fetchStats(), fetchTorrents(), fetchNotifs(), fetchSettings()])
interval = setInterval(() => {
fetchTorrents()
if (devMode.value) fetchDebug()
}, 10000)
})
onUnmounted(() => { if (interval) clearInterval(interval) })
</script>
+484 -221
View File
@@ -1,207 +1,487 @@
<template>
<div class="max-w-4xl mx-auto px-8 py-8">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<button @click="$router.push('/')"
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
← CATALOGUE
</button>
<h1 class="text-xl font-black text-white font-sans">Téléchargements</h1>
</div>
<div class="flex items-center gap-3">
<button @click="importAll" :disabled="importingAll"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-green-500 hover:text-green-500 transition-colors cursor-pointer disabled:opacity-50">
{{ importingAll ? '...' : 'IMPORTER TOUT' }}
</button>
<div class="flex items-center gap-2 text-[10px] text-[#5a7a94] tracking-widest">
<div class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-[#1e2d3d]'"/>
{{ polling ? 'EN DIRECT' : 'PAUSE' }}
</div>
<button @click="togglePolling"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
{{ polling ? 'PAUSE' : 'REPRENDRE' }}
</button>
</div>
</div>
<div>
<div class="px-8 py-8 max-w-4xl">
<!-- Résumé -->
<div class="grid grid-cols-4 gap-3 mb-6">
<div v-for="stat in stats" :key="stat.label" class="bg-[#0d1219] border border-[#1e2d3d] p-4">
<div class="text-[10px] text-[#5a7a94] tracking-widest mb-1">{{ stat.label }}</div>
<div class="text-lg font-bold text-white">{{ stat.value }}</div>
</div>
</div>
<!-- Onglets -->
<div class="flex items-center justify-between mb-4">
<div class="flex gap-0 border border-[#1e2d3d]">
<button @click="activeTab = 'active'"
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer"
:class="activeTab === 'active' ? 'bg-[#e8513a] text-white' : 'text-[#5a7a94] hover:text-white'">
EN COURS
<span v-if="activeTorrents.length > 0" class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
{{ activeTorrents.length }}
</span>
</button>
<button @click="activeTab = 'done'"
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer border-l border-[#1e2d3d]"
:class="activeTab === 'done' ? 'bg-[#e8513a] text-white' : 'text-[#5a7a94] hover:text-white'">
TERMINÉS
<span v-if="doneTorrents.length > 0" class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
{{ doneTorrents.length }}
</span>
</button>
</div>
<div v-if="activeTab === 'done'" class="flex items-center gap-2">
<button @click="hideImported = !hideImported"
class="relative shrink-0 w-8 h-4 rounded-full transition-colors cursor-pointer"
:class="hideImported ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform duration-200"
:class="hideImported ? 'translate-x-4' : 'translate-x-0'" />
</button>
<span class="text-[10px] text-[#5a7a94] tracking-widest">MASQUER IMPORTÉS</span>
</div>
</div>
<!-- Pas de clients -->
<div v-if="noClients" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
<p class="text-sm">Aucun client torrent configuré</p>
<router-link to="/settings" class="text-[10px] tracking-widest text-[#e8513a] hover:opacity-75 transition-opacity">
CONFIGURER →
</router-link>
</div>
<!-- Loading -->
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
<div class="w-5 h-5 border-2 border-white/10 border-t-[#e8513a] rounded-full animate-spin"/>
<span class="text-sm">Chargement...</span>
</div>
<!-- Vide -->
<div v-else-if="!loading && visibleTorrents.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
<p class="text-sm">
{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}
</p>
<p class="text-[11px]">
{{ activeTab === 'active'
? 'Les torrents avec la catégorie « fankai » apparaîtront ici'
: hideImported ? 'Tous les torrents terminés sont importés' : '' }}
</p>
</div>
<!-- Liste torrents -->
<div v-else class="flex flex-col gap-2">
<div v-for="t in visibleTorrents" :key="t.hash" class="bg-[#0d1219] border border-[#1e2d3d] p-4">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="flex-1 min-w-0">
<p class="text-sm text-white font-medium truncate">{{ t.name }}</p>
<div class="flex items-center gap-3 mt-1">
<span class="text-[10px] tracking-widest px-2 py-0.5 border" :class="stateBadge(t.state).class">
{{ stateBadge(t.state).label }}
</span>
<span class="text-[10px] text-[#5a7a94]">{{ t.client_name }}</span>
</div>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-bold text-white">{{ t.progress }}%</p>
<p class="text-[10px] text-[#5a7a94] mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<h1 class="text-lg font-semibold text-primary">Activité</h1>
<div class="flex items-center gap-1.5">
<span
class="w-1.5 h-1.5 rounded-full transition-colors"
:class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'"
/>
<span class="text-xs text-muted">{{ polling ? 'En direct' : 'Pause' }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="importAll" :disabled="importingAll" class="btn-primary">
{{ importingAll ? '...' : 'Importer tout' }}
</button>
<button @click="fetchTorrents" :disabled="loading" class="btn-secondary">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
:class="loading ? 'animate-spin' : ''">
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
<polyline points="16 5 21 5 21 10"/>
</svg>
</button>
<button @click="togglePolling" class="btn-secondary">
{{ polling ? 'Pause' : 'Reprendre' }}
</button>
</div>
</div>
<div class="h-1 bg-[#1e2d3d] rounded-full overflow-hidden mb-2">
<div class="h-full rounded-full transition-all duration-500"
:class="t.state === 'seeding' ? 'bg-green-500' :
t.state === 'error' ? 'bg-red-500' :
t.state === 'paused' ? 'bg-[#5a7a94]' : 'bg-[#e8513a]'"
:style="{ width: `${t.progress}%` }"/>
<!-- Stats -->
<div class="grid grid-cols-4 gap-3 mb-6">
<div v-for="stat in stats" :key="stat.label" class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">{{ stat.label }}</span>
<span class="text-xl font-semibold text-primary">{{ stat.value }}</span>
</div>
</div>
<!-- Toolbar : recherche + tri + colonnes -->
<div class="flex items-center gap-2 mb-4">
<div class="relative flex-1 max-w-xs">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
<input
v-model="search"
type="text"
placeholder="Rechercher..."
class="settings-input pl-9 w-full py-1.5 text-sm"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-[10px] text-[#5a7a94]">
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
<span v-if="t.state === 'seeding'" class="text-green-500">✓ Complété</span>
</div>
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
<span class="text-[9px] tracking-widest px-2 py-0.5 border border-red-500/40 text-red-400 cursor-default">
{{ t.errorFiles.length }} ERREUR{{ t.errorFiles.length > 1 ? 'S' : '' }}
</span>
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
<div class="bg-[#0d1219] border border-red-500/30 p-3 text-[10px] shadow-xl">
<div class="text-red-400 tracking-widest mb-2">FICHIERS EN ERREUR</div>
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
<p class="text-white truncate">{{ e.file }}</p>
<p class="text-[#5a7a94]">{{ e.error }}</p>
</div>
</div>
</div>
</div>
<span class="text-[9px] tracking-widest px-2 py-0.5 border"
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
'border-red-500/40 text-red-400'">
{{ t.organizeState === 'done' ? `IMPORTÉ (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
t.organizeState === 'partial' ? `EN COURS (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
'NON IMPORTÉ' }}
</span>
<button v-if="t.organizeState !== 'done'"
@click="importTorrent(t)"
:disabled="importing[t.hash]"
class="text-[9px] tracking-widest px-3 py-1 border transition-colors cursor-pointer border-[#1e2d3d] text-[#5a7a94] hover:border-[#e8513a] hover:text-[#e8513a] disabled:opacity-50">
{{ importing[t.hash] ? '...' : 'IMPORTER' }}
<!-- Tri -->
<div class="relative shrink-0" ref="sortRef">
<button
@click="sortOpen = !sortOpen"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
:class="activeSort !== 'none' ? 'border-accent text-accent' : ''"
>
<ArrowUpDown :size="14" />
<span class="hidden md:inline text-xs">Trier</span>
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
</button>
<div
v-if="sortOpen"
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl"
>
<button
v-for="s in sortOptions" :key="s.value"
@click="setSort(s.value)"
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
{{ s.label }}
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
</button>
</div>
</div>
<!-- Colonnes -->
<div class="relative shrink-0" ref="colRef">
<button
@click="colOpen = !colOpen"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
:class="colOpen ? 'border-accent text-accent' : ''"
>
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="hidden md:inline text-xs">Colonnes</span>
</button>
<div
v-if="colOpen"
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-52 flex flex-col gap-0.5 shadow-xl"
>
<p class="text-[10px] text-muted px-3 py-1">Colonnes affichées</p>
<label
v-for="col in columnOptions" :key="col.key"
class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs cursor-pointer hover:bg-hover transition-colors"
>
<input type="checkbox" v-model="columns[col.key]" class="accent-accent" />
{{ col.label }}
</label>
</div>
</div>
</div>
<!-- Onglets + filtres -->
<div class="flex items-center justify-between mb-4">
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
<button
@click="activeTab = 'active'"
class="px-4 py-1.5 text-xs rounded-md transition-colors"
:class="activeTab === 'active' ? 'bg-active text-primary' : 'text-muted hover:text-primary'"
>
En cours
<span v-if="activeTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">
{{ activeTorrents.length }}
</span>
</button>
<button
@click="activeTab = 'done'"
class="px-4 py-1.5 text-xs rounded-md transition-colors"
:class="activeTab === 'done' ? 'bg-active text-primary' : 'text-muted hover:text-primary'"
>
Terminés
<span v-if="doneTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">
{{ doneTorrents.length }}
</span>
</button>
</div>
<div v-if="activeTab === 'done'" class="flex items-center gap-3">
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-muted">Seeds uniquement</span>
<button
@click="seedingOnly = !seedingOnly"
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200"
:class="seedingOnly ? 'bg-green-500' : 'bg-border'"
>
<span
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="seedingOnly ? 'translate-x-[18px]' : 'translate-x-0'"
/>
</button>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-muted">Masquer importés</span>
<button
@click="hideImported = !hideImported"
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200"
:class="hideImported ? 'bg-accent' : 'bg-border'"
>
<span
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'"
/>
</button>
</label>
</div>
</div>
<!-- Pas de clients -->
<div v-if="noClients" class="settings-card flex flex-col items-center gap-3 py-16 text-center">
<p class="text-sm text-muted">Aucun client torrent configuré</p>
<RouterLink to="/settings/download-client" class="text-xs text-accent hover:underline">
Configurer un client →
</RouterLink>
</div>
<!-- Loading initial -->
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
</div>
<!-- Vide -->
<div v-else-if="!loading && visibleTorrents.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
<p class="text-sm text-muted">
{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}
</p>
<p class="text-xs text-muted">
{{ activeTab === 'active'
? 'Les torrents avec la catégorie « fankai » apparaîtront ici'
: hideImported ? 'Tous les torrents terminés sont importés' : '' }}
</p>
</div>
<!-- Liste torrents -->
<div v-else class="flex flex-col gap-2">
<div v-for="t in visibleTorrents" :key="t.hash" class="settings-card flex flex-col gap-3">
<!-- Nom + état + progression -->
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium truncate">{{ t.name }}</p>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(t.state).class">
{{ stateBadge(t.state).label }}
</span>
<span v-if="columns.client" class="text-xs text-muted">{{ t.client_name }}</span>
</div>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-semibold text-primary">{{ clampedProgress(t) }}%</p>
<p v-if="columns.size" class="text-xs text-muted mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
</div>
</div>
<!-- Barre de progression -->
<div class="h-1 bg-border rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="t.state === 'seeding' || t.state === 'unknown' ? 'bg-green-500' :
t.state === 'error' ? 'bg-red-500' :
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
:style="{ width: `${clampedProgress(t)}%` }"
/>
</div>
<!-- Infos bas + actions -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 text-xs text-muted">
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
<template v-if="t.state === 'seeding'">
<span class="text-green-500">Complété</span>
<span v-if="columns.ratio" class="font-mono"
:class="t.ratio >= 1 ? 'text-green-400' : t.ratio >= 0.5 ? 'text-yellow-500' : 'text-muted'">
R {{ t.ratio?.toFixed(2) ?? '0.00' }}
</span>
<span v-if="columns.uploaded">↑ {{ formatSize(t.uploaded ?? 0) }}</span>
<span v-if="columns.upspeed && t.upspeed > 0">{{ formatSpeed(t.upspeed) }}</span>
</template>
<span v-if="t.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
</div>
<div v-if="t.state === 'seeding' || t.state === 'unknown'" class="flex items-center gap-2">
<!-- Erreurs fichiers -->
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
<span class="text-xs px-2 py-0.5 rounded border border-red-500/40 text-red-400 cursor-default">
{{ t.errorFiles.length }} erreur{{ t.errorFiles.length > 1 ? 's' : '' }}
</span>
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
<div class="bg-card border border-red-500/30 rounded-lg p-3 text-xs shadow-xl">
<p class="text-red-400 mb-2 font-medium">Fichiers en erreur</p>
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
<p class="text-primary truncate">{{ e.file }}</p>
<p class="text-muted">{{ e.error }}</p>
</div>
</div>
</div>
</div>
<!-- Badge import -->
<span
class="text-xs px-2 py-0.5 rounded border"
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
'border-border text-muted'"
>
{{ t.organizeState === 'done' ? `Importé (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
t.organizeState === 'partial' ? `En cours (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
'Non importé' }}
</span>
<button
v-if="t.organizeState !== 'done'"
@click="importTorrent(t)"
:disabled="importing[t.hash]"
class="btn-secondary text-xs py-1"
>
{{ importing[t.hash] ? '...' : 'Importer' }}
</button>
<!-- Bouton supprimer -->
<div class="relative">
<button
@click="confirmDelete = confirmDelete === t.hash ? null : t.hash"
:disabled="deleting[t.hash]"
class="w-7 h-7 flex items-center justify-center rounded-lg border border-border text-muted hover:border-red-500/40 hover:text-red-400 transition-colors"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
</svg>
</button>
<!-- Popup confirmation -->
<div
v-if="confirmDelete === t.hash"
class="absolute bottom-full right-0 mb-2 bg-card border border-border rounded-xl p-3 z-10 w-52 shadow-xl"
>
<p class="text-xs text-primary font-medium mb-2">Supprimer ce torrent ?</p>
<label class="flex items-center gap-2 text-xs text-muted mb-3 cursor-pointer">
<input type="checkbox" v-model="deleteWithFiles[t.hash]" class="accent-red-500" />
Supprimer aussi les fichiers
</label>
<div class="flex gap-1.5">
<button
@click="deleteTorrent(t)"
:disabled="deleting[t.hash]"
class="flex-1 text-xs py-1 px-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-colors"
>
{{ deleting[t.hash] ? '...' : 'Confirmer' }}
</button>
<button
@click="confirmDelete = null"
class="flex-1 text-xs py-1 px-2 rounded-lg border border-border text-muted hover:text-primary transition-colors"
>
Annuler
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { Search, ArrowUpDown } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const torrents = ref<any[]>([])
const loading = ref(true)
const polling = ref(true)
const noClients = ref(false)
const importing = ref<Record<string, boolean>>({})
const importingAll = ref(false)
const activeTab = ref<'active' | 'done'>('active')
const hideImported = ref(false)
// ─── State ────────────────────────────────────────────────────
const torrents = ref<any[]>([])
const loading = ref(true)
const polling = ref(true)
const noClients = ref(false)
const importing = ref<Record<string, boolean>>({})
const importingAll = ref(false)
const confirmDelete = ref<string | null>(null)
const deleting = ref<Record<string, boolean>>({})
const deleteWithFiles = ref<Record<string, boolean>>({})
const activeTab = ref<'active' | 'done'>('active')
const hideImported = ref(false)
const seedingOnly = ref(false)
const search = ref('')
const sortOpen = ref(false)
const colOpen = ref(false)
const activeSort = ref<'none' | 'name' | 'size' | 'progress' | 'state' | 'ratio'>('none')
const sortDir = ref<'asc' | 'desc'>('asc')
const sortRef = ref<HTMLElement | null>(null)
const colRef = ref<HTMLElement | null>(null)
const columns = ref({
client : true,
size : true,
ratio : true,
uploaded: true,
upspeed : true,
})
const columnOptions = [
{ key: 'client', label: 'Client' },
{ key: 'size', label: 'Taille / Téléchargé' },
{ key: 'ratio', label: 'Ratio' },
{ key: 'uploaded', label: 'Uploadé (total)' },
{ key: 'upspeed', label: 'Vitesse upload' },
]
const sortOptions = [
{ label: 'Nom', value: 'name' },
{ label: 'Taille', value: 'size' },
{ label: 'Progression', value: 'progress' },
{ label: 'État', value: 'state' },
{ label: 'Ratio', value: 'ratio' },
]
onClickOutside(sortRef, () => { sortOpen.value = false })
onClickOutside(colRef, () => { colOpen.value = false })
let pollInterval: ReturnType<typeof setInterval> | null = null
// ─── Computed ─────────────────────────────────────────────────
const activeTorrents = computed(() =>
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error', 'unknown'].includes(t.state))
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
)
const doneTorrents = computed(() =>
torrents.value.filter(t => t.state === 'seeding')
torrents.value.filter(t => t.state === 'seeding' || t.state === 'unknown')
)
const visibleTorrents = computed(() => {
if (activeTab.value === 'active') return activeTorrents.value
if (hideImported.value) return doneTorrents.value.filter(t => t.organizeState !== 'done')
return doneTorrents.value
let list = activeTab.value === 'active' ? activeTorrents.value : doneTorrents.value
if (activeTab.value === 'done') {
if (seedingOnly.value) list = list.filter(t => t.state === 'seeding')
if (hideImported.value) list = list.filter(t => t.organizeState !== 'done')
}
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(t => t.name.toLowerCase().includes(q))
}
if (activeSort.value !== 'none') {
const dir = sortDir.value === 'asc' ? 1 : -1
list = [...list].sort((a, b) => {
switch (activeSort.value) {
case 'name' : return a.name.localeCompare(b.name, 'fr') * dir
case 'size' : return (a.size - b.size) * dir
case 'progress': return (a.progress - b.progress) * dir
case 'state' : return a.state.localeCompare(b.state) * dir
case 'ratio' : return ((a.ratio ?? 0) - (b.ratio ?? 0)) * dir
default : return 0
}
})
}
return list
})
const stats = computed(() => [
{ label: 'EN COURS', value: torrents.value.filter(t => t.state === 'downloading').length },
{ label: 'COMPLÉTÉS', value: torrents.value.filter(t => t.state === 'seeding').length },
{ label: 'EN PAUSE', value: torrents.value.filter(t => t.state === 'paused').length },
{ label: 'ERREURS', value: torrents.value.filter(t => t.state === 'error').length },
{ label: 'En cours', value: torrents.value.filter(t => t.state === 'downloading').length },
{ label: 'Complétés', value: torrents.value.filter(t => t.state === 'seeding').length },
{ label: 'En pause', value: torrents.value.filter(t => t.state === 'paused').length },
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
])
// ─── Helpers ──────────────────────────────────────────────────
function setSort(val: typeof activeSort.value) {
if (activeSort.value === val) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
activeSort.value = val
sortDir.value = 'asc'
}
sortOpen.value = false
}
function clampedProgress(t: any): number {
return Math.min(100, Math.max(0, t.progress ?? 0))
}
function stateBadge(state: string) {
const map: Record<string, { label: string; class: string }> = {
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
paused : { label: 'En pause', class: 'border-border text-muted' },
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
}
return map[state] ?? map.unknown
}
function formatSize(bytes: number): string {
if (!bytes || bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
function formatSpeed(bps: number): string { return `${formatSize(bps)}/s` }
function formatEta(seconds: number): string {
if (seconds < 0 || seconds > 86400 * 7) return '∞'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
return `${s}s`
}
// ─── Actions ──────────────────────────────────────────────────
async function fetchTorrents() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
@@ -217,7 +497,7 @@ async function fetchTorrents() {
function startPolling() {
fetchTorrents()
pollInterval = setInterval(fetchTorrents, 3000)
pollInterval = setInterval(fetchTorrents, 60_000)
}
function stopPolling() {
@@ -226,21 +506,17 @@ function stopPolling() {
function togglePolling() {
polling.value = !polling.value
if (polling.value) startPolling()
else stopPolling()
polling.value ? startPolling() : stopPolling()
}
onMounted(startPolling)
onUnmounted(stopPolling)
async function importTorrent(torrent: any) {
importing.value[torrent.hash] = true
try {
const res = await fetch('/api/organize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name })
body : JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }),
})
if (res.ok) {
toast(`${torrent.name} importé ✓`, 'success')
@@ -258,65 +534,52 @@ async function importTorrent(torrent: any) {
async function importAll() {
const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done')
if (toImport.length === 0) {
toast('Tous les torrents sont déjà importés', 'success')
return
}
if (toImport.length === 0) { toast('Tous les torrents sont déjà importés', 'success'); return }
importingAll.value = true
let done = 0
let errors = 0
let done = 0, errors = 0
for (const t of toImport) {
try {
const res = await fetch('/api/organize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name })
body : JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name }),
})
if (res.ok) done++
else errors++
} catch {
errors++
}
res.ok ? done++ : errors++
} catch { errors++ }
}
importingAll.value = false
await fetchTorrents()
if (errors === 0) toast(`${done} torrent(s) importé(s) ✓`, 'success')
else toast(`${done} OK, ${errors} erreur(s)`, 'error')
errors === 0
? toast(`${done} torrent(s) importé(s) ✓`, 'success')
: toast(`${done} OK, ${errors} erreur(s)`, 'error')
}
function stateBadge(state: string) {
const map: Record<string, { label: string; class: string }> = {
downloading: { label: 'TÉLÉCHARGEMENT', class: 'border-[#e8513a]/40 text-[#e8513a]' },
seeding : { label: 'COMPLÉTÉ', class: 'border-green-500/40 text-green-500' },
paused : { label: 'EN PAUSE', class: 'border-[#5a7a94]/40 text-[#5a7a94]' },
checking : { label: 'VÉRIFICATION', class: 'border-yellow-500/40 text-yellow-500' },
error : { label: 'ERREUR', class: 'border-red-500/40 text-red-500' },
unknown : { label: 'INCONNU', class: 'border-[#1e2d3d] text-[#5a7a94]' },
async function deleteTorrent(torrent: any) {
deleting.value[torrent.hash] = true
try {
const withFiles = deleteWithFiles.value[torrent.hash] ?? false
const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, {
method : 'DELETE',
credentials: 'include',
})
if (res.ok) {
toast(`"${torrent.name}" supprimé ✓`, 'success')
confirmDelete.value = null
await fetchTorrents()
} else {
toast('Erreur lors de la suppression', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
deleting.value[torrent.hash] = false
}
return map[state] ?? map.unknown
}
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
function formatSpeed(bps: number): string { return `${formatSize(bps)}/s` }
function formatEta(seconds: number): string {
if (seconds < 0 || seconds > 86400 * 7) return '∞'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
return `${s}s`
}
onMounted(startPolling)
onUnmounted(stopPolling)
</script>
-209
View File
@@ -1,209 +0,0 @@
<template>
<div class="max-w-5xl mx-auto px-8 py-8">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<button @click="$router.push('/')"
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
← CATALOGUE
</button>
<h1 class="text-xl font-black text-white font-sans">Logs</h1>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] text-[#5a7a94] tracking-widest">{{ formatSize(fileSize) }}</span>
<button @click="confirmClear"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-red-500 hover:text-red-500 transition-colors cursor-pointer">
EFFACER
</button>
<button @click="load"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
↺ RAFRAÎCHIR
</button>
</div>
</div>
<!-- Filtres -->
<div class="flex flex-wrap items-center gap-3 mb-5">
<!-- Niveau -->
<div class="flex gap-1">
<button v-for="l in levels" :key="l.value" @click="filterLevel = l.value; load()"
class="px-3 py-1 text-[10px] tracking-widest border transition-colors cursor-pointer"
:class="filterLevel === l.value
? 'border-[#e8513a] text-[#e8513a]'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
{{ l.label }}
</button>
</div>
<!-- Source -->
<select v-model="filterSource" @change="load()"
class="bg-[#0d1219] border border-[#1e2d3d] text-[#5a7a94] px-3 py-1 text-[10px] tracking-widest outline-none cursor-pointer">
<option value="">Toutes les sources</option>
<option value="organize">organize</option>
<option value="api">api</option>
<option value="auth">auth</option>
</select>
<!-- Limite / tout afficher -->
<div class="flex items-center gap-2 ml-auto">
<span class="text-[10px] text-[#5a7a94] tracking-widest">AFFICHER</span>
<button v-for="n in [100, 500, 2000]" :key="n" @click="limit = n; load()"
class="px-2 py-1 text-[10px] tracking-widest border transition-colors cursor-pointer"
:class="limit === n
? 'border-[#e8513a] text-[#e8513a]'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
{{ n === 2000 ? 'TOUT' : n }}
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center gap-3 min-h-48 text-[#5a7a94]">
<div class="w-4 h-4 border-2 border-white/10 border-t-[#e8513a] rounded-full animate-spin"/>
<span class="text-sm">Chargement...</span>
</div>
<!-- Vide -->
<div v-else-if="entries.length === 0" class="flex items-center justify-center min-h-48 text-[#5a7a94] text-sm">
Aucun log
</div>
<!-- Table logs -->
<div v-else class="bg-[#0d1219] border border-[#1e2d3d] overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-xs font-mono">
<thead>
<tr class="border-b border-[#1e2d3d]">
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-4 py-2.5 w-40">HORODATAGE</th>
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5 w-16">NIVEAU</th>
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5 w-24">SOURCE</th>
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5">MESSAGE</th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, i) in entries" :key="i"
class="border-b border-[#1e2d3d]/50 hover:bg-white/[0.02] transition-colors"
:class="entry.level === 'error' ? 'bg-red-500/5' : entry.level === 'warn' ? 'bg-yellow-500/5' : ''">
<td class="px-4 py-2 text-[#5a7a94] whitespace-nowrap">{{ formatDate(entry.at) }}</td>
<td class="px-3 py-2 whitespace-nowrap">
<span class="text-[10px] tracking-widest px-1.5 py-0.5 rounded"
:class="levelClass(entry.level)">
{{ entry.level.toUpperCase() }}
</span>
</td>
<td class="px-3 py-2 text-[#5a7a94] whitespace-nowrap">{{ entry.source }}</td>
<td class="px-3 py-2 text-[#c8d8e8] break-all">
{{ entry.msg }}
<span v-if="entry.meta" class="text-[#5a7a94] ml-2">
{{ JSON.stringify(entry.meta) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Footer count -->
<div class="px-4 py-2.5 border-t border-[#1e2d3d] flex items-center justify-between">
<span class="text-[10px] text-[#5a7a94] tracking-widest">{{ entries.length }} entrées affichées</span>
<span class="text-[10px] text-[#5a7a94]">{{ formatSize(fileSize) }} sur disque</span>
</div>
</div>
<!-- Confirm clear -->
<div v-if="showConfirm" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 max-w-sm w-full">
<h3 class="text-sm font-bold text-white mb-2">Effacer les logs ?</h3>
<p class="text-[11px] text-[#5a7a94] mb-6">Cette action est irréversible.</p>
<div class="flex gap-3">
<button @click="doClear"
class="bg-red-500 text-white px-5 py-2 text-xs tracking-widest hover:opacity-85 transition-opacity cursor-pointer">
EFFACER
</button>
<button @click="showConfirm = false"
class="border border-[#1e2d3d] text-[#5a7a94] px-5 py-2 text-xs tracking-widest hover:border-white/30 transition-colors cursor-pointer">
ANNULER
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const entries = ref<any[]>([])
const fileSize = ref(0)
const loading = ref(true)
const showConfirm = ref(false)
const filterLevel = ref('all')
const filterSource = ref('')
const limit = ref(100)
const levels = [
{ label: 'TOUS', value: 'all' },
{ label: 'INFO', value: 'info' },
{ label: 'WARN', value: 'warn' },
{ label: 'ERROR', value: 'error' },
{ label: 'DEBUG', value: 'debug' },
]
async function load() {
loading.value = true
try {
const params = new URLSearchParams({
limit : String(limit.value),
level : filterLevel.value,
...(filterSource.value ? { source: filterSource.value } : {})
})
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
if (res.ok) {
const data = await res.json()
entries.value = data.entries
fileSize.value = data.size
}
} finally {
loading.value = false
}
}
function confirmClear() { showConfirm.value = true }
async function doClear() {
showConfirm.value = false
await fetch('/api/logs/clear', { method: 'POST', credentials: 'include' })
toast('Logs effacés', 'success')
await load()
}
function formatDate(iso: string): string {
const d = new Date(iso)
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
function levelClass(level: string): string {
return {
debug: 'bg-[#1e2d3d] text-[#5a7a94]',
info : 'bg-blue-500/10 text-blue-400',
warn : 'bg-yellow-500/10 text-yellow-400',
error: 'bg-red-500/10 text-red-400',
}[level] ?? 'bg-[#1e2d3d] text-[#5a7a94]'
}
onMounted(load)
</script>
-135
View File
@@ -1,135 +0,0 @@
<template>
<div class="max-w-5xl mx-auto px-8 py-8">
<!-- Back -->
<button
@click="$router.push('/')"
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors mb-8 cursor-pointer"
>
← CATALOGUE
</button>
<!-- Loading -->
<div v-if="loading" class="text-[#5a7a94] text-sm tracking-widest py-20 text-center">
CHARGEMENT...
</div>
<template v-else-if="production">
<!-- Hero -->
<div class="flex gap-8 mb-10">
<!-- Poster -->
<div class="w-40 flex-shrink-0">
<img
v-if="production.cover"
:src="imageUrl(production.cover)"
:alt="production.name"
class="w-full object-cover"
/>
<div v-else class="w-full aspect-[2/3] bg-[#0d1219] flex items-center justify-center text-5xl">🎌</div>
</div>
<!-- Info -->
<div class="flex-1">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-2">FANKAI</div>
<h1 class="text-3xl font-black text-white font-sans mb-4">{{ production.name }}</h1>
<!-- Badges -->
<div class="flex flex-wrap gap-2 mb-4">
<span class="text-[10px] border border-[#1e2d3d] text-[#5a7a94] px-2 py-1 tracking-wider">
{{ production.status.toUpperCase() }}
</span>
<span class="text-[10px] border border-[#3a8fe8] text-[#3a8fe8] bg-[#3a8fe8]/10 px-2 py-1 tracking-wider">
{{ production.episodes_count }} FILMS
</span>
<span class="text-[10px] border border-[#1e2d3d] text-[#5a7a94] px-2 py-1 tracking-wider">
PAR {{ production.kaieur.toUpperCase() }}
</span>
</div>
<p class="text-sm text-[#5a7a94] leading-relaxed mb-6">{{ production.description }}</p>
<!-- Torrents globaux -->
<div v-if="torrents.length > 0">
<div class="text-[10px] text-[#5a7a94] tracking-widest mb-2">TORRENTS</div>
<div class="flex flex-col gap-2">
<a
v-for="t in torrents"
:key="t.url"
:href="t.url"
target="_blank"
class="flex items-center justify-between bg-[#0d1219] border border-[#1e2d3d] hover:border-[#e8513a] px-4 py-2.5 transition-colors group"
>
<span class="text-sm text-white">{{ t.name }}</span>
<span class="text-[10px] text-[#e8513a] tracking-widest group-hover:text-white transition-colors">
NYAA ↗
</span>
</a>
</div>
</div>
<!-- Lien wiki -->
<a
v-if="production.link"
:href="production.link"
target="_blank"
class="inline-block mt-4 text-[10px] text-[#5a7a94] hover:text-[#3a8fe8] tracking-widest transition-colors"
>
GUIDE DES ÉPISODES ↗
</a>
</div>
</div>
<!-- Saisons -->
<!-- Saisons -->
<div v-if="production.seasons.length > 0" class="space-y-6">
<div v-for="season in production.seasons" :key="season.id">
<div class="flex items-center gap-4 mb-3">
<div class="text-[10px] text-[#5a7a94] tracking-widest">SAISON {{ season.number }}</div>
<div class="text-sm font-bold text-white">{{ season.name }}</div>
<div class="flex-1 h-px bg-[#1e2d3d]"></div>
</div>
<div class="grid grid-cols-1 gap-px bg-[#1e2d3d]">
<div v-for="ep in season.episodes" :key="ep.id">
<EpisodeRow :ep="ep" :production-id="production.id" />
</div>
</div>
</div>
</div>
<!-- Episodes sans saison -->
<div v-else-if="production.episodes.length > 0">
<div class="flex items-center gap-4 mb-3">
<div class="text-sm font-bold text-white">ÉPISODES</div>
<div class="flex-1 h-px bg-[#1e2d3d]"></div>
</div>
<div class="grid grid-cols-1 gap-px bg-[#1e2d3d]">
<div v-for="ep in production.episodes" :key="ep.id">
<EpisodeRow :ep="ep" :production-id="production.id" />
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useCatalogStore } from '@/stores/catalog'
import { imageUrl, parseTorrents, type ProductionDetail } from '@/api/fankai'
import EpisodeRow from "@/components/EpisodeRow.vue";
const route = useRoute()
const store = useCatalogStore()
const production = ref<ProductionDetail | null>(null)
const loading = ref(true)
const torrents = computed(() => parseTorrents(production.value?.torrents ?? null))
onMounted(async () => {
const id = Number(route.params.id)
const name = route.query.name as string ?? ''
production.value = await store.loadProduction(id, name)
loading.value = false
})
</script>
+306 -256
View File
@@ -1,66 +1,82 @@
<template>
<div class="min-h-screen bg-zinc-950 text-zinc-100">
<!-- Back -->
<div class="max-w-7xl mx-auto px-6 pt-6">
<router-link to="/" class="inline-flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-100 transition">
<svg width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
Catalogue
</router-link>
</div>
<div>
<!-- Loading -->
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-sm">Chargement…</p>
</div>
<!-- Error -->
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
<p class="text-sm">{{ store.error }}</p>
<button class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm" @click="load">Réessayer</button>
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
<p class="text-sm text-red-400">{{ store.error }}</p>
<button class="btn-primary" @click="load">Réessayer</button>
</div>
<template v-else-if="data">
<!-- Hero -->
<!-- Hero avec fanart -->
<div class="relative">
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none">
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20"
@error="e => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'" />
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-64 overflow-hidden pointer-events-none">
<img
:src="data.serie.fanart_image"
class="w-full h-full object-cover opacity-15"
@error="(e) => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-main" />
</div>
<div class="relative max-w-7xl mx-auto px-6 pt-8 pb-10 flex gap-8">
<div class="relative px-4 md:px-8 pt-6 pb-10 flex gap-6 min-h-[200px]">
<!-- Poster -->
<div class="shrink-0 w-36 rounded-xl overflow-hidden border border-white/10 shadow-2xl hidden sm:block">
<div class="shrink-0 w-32 rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" />
<div v-else class="w-full aspect-[2/3] bg-zinc-900 flex items-center justify-center text-zinc-700">
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="20" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/>
</svg>
<div v-else class="w-full aspect-[2/3] bg-card flex items-center justify-center text-muted">
<Tv :size="28" />
</div>
</div>
<!-- Info -->
<!-- Infos -->
<div class="flex flex-col gap-3 justify-end min-w-0">
<h1 class="text-3xl font-bold tracking-tight">{{ data.serie.title }}</h1>
<!-- Retour -->
<RouterLink to="/series" class="inline-flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-fit">
<ArrowLeft :size="13" />
Séries
</RouterLink>
<!-- Titre + bouton import manuel -->
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ data.serie.title }}</h1>
<button
@click="openManualImport"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted hover:text-primary hover:border-secondary transition shrink-0"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Import manuel
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<span v-if="data.serie.year" class="text-sm text-zinc-400">{{ data.serie.year }}</span>
<span v-if="data.serie.year" class="text-sm text-muted">{{ data.serie.year }}</span>
<span
v-if="data.serie.status"
class="text-xs px-2 py-0.5 rounded-full font-medium border"
class="text-xs px-2 py-0.5 rounded-full border"
:class="data.serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/15 text-green-400 border-green-500/20'
: 'bg-white/5 text-zinc-500 border-white/10'"
>{{ data.serie.status }}</span>
: 'bg-hover text-muted border-border'"
>
{{ data.serie.status }}
</span>
</div>
<p v-if="data.serie.plot" class="text-sm text-zinc-400 leading-relaxed max-w-2xl line-clamp-3">
<p v-if="data.serie.plot" class="text-sm text-secondary leading-relaxed max-w-2xl line-clamp-3">
{{ data.serie.plot }}
</p>
<!-- Boutons hero : intégrale, pack_saison promu, pack_episodes -->
<!-- Boutons intégrale -->
<div class="flex flex-wrap gap-2 mt-1">
<button
v-for="(t, i) in data.torrents_integrale"
@@ -69,271 +85,273 @@
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
:class="[
t.label === 'Intégrale'
? 'bg-orange-500 hover:bg-orange-600 text-white'
: 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20',
(downloading.has(`integrale-${i}`) || downloaded.has(`integrale-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''
? 'bg-accent text-white hover:bg-accent-hover'
: 'bg-accent-muted text-accent border border-accent/20 hover:bg-accent/20',
(isDownloading(`integrale-${i}`) || isDownloaded(`integrale-${i}`) || isAlreadyQueued(t))
? 'opacity-50 cursor-not-allowed' : ''
]"
:disabled="downloading.has(`integrale-${i}`) || downloaded.has(`integrale-${i}`) || isAlreadyQueued(t)"
:disabled="isDownloading(`integrale-${i}`) || isDownloaded(`integrale-${i}`) || isAlreadyQueued(t)"
@click="download(`integrale-${i}`, t.torrent_url, t.magnet)"
>
<DownloadIcon :spinning="downloading.has(`integrale-${i}`)" />
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : downloaded.has(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
<EpStateIcon :state="epBtnState(`integrale-${i}`, t)" />
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : isDownloaded(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
</button>
</div>
<!-- Barre progression intégrale -->
<!-- Progression intégrale -->
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
<template v-if="torrentProgress(extractHash(t))">
<span v-if="torrentProgress(extractHash(t))!.progress === 100"
class="mt-2 inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ Téléchargé
</span>
<div v-else class="mt-2 w-full max-w-sm">
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
<span>Téléchargement</span>
<span>{{ torrentProgress(extractHash(t))!.progress }}%</span>
</div>
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(t))!.progress}%` }" />
</div>
</div>
</template>
<ProgressBar :progress="torrentProgress(extractHash(t))?.progress" />
</template>
</div>
</div>
</div>
<!-- Seasons -->
<div class="max-w-7xl mx-auto px-6 pb-16 flex flex-col gap-6">
<!-- Saisons -->
<div class="px-4 md:px-8 pb-16 flex flex-col gap-4">
<div
v-for="season in data.seasons"
:key="season.id"
class="bg-zinc-900/50 border border-white/5 rounded-2xl overflow-hidden"
class="bg-card border border-border rounded-xl overflow-hidden"
>
<!-- Season header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-white/5">
<!-- Header saison -->
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
<div class="flex items-center gap-3">
<button class="text-zinc-500 hover:text-zinc-100 transition" @click="toggleSeason(season.id)">
<svg
width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
<button class="text-muted hover:text-primary transition-colors" @click="toggleSeason(season.id)">
<ChevronUp
:size="15"
class="transition-transform duration-200"
:class="collapsedSeasons.has(season.id) ? '' : 'rotate-180'"
>
<path d="M18 15l-6-6-6 6"/>
</svg>
:class="collapsedSeasons.has(season.id) ? 'rotate-180' : ''"
/>
</button>
<div>
<h2 class="font-semibold text-sm">
<h2 class="text-sm font-semibold text-primary">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-zinc-500 font-normal ml-1">
<span
v-if="season.title && season.title !== `Saison ${season.season_number}`"
class="text-muted font-normal ml-1"
>
— {{ season.title }}
</span>
</h2>
<p class="text-xs text-zinc-600 mt-0.5">
<p class="text-xs text-muted mt-0.5">
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
<span v-if="season.organized_state !== 'none'"
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px] font-medium"
:class="season.organized_state === 'complete'
? 'bg-green-500/10 text-green-400'
: 'bg-yellow-500/10 text-yellow-500'">
<template v-if="seasonAvailableCount(season) > 0">
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
</template>
<span
v-if="season.organized_state !== 'none'"
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px]"
:class="season.organized_state === 'complete'
? 'bg-green-500/10 text-green-400'
: 'bg-yellow-500/10 text-yellow-500'"
>
{{ season.organized_state === 'complete'
? `✓ ${season.organized_count} organisé${season.organized_count > 1 ? 's' : ''}`
: `${season.organized_count}/${seasonAvailableCount(season)} organisés` }}
? `✓ ${season.organized_count} importé${season.organized_count > 1 ? 's' : ''}`
: `${season.organized_count}/${seasonAvailableCount(season) || season.episodes.length} importés` }}
</span>
</p>
</div>
</div>
<!-- Bouton pack_saison sur le header -->
<!-- Bouton pack saison -->
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
:class="[
season.torrent && !downloaded.has(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
]"
:disabled="!season.torrent || downloading.has(`season-${season.id}`) || downloaded.has(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="season.torrent && !isDownloaded(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
: 'text-muted border-border cursor-not-allowed opacity-50'"
:disabled="!season.torrent || isDownloading(`season-${season.id}`) || isDownloaded(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
@click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<DownloadIcon :spinning="downloading.has(`season-${season.id}`)" :size="12" />
{{ season.organized_state === 'complete' ? 'Organisé ✓' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : downloaded.has(`season-${season.id}`) ? 'Envoyé ✓' : 'Saison entière' }}
<EpStateIcon :state="seasonBtnState(season)" :size="12" />
{{ season.organized_state === 'complete' ? 'Importé' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : isDownloaded(`season-${season.id}`) ? 'Envoyé' : 'Saison entière' }}
</button>
</div>
<!-- Barre progression saison -->
<template v-if="torrentProgress(extractHash(season.torrent))">
<div v-if="torrentProgress(extractHash(season.torrent))!.progress === 100"
class="px-5 py-2 border-b border-white/5">
<span class="inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ Téléchargé
</span>
<!-- Progression saison -->
<div
v-if="torrentProgress(extractHash(season.torrent)) && torrentProgress(extractHash(season.torrent))!.progress < 100"
class="px-5 pb-3 -mt-1"
>
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<div
class="h-full bg-accent rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(season.torrent))!.progress}%` }"
/>
</div>
<div v-else class="px-5 py-2 border-b border-white/5">
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
<span>Téléchargement en cours</span>
<span>{{ torrentProgress(extractHash(season.torrent))!.progress }}%</span>
</div>
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(season.torrent))!.progress}%` }" />
</div>
</div>
</template>
<p class="text-[11px] text-muted mt-1">{{ torrentProgress(extractHash(season.torrent))!.progress }}%</p>
</div>
<!-- Episodes -->
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-white/[0.04]">
<!-- Épisodes -->
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-border/50">
<div
v-for="ep in season.episodes"
:key="ep.id"
class="px-5 py-3 hover:bg-white/[0.02] transition"
class="px-5 py-3 hover:bg-hover/50 transition-colors"
:class="{ 'opacity-40': !ep.available && !ep.organized }"
>
<!-- Ligne principale -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-3">
<!-- Thumbnail -->
<div class="shrink-0 w-24 aspect-video rounded-lg overflow-hidden bg-zinc-800 hidden sm:block">
<div class="shrink-0 w-20 aspect-video rounded-md overflow-hidden bg-shell hidden sm:flex items-center justify-center text-muted text-xs font-mono">
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700 text-xs font-mono">
{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}
</div>
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
</div>
<!-- Info -->
<!-- Infos épisode -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-xs text-zinc-600 shrink-0 font-mono">
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
<span class="text-xs text-muted shrink-0 font-mono">
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
</span>
<span class="text-sm text-primary truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
<span
v-if="ep.fankai === false || ep.torrent?.fankai === false"
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-purple-500/10 text-purple-400 border-purple-500/20"
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
>
Hors Fankai
</span>
<span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
</div>
<div class="flex items-center gap-3 mt-0.5">
<span v-if="ep.aired" class="text-xs text-zinc-600">{{ formatDate(ep.aired) }}</span>
<span v-if="ep.duration" class="text-xs text-zinc-600">{{ ep.duration }} min</span>
<div class="flex items-center gap-2 mt-0.5">
<span v-if="ep.aired" class="text-xs text-muted">{{ formatDate(ep.aired) }}</span>
<span v-if="ep.duration" class="text-xs text-muted">{{ formatDuration(ep.duration) }}</span>
</div>
</div>
<!-- Badges + bouton download -->
<div class="shrink-0 flex items-center gap-2">
<!-- Bouton état unique -->
<button
class="shrink-0 w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
:class="epBtnClass(ep)"
:disabled="!ep.torrent || ep.organized || isAlreadyQueued(ep.torrent)"
@click="ep.torrent && !ep.organized && !isAlreadyQueued(ep.torrent) && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
>
<EpStateIcon :state="epState(ep)" :size="14" />
</button>
<!-- Badge Hors Fankai (torrent manuel) -->
<span
v-if="ep.torrent?.manual"
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
class="text-[10px] px-2 py-0.5 rounded-full border bg-purple-500/10 text-purple-400 border-purple-500/20"
>
Hors Fankai
</span>
</div>
<!-- Badge organisé -->
<span v-if="ep.organized"
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ organisé
</span>
<!-- Badge téléchargé à 100% -->
<span v-if="torrentProgress(extractHash(ep.torrent))?.progress === 100"
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ téléchargé
</span>
<!-- Badge dispo/indispo -->
<span
class="inline-block text-[10px] px-2 py-0.5 rounded-full border"
:class="ep.available
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
: 'bg-white/5 text-zinc-600 border-white/8'"
>
{{ ep.available ? 'dispo' : 'indispo' }}
</span>
<!-- Bouton download -->
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
:class="[
ep.torrent && !downloaded.has(`ep-${ep.id}`) && !isAlreadyQueued(ep.torrent) && !ep.organized
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
]"
:disabled="!ep.torrent || downloading.has(`ep-${ep.id}`) || downloaded.has(`ep-${ep.id}`) || isAlreadyQueued(ep.torrent) || ep.organized"
@click="ep.torrent && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
>
<DownloadIcon :spinning="downloading.has(`ep-${ep.id}`)" :size="12" />
{{ ep.organized ? '✓' : isAlreadyQueued(ep.torrent) ? '✓' : downloaded.has(`ep-${ep.id}`) ? '✓' : 'Ep.' }}
</button>
<!-- Barre progression épisode -->
<div
v-if="torrentProgress(extractHash(ep.torrent)) && torrentProgress(extractHash(ep.torrent))!.progress < 100"
class="mt-1.5 sm:pl-[92px]"
>
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<div
class="h-full bg-accent rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(ep.torrent))!.progress}%` }"
/>
</div>
</div>
<!-- Barre progression épisode (seulement si < 100%) -->
<div v-if="torrentProgress(extractHash(ep.torrent)) && torrentProgress(extractHash(ep.torrent))!.progress < 100"
class="mt-2 sm:pl-28">
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
<span>Téléchargement</span>
<span>{{ torrentProgress(extractHash(ep.torrent))!.progress }}%</span>
</div>
<div class="h-0.5 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(ep.torrent))!.progress}%` }" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Toast -->
<Transition name="toast">
<div
v-if="toast"
class="fixed bottom-6 right-6 flex items-center gap-3 px-4 py-3 rounded-xl shadow-2xl text-sm font-medium z-50"
:class="toast.type === 'success'
? 'bg-zinc-800 border border-green-500/30 text-green-400'
: 'bg-zinc-800 border border-red-500/30 text-red-400'"
>
<svg v-if="toast.type === 'success'" width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path d="M20 6 9 17l-5-5"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
</svg>
{{ toast.message }}
</div>
</Transition>
<!-- Modal import manuel -->
<ManualImportModal
v-if="manualImportOpen && data"
:serie-id="Number(route.params.id)"
:serie-name="data.serie.title"
:seasons="data.seasons"
:organized="organizedByEpisode"
:initial-path="mediaPath"
@close="manualImportOpen = false"
@imported="load(); fetchOrganized(); manualImportOpen = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue'
import { useRoute } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
import { ArrowLeft, Tv, ChevronUp, Download, Loader, Check, X } from 'lucide-vue-next'
import { useSeriesStore } from '@/stores/series'
import { useToast } from '@/composables/useToast'
import ManualImportModal from '@/components/ManualImportModal.vue'
import type { Season } from '@/stores/series'
// ── Composant icône download ──────────────────────────────────
const DownloadIcon = defineComponent({
props: { spinning: Boolean, size: { type: Number, default: 14 } },
// ── Icône d'état du bouton ────────────────────────────────────
const EpStateIcon = defineComponent({
props: {
state: { type: String, default: 'idle' },
size : { type: Number, default: 14 },
},
setup(props) {
return () => props.spinning
? h('div', { class: `w-${props.size === 12 ? '3' : '3.5'} h-${props.size === 12 ? '3' : '3.5'} border border-current/30 border-t-current rounded-full animate-spin` })
: h('svg', { width: props.size, height: props.size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
h('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3' })
])
}
return () => {
if (props.state === 'loading') return h(Loader, { size: props.size, class: 'animate-spin' })
if (props.state === 'done') return h(Check, { size: props.size })
if (props.state === 'unavailable') return h(X, { size: props.size })
return h(Download, { size: props.size })
}
},
})
// ── Setup ─────────────────────────────────────────────────────
const route = useRoute()
const store = useSeriesStore()
// ── Barre de progression ──────────────────────────────────────
const ProgressBar = defineComponent({
props: { progress: { type: Number, default: null } },
setup(props) {
return () => {
if (props.progress == null || props.progress >= 100) return null
return h('div', { class: 'h-0.5 bg-border rounded-full overflow-hidden' }, [
h('div', {
class: 'h-full bg-accent rounded-full transition-all duration-500',
style: { width: `${props.progress}%` },
}),
])
}
},
})
const collapsedSeasons = ref<Set<number>>(new Set())
const downloading = ref<Set<string>>(new Set())
const downloaded = ref<Set<string>>(new Set())
const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null)
const route = useRoute()
const store = useSeriesStore()
const { add: toast } = useToast()
// ── Progression downloads ──────────────────────────────────────
interface ActiveTorrent { hash: string; progress: number; state: string; name: string }
const collapsedSeasons = ref<Set<number>>(new Set())
const downloading = ref<string[]>([])
const downloaded = ref<string[]>([])
const manualImportOpen = ref(false)
const organizedByEpisode = ref<Record<string, any>>({})
const mediaPath = ref('/')
function isDownloading(key: string) { return downloading.value.includes(key) }
function isDownloaded(key: string) { return downloaded.value.includes(key) }
function addDownloading(key: string) { if (!downloading.value.includes(key)) downloading.value.push(key) }
function removeDownloading(key: string) { downloading.value = downloading.value.filter(k => k !== key) }
function addDownloaded(key: string) { if (!downloaded.value.includes(key)) downloaded.value.push(key) }
interface ActiveTorrent { hash: string; progress: number; state: string }
const activeTorrents = ref<ActiveTorrent[]>([])
let pollTimer: ReturnType<typeof setInterval> | null = null
const data = computed(() => store.currentSerie)
async function fetchOrganized() {
try {
const res = await fetch(`/api/organized/${route.params.id}`, { credentials: 'include' })
if (res.ok) organizedByEpisode.value = await res.json()
} catch {}
}
async function fetchSettings() {
try {
const res = await fetch('/api/settings', { credentials: 'include' })
if (res.ok) {
const s = await res.json()
mediaPath.value = s.mediaPath || '/'
}
} catch {}
}
function openManualImport() {
fetchOrganized()
manualImportOpen.value = true
}
// ── Polling downloads ─────────────────────────────────────────
async function fetchActiveDownloads() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
@@ -343,7 +361,6 @@ async function fetchActiveDownloads() {
hash : t.hash,
progress: t.progress ?? 0,
state : t.state,
name : t.name,
}))
} catch {}
}
@@ -353,18 +370,58 @@ function torrentProgress(hash: string | null | undefined): ActiveTorrent | null
return activeTorrents.value.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
}
function extractHash(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): string | null {
if (!torrent) return null
const magnet = torrent.magnet ?? ''
const m = magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
function extractHash(torrent: { magnet?: string | null } | null | undefined): string | null {
if (!torrent?.magnet) return null
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
return m ? m[1].toLowerCase() : null
}
// ── Data ───────────────────────────────────────────────────────
const data = computed(() => store.currentSerie)
function isAlreadyQueued(torrent: { magnet?: string | null } | null | undefined): boolean {
const hash = extractHash(torrent)
if (!hash) return false
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
// ── États boutons ─────────────────────────────────────────────
type BtnState = 'idle' | 'loading' | 'done' | 'unavailable'
function epState(ep: any): BtnState {
if (ep.organized) return 'done'
if (isDownloaded(`ep-${ep.id}`)) return 'done'
if (isAlreadyQueued(ep.torrent)) return 'done'
const prog = torrentProgress(extractHash(ep.torrent))
if (prog && prog.progress < 100) return 'loading'
if (!ep.torrent || !ep.available) return 'unavailable'
if (isDownloading(`ep-${ep.id}`)) return 'loading'
return 'idle'
}
function epBtnState(key: string, torrent: any): BtnState {
if (isDownloaded(key) || isAlreadyQueued(torrent)) return 'done'
if (isDownloading(key)) return 'loading'
return 'idle'
}
function seasonBtnState(season: any): BtnState {
if (season.organized_state === 'complete') return 'done'
if (isDownloaded(`season-${season.id}`)) return 'done'
if (isAlreadyQueued(season.torrent)) return 'done'
if (isDownloading(`season-${season.id}`)) return 'loading'
if (!season.torrent) return 'unavailable'
return 'idle'
}
function epBtnClass(ep: any): string {
const state = epState(ep)
if (state === 'done') return 'border-green-500/30 text-green-500 bg-green-500/10 cursor-default'
if (state === 'loading') return 'border-accent/30 text-accent bg-accent-muted cursor-default'
if (state === 'unavailable') return 'border-border text-muted opacity-40 cursor-not-allowed'
return 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'
}
// ── Actions ───────────────────────────────────────────────────
function seasonAvailableCount(season: Season) {
return season.episodes.filter(e => e.available).length
return season.episodes.filter((e: any) => e.available).length
}
function toggleSeason(id: number) {
@@ -372,33 +429,17 @@ function toggleSeason(id: number) {
else collapsedSeasons.value.add(id)
}
function formatDate(d: string) {
if (!d) return ''
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
}
function showToast(type: 'success' | 'error', message: string) {
toast.value = { type, message }
setTimeout(() => { toast.value = null }, 3500)
}
function isAlreadyQueued(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): boolean {
const hash = extractHash(torrent)
if (!hash) return false
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
async function download(key: string, torrent_url: string | null, magnet: string | null) {
if (downloading.value.has(key) || downloaded.value.has(key)) return
downloading.value = new Set([...downloading.value, key])
if (isDownloading(key) || isDownloaded(key)) return
addDownloading(key)
const result = await store.download(torrent_url, magnet)
downloading.value = new Set([...downloading.value].filter(k => k !== key))
removeDownloading(key)
if (result.success) {
downloaded.value = new Set([...downloaded.value, key])
showToast('success', 'Téléchargement lancé')
addDownloaded(key)
toast('Téléchargement lancé ✓', 'success')
fetchActiveDownloads()
} else {
showToast('error', result.error ?? 'Erreur inconnue')
toast(result.error ?? 'Erreur inconnue', 'error')
}
}
@@ -406,8 +447,22 @@ function load() {
store.fetchSerieDetail(Number(route.params.id))
}
function formatDate(d: string): string {
if (!d) return ''
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
}
function formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return ''
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return m > 0 ? `${h}h ${m}min` : `${h}h`
return `${m} min`
}
onMounted(() => {
load()
fetchSettings()
fetchActiveDownloads()
pollTimer = setInterval(fetchActiveDownloads, 5000)
})
@@ -415,9 +470,4 @@ onMounted(() => {
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<style scoped>
.toast-enter-active, .toast-leave-active { transition: all 0.25s ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(6px); }
</style>
</script>
+323
View File
@@ -0,0 +1,323 @@
<template>
<div class="flex flex-col">
<!-- Toolbar -->
<div class="shrink-0 px-4 md:px-6 py-3 border-b border-border flex flex-col gap-2">
<!-- Ligne 1 : recherche + filtres -->
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
<input
v-model="search"
type="text"
placeholder="Rechercher…"
class="settings-input pl-9 w-full py-1.5 text-sm"
/>
</div>
<!-- Filtres : icône only sur mobile, texte sur desktop -->
<div class="relative shrink-0" ref="filterRef">
<button
@click="filterOpen = !filterOpen"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 relative"
:class="hasActiveFilter ? 'border-accent text-accent' : ''"
title="Filtres"
>
<SlidersHorizontal :size="15" />
<span class="hidden md:inline text-xs">Filtres</span>
<ChevronDown class="hidden md:inline transition-transform" :size="12" :class="filterOpen ? 'rotate-180' : ''" />
<span v-if="hasActiveFilter" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
</button>
<div
v-if="filterOpen"
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-3 z-20 w-52 flex flex-col gap-3 shadow-xl"
>
<div>
<p class="text-xs text-muted mb-1.5">Disponibilité</p>
<div class="flex flex-col gap-1">
<button
v-for="f in filtersDisponibilite" :key="f.value"
@click="activeFilter = f.value"
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
{{ f.label }}
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
</button>
</div>
</div>
<div class="border-t border-border pt-3">
<p class="text-xs text-muted mb-1.5">État import</p>
<div class="flex flex-col gap-1">
<button
v-for="f in filtersImport" :key="f.value"
@click="activeFilter = f.value"
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full shrink-0" :style="{ background: f.color }" />
{{ f.label }}
</div>
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
</button>
</div>
</div>
<button
v-if="hasActiveFilter"
@click="activeFilter = 'all'"
class="text-xs text-muted hover:text-primary transition-colors text-left pt-1 border-t border-border"
>
Effacer les filtres
</button>
</div>
</div>
</div>
<!-- Ligne 2 : tri + taille + count -->
<div class="flex items-center gap-2 flex-wrap">
<div class="flex items-center gap-1">
<button
v-for="s in sortOptions" :key="s.value"
@click="activeSort = s.value"
class="px-2.5 py-1 text-xs rounded-lg border transition-colors"
:class="activeSort === s.value
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ s.label }}
</button>
</div>
<div class="flex items-center gap-1 border-l border-border pl-2 ml-auto">
<button
v-for="(_, size) in posterSizes" :key="size"
@click="posterSize = size"
class="w-7 h-7 text-[10px] rounded-md border transition-colors flex items-center justify-center"
:class="posterSize === size
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ size }}
</button>
</div>
<span class="text-xs text-muted">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
</div>
</div>
<!-- Légende barres -->
<div class="shrink-0 px-4 md:px-6 py-2 flex items-center gap-4 border-b border-border">
<div v-for="l in legend" :key="l.label" class="flex items-center gap-1.5">
<span class="w-3 h-1.5 rounded-full" :style="{ background: l.color }" />
<span class="text-[11px] text-muted">{{ l.label }}</span>
</div>
</div>
<!-- Grille -->
<div class="px-4 md:px-6 py-6">
<div v-if="store.loadingSeries" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-sm">Chargement du catalogue…</p>
</div>
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
<p class="text-sm text-red-400">{{ store.error }}</p>
<button class="btn-primary" @click="store.fetchSeries()">Réessayer</button>
</div>
<div v-else-if="filtered.length === 0" class="flex items-center justify-center h-64 text-muted text-sm">
Aucune série trouvée
</div>
<div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }">
<RouterLink
v-for="serie in filtered"
:key="serie.id"
:to="`/series/${serie.id}`"
class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5"
:class="{ 'opacity-40': !serie.has_torrents && serie.download_state === 'none' }"
>
<div class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border border-border">
<img
v-if="serie.poster_image"
:src="serie.poster_image"
:alt="serie.title"
loading="lazy"
class="w-full h-full object-cover"
/>
<div v-else class="w-full h-full flex items-center justify-center text-muted">
<Tv :size="28" />
</div>
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<span class="text-xs text-white border border-white/30 px-3 py-1 rounded-md">Voir</span>
</div>
<div
class="absolute bottom-0 left-0 right-0 h-1"
:style="{ background: stateColor(serie.download_state) }"
/>
</div>
<div>
<p class="text-xs font-medium truncate leading-snug text-primary">{{ serie.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5">
<span v-if="serie.year" class="text-[11px] text-muted">{{ serie.year }}</span>
<span
v-if="serie.status"
class="text-[10px] px-1.5 py-0.5 rounded"
:class="serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/10 text-green-400'
: 'bg-hover text-muted'"
>
{{ serie.status }}
</span>
</div>
</div>
</RouterLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
import { RouterLink } from 'vue-router'
import { Search, SlidersHorizontal, ChevronDown, Tv } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
import { useSeriesStore } from '@/stores/series'
import { useDownloadsStore } from '@/stores/downloads'
import { useToast } from '@/composables/useToast'
import { usePosterSize } from '@/composables/usePosterSize'
defineOptions({ name: 'SeriesView' })
const store = useSeriesStore()
const dlStore = useDownloadsStore()
const { add: toast } = useToast()
const { current: posterSize, sizes: posterSizes } = usePosterSize()
const search = ref('')
const activeFilter = ref('all')
const activeSort = ref('alpha')
const filterOpen = ref(false)
const filterRef = ref<HTMLElement | null>(null)
onClickOutside(filterRef, () => { filterOpen.value = false })
const filtersDisponibilite = [
{ label: 'Toutes', value: 'all' },
{ label: 'Disponibles', value: 'available' },
{ label: 'Sans torrent', value: 'unavailable' },
]
const filtersImport = [
{ label: 'Importés', value: 'complete', color: '#22c55e' },
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
{ label: 'Partiel', value: 'partial', color: '#9b59b6' },
]
const sortOptions = [
{ label: 'A→Z', value: 'alpha' },
{ label: 'Z→A', value: 'alpha-desc' },
{ label: 'Récents', value: 'recent' },
{ label: 'Importés', value: 'imported' },
]
const legend = [
{ label: 'Importé', color: '#22c55e' },
{ label: 'En cours', color: '#3b82f6' },
{ label: 'Partiel', color: '#9b59b6' },
{ label: 'Non importé', color: 'var(--border)' },
]
const hasActiveFilter = computed(() => activeFilter.value !== 'all')
const filtered = computed(() => {
let list = [...store.series]
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
if (activeFilter.value === 'downloading') list = list.filter(s => ['downloading', 'partial'].includes(s.download_state))
if (activeFilter.value === 'partial') list = list.filter(s => s.download_state === 'partial')
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(s => s.title.toLowerCase().includes(q))
}
switch (activeSort.value) {
case 'alpha': list.sort((a, b) => a.title.localeCompare(b.title, 'fr')); break
case 'alpha-desc': list.sort((a, b) => b.title.localeCompare(a.title, 'fr')); break
case 'recent': list.sort((a, b) => (b.year ?? 0) - (a.year ?? 0)); break
case 'imported': {
const order: Record<string, number> = { complete: 0, partial: 1, downloading: 2, none: 3 }
list.sort((a, b) => (order[a.download_state] ?? 3) - (order[b.download_state] ?? 3))
break
}
}
return list
})
function stateColor(state: string): string {
return ({
complete : '#22c55e',
downloading: '#3b82f6',
partial : '#9b59b6',
none : 'transparent',
} as Record<string, string>)[state] ?? 'transparent'
}
// ── Polling downloads + notifs import ────────────────────────
let dlInterval: ReturnType<typeof setInterval> | null = null
const seenNotifs = new Set<string>()
async function fetchOrganizeNotifs() {
try {
const res = await fetch('/api/organize/recent', { credentials: 'include' })
if (!res.ok) return
const notifs: any[] = await res.json()
let hasNew = false
for (const n of notifs) {
const key = `${n.hash}-${n.at}`
if (seenNotifs.has(key)) continue
seenNotifs.add(key)
hasNew = true
if (n.done > 0) {
const msg = n.errors > 0
? `${n.name} — ${n.done} fichier(s) importé(s), ${n.errors} erreur(s)`
: `${n.name} — ${n.done} fichier(s) importé(s) ✓`
toast(msg, n.errors > 0 ? 'error' : 'success')
}
}
if (hasNew) await store.fetchSeries()
} catch {}
}
onMounted(async () => {
if (store.series.length === 0) await store.fetchSeries()
dlStore.refresh()
fetchOrganizeNotifs()
dlInterval = setInterval(() => {
dlStore.refresh()
fetchOrganizeNotifs()
}, 10000)
})
onActivated(async () => {
store.fetchSeries()
dlStore.refresh()
})
onUnmounted(() => {
if (dlInterval) clearInterval(dlInterval)
})
</script>
-515
View File
@@ -1,515 +0,0 @@
<template>
<div class="max-w-2xl mx-auto px-8 py-8">
<!-- Header -->
<div class="flex items-center gap-4 mb-8">
<button
@click="$router.push('/')"
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
← CATALOGUE
</button>
<h1 class="text-xl font-black text-white font-sans">Paramètres</h1>
</div>
<!-- Bannière données manquantes -->
<div v-if="torrentsStatus.empty" class="border border-[#e8513a]/40 bg-[#e8513a]/5 p-4 mb-6 flex items-center justify-between gap-4">
<div>
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES MANQUANTES</div>
<p class="text-sm text-[#a0b4c4]">
{{ torrentsStatus.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
Téléchargez les données pour utiliser Fankarr.
</p>
</div>
<button @click="update" :disabled="updating"
class="bg-[#e8513a] text-white px-5 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer whitespace-nowrap">
{{ updating ? 'TÉLÉCHARGEMENT...' : 'TÉLÉCHARGER' }}
</button>
</div>
<!-- Données torrents -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES</div>
<h2 class="text-sm font-bold text-white mb-1">Catalogue Fankai</h2>
<p class="text-[11px] text-[#5a7a94] mb-5">
{{ torrentsStatus.empty ? 'Aucune donnée chargée' : `${torrentsStatus.count} séries disponibles` }}
</p>
<button @click="update" :disabled="updating"
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
{{ updating ? 'MISE À JOUR...' : 'METTRE À JOUR' }}
</button>
</div>
<!-- Clients torrent -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">TÉLÉCHARGEMENT</div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-sm font-bold text-white">Clients torrent</h2>
<button @click="openAddModal"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
+ AJOUTER
</button>
</div>
<div v-if="clients.length === 0" class="text-[11px] text-[#5a7a94]">
Aucun client configuré
</div>
<div v-else class="flex flex-col gap-2">
<div v-for="client in clients" :key="client.uuid"
class="flex items-center justify-between border border-[#1e2d3d] px-4 py-3">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full"
:class="healthStatus[client.uuid] === true ? 'bg-green-500' :
healthStatus[client.uuid] === false ? 'bg-red-500' : 'bg-[#1e2d3d]'"/>
<div>
<p class="text-sm text-white font-medium">{{ client.name }}</p>
<p class="text-[10px] text-[#5a7a94] tracking-widest mt-0.5">{{ clientLabel(client.type) }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="testClient(client.uuid)"
class="text-[10px] text-[#5a7a94] tracking-widest hover:text-[#3a8fe8] transition-colors cursor-pointer px-2 py-1">
TESTER
</button>
<button @click="deleteClient(client.uuid)"
class="text-[10px] text-[#5a7a94] tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer px-2 py-1">
SUPPRIMER
</button>
</div>
</div>
</div>
</div>
<!-- Médias & Import -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">MÉDIAS</div>
<h2 class="text-sm font-bold text-white mb-6">Import</h2>
<div class="flex flex-col gap-5">
<!-- Dossier complétion -->
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER TÉLÉCHARGEMENTS</label>
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<button @click="openPicker('completePath')"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate"
:class="form.completePath ? 'text-white' : 'text-[#5a7a94]'">
{{ form.completePath || '/' }}
</button>
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
</div>
<!-- Dossier médiathèque -->
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="text-[10px] text-[#5a7a94] tracking-widest">MÉDIATHÈQUE FANKAI</label>
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<button @click="openPicker('mediaPath')"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate"
:class="form.mediaPath ? 'text-white' : 'text-[#5a7a94]'">
{{ form.mediaPath || '/' }}
</button>
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier racine de votre médiathèque Fankai (Jellyfin, Kodi, Plex…)</p>
</div>
<!-- Mode import -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">MODE D'IMPORT</label>
<div class="flex gap-2">
<button @click="form.organizeMode = 'hardlink'"
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
:class="form.organizeMode === 'hardlink'
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
HARDLINK
</button>
<button @click="form.organizeMode = 'move'"
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
:class="form.organizeMode === 'move'
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
DÉPLACER
</button>
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
<span class="text-white">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
</p>
</div>
<!-- Import automatique -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">IMPORT AUTOMATIQUE</label>
<div class="flex items-center gap-3">
<button @click="form.autoImport = !form.autoImport"
class="relative shrink-0 w-11 h-6 rounded-full transition-colors cursor-pointer"
:class="form.autoImport ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
<span class="absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="form.autoImport ? 'translate-x-5' : 'translate-x-0'" />
</button>
<span class="text-xs text-[#c8d8e8]">Importer automatiquement dès qu'un téléchargement est terminé</span>
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
Vérifie toutes les 5 minutes si de nouveaux fichiers sont prêts à être importés
</p>
</div>
<!-- Titres Plex -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">NOMS DE DOSSIERS</label>
<div class="flex items-center gap-3">
<button @click="form.usePlexTitles = !form.usePlexTitles"
class="relative shrink-0 w-11 h-6 rounded-full transition-colors cursor-pointer"
:class="form.usePlexTitles ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
<span class="absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="form.usePlexTitles ? 'translate-x-5' : 'translate-x-0'" />
</button>
<span class="text-xs text-[#c8d8e8]">Utiliser les titres optimisés Plex</span>
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
Utilise <span class="text-white">title_for_plex</span> pour les noms de dossiers —
évite les caractères spéciaux (ex: <span class="text-white">Kaguya-sama - Love is War Henshū</span>
au lieu de <span class="text-white">Kaguya-sama : Love is War Henshū</span>)
</p>
</div>
<!-- Support NFO -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">NFO / MÉTADONNÉES</label>
<div class="flex items-center gap-3">
<button @click="form.nfoSupport = !form.nfoSupport"
class="relative shrink-0 w-11 h-6 rounded-full transition-colors cursor-pointer"
:class="form.nfoSupport ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
<span class="absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="form.nfoSupport ? 'translate-x-5' : 'translate-x-0'" />
</button>
<span class="text-xs text-[#c8d8e8]">Télécharger les NFO et images depuis GitLab</span>
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
Pour les lecteurs sans agent Fankai (Kodi, Infuse, etc.) — télécharge les métadonnées depuis
<span class="text-white">fankai_pack</span> lors de l'import
</p>
</div>
<div class="pt-1 flex items-center gap-3">
<button type="button" @click="save" :disabled="saving"
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
{{ saving ? '...' : 'SAUVEGARDER' }}
</button>
<button type="button" @click="scan" :disabled="scanning"
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors disabled:opacity-50 cursor-pointer">
{{ scanning ? 'SCAN...' : 'ANALYSER MÉDIATHÈQUE' }}
</button>
<span v-if="scanResult" class="text-[10px]"
:class="scanResult.added > 0 ? 'text-green-400' : 'text-[#5a7a94]'">
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
</span>
</div>
</div>
</div>
<!-- Compte -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">COMPTE</div>
<h2 class="text-sm font-bold text-white mb-6">Session</h2>
<button type="button" @click="logout"
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
SE DÉCONNECTER
</button>
</div>
<!-- Folder Picker -->
<FolderPicker
v-if="picker.open"
:initial-path="picker.currentPath"
@select="onPickerSelect"
@cancel="picker.open = false"
/>
<!-- Modal ajout client -->
<div v-if="modal.open" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
<div class="bg-[#0d1219] border border-[#1e2d3d] w-full max-w-md p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-sm font-bold text-white">Ajouter un client</h3>
<button @click="closeModal" class="text-[#5a7a94] hover:text-white transition-colors cursor-pointer">✕</button>
</div>
<div class="mb-4">
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">NOM</label>
<input v-model="modal.name" placeholder="Mon client torrent"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"/>
</div>
<div class="mb-4">
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">TYPE</label>
<select v-model="modal.type" @change="onTypeChange"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors cursor-pointer">
<option value="">Choisir un type...</option>
<option v-for="def in availableClients" :key="def.id" :value="def.id">{{ def.label }}</option>
</select>
</div>
<div v-if="currentDefinition" class="flex flex-col gap-3 mb-6">
<div v-for="field in currentDefinition.fields" :key="field.key">
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">{{ field.label.toUpperCase() }}</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"/>
</div>
</div>
<div class="flex gap-3">
<button @click="saveClient" :disabled="modal.saving || !modal.tested"
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
{{ modal.saving ? '...' : 'ENREGISTRER' }}
</button>
<button @click="testNewClient" :disabled="modal.testing || !modal.type"
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#3a8fe8] hover:text-[#3a8fe8] transition-colors disabled:opacity-50 cursor-pointer">
{{ modal.testing ? '...' : modal.tested ? 'TESTÉ ✓' : 'TESTER' }}
</button>
</div>
<p v-if="!modal.tested && modal.type" class="text-[10px] text-[#5a7a94] mt-3">
Testez la connexion avant d'enregistrer
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import FolderPicker from '@/components/FolderPicker.vue'
const router = useRouter()
const auth = useAuthStore()
const { add: toast } = useToast()
// ── State ──────────────────────────────────────────────────────
const form = ref({
mediaPath : '',
completePath: '',
organizeMode: 'hardlink' as 'hardlink' | 'move',
nfoSupport : false,
autoImport : true,
usePlexTitles: false,
})
const saving = ref(false)
const updating = ref(false)
const scanning = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
// ── Folder picker ──────────────────────────────────────────────
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
open: false, field: 'completePath', currentPath: '/'
})
function openPicker(field: 'completePath' | 'mediaPath') {
picker.value = { open: true, field, currentPath: (form.value as any)[field] || '/' }
}
function onPickerSelect(path: string) {
(form.value as any)[picker.value.field] = path
picker.value.open = false
}
const isDocker = ref(false)
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
const clients = ref<any[]>([])
const availableClients = ref<any[]>([])
const healthStatus = ref<Record<string, boolean | null>>({})
const modal = ref({
open : false,
name : '',
type : '',
config : {} as Record<string, string>,
testing: false,
saving : false,
tested : false,
})
// ── Computed ───────────────────────────────────────────────────
const currentDefinition = computed(() =>
availableClients.value.find(d => d.id === modal.value.type) ?? null
)
function clientLabel(type: string): string {
return availableClients.value.find(d => d.id === type)?.label ?? type
}
// ── Init ───────────────────────────────────────────────────────
onMounted(async () => {
const [settingsRes, statusRes, clientsRes, availRes, systemRes] = await Promise.all([
fetch('/api/settings', { credentials: 'include' }),
fetch('/api/torrents/status', { credentials: 'include' }),
fetch('/api/torrent-clients', { credentials: 'include' }),
fetch('/api/torrent-clients/available', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
])
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (statusRes.ok) torrentsStatus.value = await statusRes.json()
if (clientsRes.ok) clients.value = await clientsRes.json()
if (availRes.ok) availableClients.value = await availRes.json()
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
for (const c of clients.value) refreshHealth(c.uuid)
})
// ── Healthcheck ────────────────────────────────────────────────
async function refreshHealth(uuid: string) {
healthStatus.value[uuid] = null
try {
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
healthStatus.value[uuid] = res.ok ? (await res.json()).online : false
} catch { healthStatus.value[uuid] = false }
}
// ── Test / delete clients ──────────────────────────────────────
async function testClient(uuid: string) {
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
const { ok, message } = await res.json()
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
healthStatus.value[uuid] = ok
}
async function deleteClient(uuid: string) {
const res = await fetch(`/api/torrent-clients/${uuid}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) {
clients.value = clients.value.filter(c => c.uuid !== uuid)
delete healthStatus.value[uuid]
toast('Client supprimé', 'success')
} else {
toast('Erreur lors de la suppression', 'error')
}
}
// ── Modal ──────────────────────────────────────────────────────
function openAddModal() {
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
}
function closeModal() { modal.value.open = false }
function onTypeChange() {
modal.value.config = {}
modal.value.tested = false
const def = currentDefinition.value
if (def) {
for (const field of def.fields) {
if (field.default !== undefined) modal.value.config[field.key] = String(field.default)
}
}
}
async function testNewClient() {
modal.value.testing = true
modal.value.tested = false
try {
const res = await fetch('/api/torrent-clients/test-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ type: modal.value.type, config: modal.value.config })
})
const { ok, message } = await res.json()
modal.value.tested = ok
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
modal.value.testing = false
}
}
async function saveClient() {
if (!modal.value.tested) return
modal.value.saving = true
try {
const res = await fetch('/api/torrent-clients', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ name: modal.value.name || clientLabel(modal.value.type), type: modal.value.type, config: modal.value.config })
})
if (res.ok) {
const client = await res.json()
clients.value.push(client)
refreshHealth(client.uuid)
closeModal()
toast('Client enregistré ✓', 'success')
} else {
const { error } = await res.json()
toast(error ?? "Erreur lors de l'enregistrement", 'error')
}
} finally {
modal.value.saving = false
}
}
// ── Données ────────────────────────────────────────────────────
async function update() {
updating.value = true
try {
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
if (res.ok) {
const { count } = await res.json()
torrentsStatus.value = { exists: true, count, empty: count === 0 }
toast(`${count} séries chargées ✓`, 'success')
} else {
const { error } = await res.json()
toast(error ?? 'Erreur lors de la mise à jour', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
updating.value = false
}
}
// ── Scan ───────────────────────────────────────────────────────
async function scan() {
scanning.value = true
scanResult.value = null
try {
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
if (res.ok) {
const data = await res.json()
scanResult.value = { found: data.found, added: data.added }
toast(
data.added > 0
? `${data.found} fichiers analysés — ${data.added} ajoutés`
: `${data.found} fichiers analysés — rien de nouveau`,
'success'
)
} else {
toast('Erreur lors de l\'analyse', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
scanning.value = false
}
}
// ── Sauvegarder ────────────────────────────────────────────────
async function save() {
saving.value = true
try {
const res = await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(form.value)
})
if (res.ok) toast('Configuration sauvegardée', 'success')
else toast('Erreur lors de la sauvegarde', 'error')
} finally {
saving.value = false
}
}
async function logout() {
await auth.logout()
await router.push('/auth')
}
</script>
+49
View File
@@ -0,0 +1,49 @@
<template>
<div class="flex flex-col gap-6">
<div>
<h2 class="text-base font-semibold text-primary">Avancé</h2>
<p class="text-sm text-muted mt-1">Options pour le débogage et le développement.</p>
</div>
<div class="settings-card flex flex-col gap-5">
<SettingsToggle
v-model="devMode"
label="Mode développeur"
description="Affiche un panneau de debug sur le dashboard : mémoire, cache, uptime, worker."
/>
</div>
<div v-if="saved" class="text-xs text-green-400">Paramètre sauvegardé.</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
const devMode = ref(false)
const saved = ref(false)
let saveTimer: ReturnType<typeof setTimeout> | null = null
onMounted(async () => {
const res = await fetch('/api/settings', { credentials: 'include' })
if (res.ok) {
const s = await res.json()
devMode.value = s.devMode ?? false
}
})
watch(devMode, async (val) => {
await fetch('/api/settings', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ devMode: val }),
})
saved.value = true
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saved.value = false }, 2000)
})
</script>
+79
View File
@@ -0,0 +1,79 @@
<template>
<div class="flex flex-col gap-6">
<div>
<h2 class="text-base font-semibold text-primary">Catalogue Fankai</h2>
<p class="text-sm text-muted mt-1">
Données locales synchronisées depuis GitLab. Mise à jour automatique toutes les 6h.
</p>
</div>
<!-- Bannière données manquantes -->
<div
v-if="status.empty"
class="border border-accent/30 bg-accent-muted rounded-lg p-4 flex items-center justify-between gap-4"
>
<div>
<p class="text-xs text-accent font-medium mb-1">Données manquantes</p>
<p class="text-sm text-secondary">
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
Téléchargez les données pour utiliser FanKarr.
</p>
</div>
<button @click="update" :disabled="updating" class="btn-primary whitespace-nowrap">
{{ updating ? 'Téléchargement...' : 'Télécharger' }}
</button>
</div>
<!-- Statut -->
<div class="settings-card">
<p class="text-xs text-muted mb-1">Statut</p>
<p class="text-sm text-primary font-medium">
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}
</p>
</div>
<!-- Action -->
<div>
<button @click="update" :disabled="updating" class="btn-primary">
{{ updating ? 'Mise à jour...' : 'Mettre à jour maintenant' }}
</button>
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const updating = ref(false)
const status = ref({ exists: false, count: 0, empty: true })
onMounted(async () => {
const res = await fetch('/api/torrents/status', { credentials: 'include' })
if (res.ok) status.value = await res.json()
})
async function update() {
updating.value = true
try {
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
if (res.ok) {
const { count } = await res.json()
status.value = { exists: true, count, empty: count === 0 }
toast(`${count} séries chargées ✓`, 'success')
} else {
const { error } = await res.json()
toast(error ?? 'Erreur lors de la mise à jour', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
updating.value = false
}
}
</script>
+282
View File
@@ -0,0 +1,282 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-primary">Clients torrent</h2>
<p class="text-sm text-muted mt-1">Gérez vos clients de téléchargement.</p>
</div>
<button @click="openAddModal" class="btn-secondary">+ Ajouter</button>
</div>
<!-- Liste vide -->
<div v-if="clients.length === 0" class="settings-card text-sm text-muted">
Aucun client configuré.
</div>
<!-- Liste clients -->
<div v-else class="flex flex-col gap-2">
<div
v-for="client in clients"
:key="client.uuid"
class="settings-card flex items-center justify-between"
>
<div class="flex items-center gap-3">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="healthStatus[client.uuid] === true ? 'bg-green-500' :
healthStatus[client.uuid] === false ? 'bg-red-500' : 'bg-border'"
/>
<div>
<p class="text-sm text-primary font-medium">{{ client.name }}</p>
<p class="text-xs text-muted mt-0.5">{{ clientLabel(client.type) }}</p>
</div>
</div>
<div class="flex items-center gap-1">
<button @click="testClient(client.uuid)" class="btn-ghost text-xs">Tester</button>
<button @click="editClient(client)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
<button @click="deleteClient(client.uuid)" class="btn-ghost text-xs text-red-400 hover:text-red-300">Supprimer</button>
</div>
</div>
</div>
<!-- Modal ajout -->
<Teleport to="body">
<div
v-if="modal.open"
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
@click.self="closeModal"
>
<div class="bg-card border border-border rounded-xl w-full max-w-md p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-primary">{{ modal.uuid ? 'Modifier un client' : 'Ajouter un client' }}</h3>
<button @click="closeModal" class="text-muted hover:text-primary transition-colors">✕</button>
</div>
<div class="flex flex-col gap-3">
<div>
<label class="settings-label">Nom</label>
<input v-model="modal.name" placeholder="Mon client torrent" class="settings-input" />
</div>
<div>
<label class="settings-label">Type</label>
<select v-model="modal.type" @change="onTypeChange" class="settings-input">
<option value="">Choisir un type...</option>
<option v-for="def in availableClients" :key="def.id" :value="def.id">{{ def.label }}</option>
</select>
</div>
<div v-if="currentDefinition" v-for="field in currentDefinition.fields" :key="field.key">
<label class="settings-label">
{{ field.label }}
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/>
</div>
</div>
<p v-if="!modal.tested && modal.type" class="text-xs text-muted">
Testez la connexion avant d'enregistrer.
</p>
<div class="flex gap-2 pt-1">
<button
@click="saveClient"
:disabled="modal.saving || !modal.tested"
class="btn-primary"
>
{{ modal.saving ? '...' : 'Enregistrer' }}
</button>
<button
@click="testNewClient"
:disabled="modal.testing || !modal.type"
class="btn-secondary"
>
{{ modal.testing ? '...' : modal.tested ? 'Testé ✓' : 'Tester' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const clients = ref<any[]>([])
const availableClients = ref<any[]>([])
const healthStatus = ref<Record<string, boolean | null>>({})
const modal = ref({
open : false,
uuid : null as string | null,
name : '',
type : '',
config : {} as Record<string, string>,
testing: false,
saving : false,
tested : false,
})
const currentDefinition = computed(() =>
availableClients.value.find(d => d.id === modal.value.type) ?? null
)
function clientLabel(type: string) {
return availableClients.value.find(d => d.id === type)?.label ?? type
}
onMounted(async () => {
const [clientsRes, availRes] = await Promise.all([
fetch('/api/torrent-clients', { credentials: 'include' }),
fetch('/api/torrent-clients/available', { credentials: 'include' }),
])
if (clientsRes.ok) clients.value = await clientsRes.json()
if (availRes.ok) availableClients.value = await availRes.json()
for (const c of clients.value) refreshHealth(c.uuid)
})
async function refreshHealth(uuid: string) {
healthStatus.value[uuid] = null
try {
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
healthStatus.value[uuid] = res.ok ? (await res.json()).online : false
} catch {
healthStatus.value[uuid] = false
}
}
async function testClient(uuid: string) {
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
const { ok, message } = await res.json()
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
healthStatus.value[uuid] = ok
}
async function deleteClient(uuid: string) {
const res = await fetch(`/api/torrent-clients/${uuid}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) {
clients.value = clients.value.filter(c => c.uuid !== uuid)
delete healthStatus.value[uuid]
toast('Client supprimé', 'success')
} else {
toast('Erreur lors de la suppression', 'error')
}
}
function openAddModal() {
modal.value = { open: true, uuid: null, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
}
function editClient(client: any) {
modal.value = {
open : true,
uuid : client.uuid,
name : client.name,
type : client.type,
config : JSON.parse(JSON.stringify(client.config)),
testing: false,
saving : false,
tested : true,
}
}
function closeModal() { modal.value.open = false }
function onTypeChange() {
modal.value.config = {}
modal.value.tested = false
const def = currentDefinition.value
if (def) {
for (const field of def.fields) {
if (field.default !== undefined) modal.value.config[field.key] = String(field.default)
}
}
}
// Reset tested dès que l'utilisateur modifie un champ (nom ou config)
// sauf si c'est un mot de passe masqué non touché
watch(() => modal.value.name, () => {
if (modal.value.uuid) modal.value.tested = false
})
watch(() => modal.value.config, (newConfig) => {
if (!modal.value.uuid) return
const driver = currentDefinition.value
if (!driver) return
// Si au moins un champ non-password a changé → reset tested
// Pour les passwords, on reset seulement si la valeur n'est plus le masque
for (const [key, val] of Object.entries(newConfig)) {
const field = driver.fields.find(f => f.key === key)
if (!field) continue
if (field.type === 'password' && val === '••••••••') continue
modal.value.tested = false
return
}
}, { deep: true })
async function testNewClient() {
modal.value.testing = true
modal.value.tested = false
try {
const res = await fetch('/api/torrent-clients/test-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ type: modal.value.type, config: modal.value.config, uuid: modal.value.uuid }),
})
const { ok, message } = await res.json()
modal.value.tested = ok
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
modal.value.testing = false
}
}
async function saveClient() {
if (!modal.value.tested) return
modal.value.saving = true
try {
const isEdit = !!modal.value.uuid
const method = isEdit ? 'PUT' : 'POST'
const url = isEdit ? `/api/torrent-clients/${modal.value.uuid}` : '/api/torrent-clients'
const res = await fetch(url, {
method, headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({
name : modal.value.name || clientLabel(modal.value.type),
type : modal.value.type,
config: modal.value.config,
}),
})
if (res.ok) {
const client = await res.json()
if (isEdit) {
const index = clients.value.findIndex(c => c.uuid === client.uuid)
if (index !== -1) clients.value[index] = client
} else {
clients.value.push(client)
}
refreshHealth(client.uuid)
closeModal()
toast(isEdit ? 'Client modifié ✓' : 'Client enregistré ✓', 'success')
} else {
const { error } = await res.json()
toast(error ?? "Erreur lors de l'enregistrement", 'error')
}
} finally {
modal.value.saving = false
}
}
</script>
+185
View File
@@ -0,0 +1,185 @@
<template>
<div class="flex flex-col gap-4">
<!-- Actions -->
<div class="flex items-center justify-between">
<span class="text-xs text-muted">{{ entries.length }} entrées · {{ formatSize(fileSize) }}</span>
<div class="flex gap-2">
<button @click="load" class="btn-secondary text-xs">↺ Rafraîchir</button>
<button @click="showConfirm = true" class="btn-ghost text-xs text-red-400 hover:text-red-300">Effacer</button>
</div>
</div>
<!-- Filtres -->
<div class="flex flex-wrap items-center gap-2">
<div class="flex gap-1">
<button
v-for="l in levels" :key="l.value"
@click="filterLevel = l.value; load()"
class="px-3 py-1 text-xs rounded-lg border transition-colors"
:class="filterLevel === l.value
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ l.label }}
</button>
</div>
<select v-model="filterSource" @change="load()" class="settings-input w-auto text-xs py-1">
<option value="">Toutes les sources</option>
<option value="organize">organize</option>
<option value="api">api</option>
<option value="auth">auth</option>
</select>
<div class="flex items-center gap-1 ml-auto">
<span class="text-xs text-muted mr-1">Afficher</span>
<button
v-for="n in [100, 500, 2000]" :key="n"
@click="limit = n; load()"
class="px-2 py-1 text-xs rounded-lg border transition-colors"
:class="limit === n
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ n === 2000 ? 'Tout' : n }}
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
</div>
<!-- Vide -->
<div v-else-if="entries.length === 0" class="flex items-center justify-center py-16 text-muted text-sm">
Aucun log
</div>
<!-- Table -->
<div v-else class="settings-card p-0 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-xs font-mono">
<thead>
<tr class="border-b border-border">
<th class="text-left text-muted px-4 py-2.5 w-36 font-normal">Horodatage</th>
<th class="text-left text-muted px-3 py-2.5 w-16 font-normal">Niveau</th>
<th class="text-left text-muted px-3 py-2.5 w-24 font-normal">Source</th>
<th class="text-left text-muted px-3 py-2.5 font-normal">Message</th>
</tr>
</thead>
<tbody>
<tr
v-for="(entry, i) in entries" :key="i"
class="border-b border-border/50 hover:bg-hover transition-colors"
:class="entry.level === 'error' ? 'bg-red-500/5' : entry.level === 'warn' ? 'bg-yellow-500/5' : ''"
>
<td class="px-4 py-2 text-muted whitespace-nowrap">{{ formatDate(entry.at) }}</td>
<td class="px-3 py-2 whitespace-nowrap">
<span class="px-1.5 py-0.5 rounded text-[10px]" :class="levelClass(entry.level)">
{{ entry.level.toUpperCase() }}
</span>
</td>
<td class="px-3 py-2 text-muted whitespace-nowrap">{{ entry.source }}</td>
<td class="px-3 py-2 text-primary break-all">
{{ entry.msg }}
<span v-if="entry.meta" class="text-muted ml-2">{{ JSON.stringify(entry.meta) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Confirm effacement -->
<Teleport to="body">
<div v-if="showConfirm" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="showConfirm = false">
<div class="bg-card border border-border rounded-xl p-6 max-w-sm w-full flex flex-col gap-4">
<div>
<h3 class="text-sm font-semibold text-primary mb-1">Effacer les logs ?</h3>
<p class="text-xs text-muted">Cette action est irréversible.</p>
</div>
<div class="flex gap-2">
<button @click="doClear" class="btn-primary bg-red-500 hover:bg-red-400">Effacer</button>
<button @click="showConfirm = false" class="btn-secondary">Annuler</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const entries = ref<any[]>([])
const fileSize = ref(0)
const loading = ref(true)
const showConfirm = ref(false)
const filterLevel = ref('all')
const filterSource = ref('')
const limit = ref(100)
const levels = [
{ label: 'Tous', value: 'all' },
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
{ label: 'Error', value: 'error' },
{ label: 'Debug', value: 'debug' },
]
async function load() {
loading.value = true
try {
const params = new URLSearchParams({
limit: String(limit.value),
level: filterLevel.value,
...(filterSource.value ? { source: filterSource.value } : {}),
})
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
if (res.ok) {
const data = await res.json()
entries.value = data.entries
fileSize.value = data.size
}
} finally {
loading.value = false
}
}
async function doClear() {
showConfirm.value = false
await fetch('/api/logs/clear', { method: 'POST', credentials: 'include' })
toast('Logs effacés', 'success')
await load()
}
function formatDate(iso: string): string {
const d = new Date(iso)
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
function levelClass(level: string): string {
return ({
debug: 'bg-border text-muted',
info : 'bg-blue-500/10 text-blue-400',
warn : 'bg-yellow-500/10 text-yellow-400',
error: 'bg-red-500/10 text-red-400',
} as Record<string, string>)[level] ?? 'bg-border text-muted'
}
onMounted(load)
</script>
+239
View File
@@ -0,0 +1,239 @@
<template>
<div class="flex flex-col gap-6">
<div>
<h2 class="text-base font-semibold text-primary">Media Management</h2>
<p class="text-sm text-muted mt-1">Configuration des dossiers et du comportement d'import.</p>
</div>
<!-- Dossiers -->
<div class="settings-card flex flex-col gap-5">
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="settings-label">Dossier téléchargements</label>
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
{{ form.completePath || '/' }}
</button>
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
</div>
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="settings-label">Médiathèque Fankai</label>
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
{{ form.mediaPath || '/' }}
</button>
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
</div>
</div>
<!-- Alerte globale si un des deux paths est sur / -->
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5">
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
<p class="text-xs text-muted">
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
Configurez-les ci-dessus avant de sauvegarder.
</p>
</div>
</div>
<!-- Mode import -->
<div class="settings-card flex flex-col gap-2">
<label class="settings-label">Mode d'import</label>
<div class="flex gap-2">
<button
@click="form.organizeMode = 'hardlink'"
class="px-4 py-2 text-xs rounded-lg border transition-colors"
:class="form.organizeMode === 'hardlink'
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
Hardlink
</button>
<button
@click="form.organizeMode = 'copy'"
class="px-4 py-2 text-xs rounded-lg border transition-colors"
:class="form.organizeMode === 'copy'
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
Copier
</button>
<button
@click="form.organizeMode = 'move'"
class="px-4 py-2 text-xs rounded-lg border transition-colors"
:class="form.organizeMode === 'move'
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
Déplacer
</button>
</div>
<p class="text-xs text-muted">
<span class="text-primary">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
</p>
</div>
<!-- Toggles -->
<div class="settings-card flex flex-col gap-5">
<SettingsToggle
v-model="form.autoImport"
label="Import automatique"
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
/>
<SettingsToggle
v-model="form.nfoSupport"
label="NFO / Métadonnées"
description="Télécharge les NFO et images depuis GitLab lors de l'import (Kodi, Infuse, etc.)."
/>
<SettingsToggle
v-if="form.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement"
description="Supprime automatiquement le torrent du client après un import en mode Déplacer."
/>
</div>
<!-- Actions -->
<div class="flex items-center gap-3">
<button @click="save" :disabled="saving" class="btn-primary">
{{ saving ? '...' : 'Sauvegarder' }}
</button>
<button @click="scan" :disabled="scanning" class="btn-secondary">
{{ scanning ? 'Scan...' : 'Analyser médiathèque' }}
</button>
<span
v-if="scanResult"
class="text-xs"
:class="scanResult.added > 0 ? 'text-green-400' : 'text-muted'"
>
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
</span>
</div>
</div>
<!-- Folder Picker -->
<FolderPicker
v-if="picker.open"
:initial-path="picker.currentPath"
@select="onPickerSelect"
@cancel="picker.open = false"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import FolderPicker from '@/components/FolderPicker.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
const { add: toast } = useToast()
const saving = ref(false)
const scanning = ref(false)
const isDocker = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
const form = ref({
mediaPath : '',
completePath : '',
organizeMode : 'hardlink' as 'hardlink' | 'copy' | 'move',
nfoSupport : false,
autoImport : true,
deleteTorrentOnMove: false,
})
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
open: false,
field: 'completePath',
currentPath: '/',
})
// FIX : détection path non configuré (vide ou racine /)
function isRootPath(p: string): boolean {
return !p || p === '/'
}
const hasUnconfiguredPaths = computed(() =>
isRootPath(form.value.completePath) || isRootPath(form.value.mediaPath)
)
function openPicker(field: 'completePath' | 'mediaPath') {
picker.value = { open: true, field, currentPath: form.value[field] || picker.value.currentPath || '/' }
}
function onPickerSelect(path: string) {
form.value[picker.value.field] = path
picker.value.open = false
}
onMounted(async () => {
const [settingsRes, systemRes, infoRes] = await Promise.all([
fetch('/api/settings', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
fetch('/api/system/info', { credentials: 'include' }),
])
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
})
async function save() {
saving.value = true
try {
const res = await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(form.value),
})
if (res.ok) toast('Configuration sauvegardée', 'success')
else toast('Erreur lors de la sauvegarde', 'error')
} finally {
saving.value = false
}
}
async function scan() {
scanning.value = true
scanResult.value = null
try {
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
if (res.ok) {
const data = await res.json()
scanResult.value = { found: data.found, added: data.added }
toast(
data.added > 0
? `${data.found} fichiers analysés — ${data.added} ajoutés`
: `${data.found} fichiers analysés — rien de nouveau`,
'success'
)
} else {
toast("Erreur lors de l'analyse", 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
scanning.value = false
}
}
</script>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div>
<div class="px-8 py-8 max-w-4xl">
<h1 class="text-lg font-semibold text-primary mb-6">{{ pageTitle }}</h1>
<RouterView />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const titles: Record<string, string> = {
'settings-download' : 'Clients de téléchargement',
'settings-media' : 'Gestion des médias',
'settings-catalogue' : 'Catalogue Fankai',
'settings-logs' : 'Journaux',
}
const pageTitle = computed(() => titles[route.name as string] ?? 'Paramètres')
</script>