Compare commits

...
62 Commits
Author SHA1 Message Date
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
MasutaYunikon 49c3a09129 Merge pull request #60 from Masutayunikon/release-please--branches--main
chore(main): release 2.4.1
2026-03-24 22:01:53 +01:00
MasutaYunikon 4a24b7fa0f chore(main): release 2.4.1 2026-03-24 22:01:36 +01:00
MasutaYunikon 80aef10230 Merge remote-tracking branch 'origin/main' 2026-03-24 22:00:58 +01:00
MasutaYunikon 92055caf4d fix: add usePlexTitles to return function 2026-03-24 22:00:47 +01:00
MasutaYunikon d8e284b0de Merge pull request #59 from Masutayunikon/release-please--branches--main
chore(main): release 2.4.0
2026-03-24 21:55:31 +01:00
MasutaYunikon 0b98269695 chore(main): release 2.4.0 2026-03-24 19:19:44 +01:00
MasutaYunikon 402946b462 Merge remote-tracking branch 'origin/main' 2026-03-24 19:19:03 +01:00
MasutaYunikon c374ceb0a7 fix(organize): sanitisation des caractères spéciaux dans les noms de dossiers
Le titre "Kaguya-sama : Love is War Henshū" contenait un ":" qui causait
une troncation du nom de dossier sur certains systèmes de fichiers.
sanitizeDirName() remplace ":" par " -" et supprime les autres caractères
interdits (<>"/\|?*).

feat(settings): option titres optimisés Plex (title_for_plex)

Nouveau toggle "Noms de dossiers" dans les paramètres. Quand activé,
utilise le champ title_for_plex de l'API Fankai pour les noms de dossiers
au lieu du titre brut — évite les caractères spéciaux incompatibles avec
Plex et les systèmes de fichiers Windows/NTFS.

enrichSeriesDataWithOriginalFilenames fetche maintenant /series/{id} pour
injecter title_for_plex dans chaque serieData avant de passer au worker.

fix(organize): retry sur les erreurs réseau dans enrichSeriesDataWithOriginalFilenames

2 tentatives avec 500ms d'attente entre les deux pour les erreurs réseau
ponctuelles sur /seasons/{id}/episodes — évite que formatted_name soit null
à cause d'un timeout isolé au démarrage.

fix(catalogue): activeTorrents filtre désormais uniquement state=downloading

Les torrents en seeding étaient inclus dans activeTorrents, forçant
computeSerieDownloadState à retourner "downloading" même pour les séries
entièrement importées. Les badges affichaient "EN COURS" au lieu de "IMPORTÉ".
2026-03-24 19:18:51 +01:00
MasutaYunikon 6f21b0a5cd Merge pull request #58 from Masutayunikon/release-please--branches--main
chore(main): release 2.3.3
2026-03-24 03:41:25 +01:00
MasutaYunikon 1c36bb7688 chore(main): release 2.3.3 2026-03-24 03:39:35 +01:00
MasutaYunikon 82a9d51731 Merge remote-tracking branch 'origin/main' 2026-03-24 03:39:03 +01:00
MasutaYunikon ac03aa3c11 fix: recuperation des episodes avec un delai 2026-03-24 03:38:54 +01:00
42 changed files with 3528 additions and 2361 deletions

No files matched your search

+5
View File
@@ -53,6 +53,11 @@ jobs:
bun-target: bun-linux-x64 bun-target: bun-linux-x64
binary: binaries/fankarr binary: binaries/fankarr
archive: fankarr-linux-x64.zip archive: fankarr-linux-x64.zip
- target: linux-arm64
os: ubuntu-latest
bun-target: bun-linux-arm64
binary: binaries/fankarr
archive: fankarr-linux-arm64-termux.zip
- target: macos - target: macos
os: macos-latest os: macos-latest
bun-target: bun-darwin-arm64 bun-target: bun-darwin-arm64
+122
View File
@@ -1,5 +1,127 @@
# Changelog # Changelog
## [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)
### Bug Fixes
* add usePlexTitles to return function ([381e327](https://github.com/Masutayunikon/FanKarr/commit/381e3274344c59cc80ba6230d30c96e5c5637ea5))
## [2.4.0](https://github.com/Masutayunikon/FanKarr/compare/v2.3.3...v2.4.0) (2026-03-24)
### Features
* **settings:** option titres optimisés Plex (title_for_plex) ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
### Bug Fixes
* **catalogue:** activeTorrents filtre désormais uniquement state=downloading ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
* **organize:** retry sur les erreurs réseau dans enrichSeriesDataWithOriginalFilenames ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
* **organize:** sanitisation des caractères spéciaux dans les noms de dossiers ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
## [2.3.3](https://github.com/Masutayunikon/FanKarr/compare/v2.3.2...v2.3.3) (2026-03-24)
### Bug Fixes
* recuperation des episodes avec un delai ([dfc6c13](https://github.com/Masutayunikon/FanKarr/commit/dfc6c1300e3dd8323e422ce3e71465bac77f94cf))
## [2.3.2](https://github.com/Masutayunikon/FanKarr/compare/v2.3.1...v2.3.2) (2026-03-24) ## [2.3.2](https://github.com/Masutayunikon/FanKarr/compare/v2.3.1...v2.3.2) (2026-03-24)
+6 -4
View File
@@ -17,17 +17,20 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@vueuse/core": "^14.2.1",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"lucide-vue-next": "^1.0.0",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-router": "^5.0.3", "vue-router": "^5.0.3"
"bcryptjs": "^3.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.9", "@types/jsonwebtoken": "^9.0.9",
@@ -38,7 +41,6 @@
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vue-tsc": "^3.1.5", "vue-tsc": "^3.1.5"
"@types/bcrypt": "^5.0.2"
} }
} }
+46
View File
@@ -11,6 +11,9 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.1 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)) 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: bcryptjs:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
@@ -23,6 +26,9 @@ importers:
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.3 specifier: ^9.0.3
version: 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: node-fetch:
specifier: ^3.3.2 specifier: ^3.3.2
version: 3.3.2 version: 3.3.2
@@ -536,6 +542,9 @@ packages:
'@types/serve-static@2.2.0': '@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@vitejs/plugin-vue@6.0.4': '@vitejs/plugin-vue@6.0.4':
resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -622,6 +631,19 @@ packages:
vue: vue:
optional: true 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: accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -1039,6 +1061,11 @@ packages:
lodash.once@4.1.1: lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} 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: magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
@@ -1758,6 +1785,8 @@ snapshots:
'@types/http-errors': 2.0.5 '@types/http-errors': 2.0.5
'@types/node': 24.12.0 '@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))': '@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: dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2 '@rolldown/pluginutils': 1.0.0-rc.2
@@ -1886,6 +1915,19 @@ snapshots:
typescript: 5.9.3 typescript: 5.9.3
vue: 3.5.29(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: accepts@2.0.0:
dependencies: dependencies:
mime-types: 3.0.2 mime-types: 3.0.2
@@ -2303,6 +2345,10 @@ snapshots:
lodash.once@4.1.1: {} 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: magic-string-ast@1.0.3:
dependencies: dependencies:
magic-string: 0.30.21 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 type { Request, Response, NextFunction } from 'express'
import { JWT_SECRET } from './secret.js' import { JWT_SECRET } from './secret.js'
import { DATA_DIR } from './config.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 const SALT_ROUNDS = 10
interface AuthData { interface AuthData {
username: string username : string
passwordHash: string passwordHash: string
} }
@@ -32,25 +33,25 @@ function writeAuth(data: AuthData): void {
// GET /api/auth/status // GET /api/auth/status
export function authStatus(req: Request, res: Response): void { export function authStatus(req: Request, res: Response): void {
const existing = readAuth() const existing = readAuth()
const token = req.cookies?.fankarr_token const token = req.cookies?.fankarr_token
let loggedIn = false let loggedIn = false
if (token) { if (token) {
try { try {
jwt.verify(token, JWT_SECRET) jwt.verify(token, JWT_SECRET)
loggedIn = true loggedIn = true
} catch {} } catch {
logger.debug('auth', 'Token invalide ou expiré')
}
} }
res.json({ res.json({ setup: !!existing, loggedIn })
setup: !!existing,
loggedIn
})
} }
// POST /api/auth/setup // POST /api/auth/setup
export function authSetup(req: Request, res: Response): void { export function authSetup(req: Request, res: Response): void {
if (readAuth()) { 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à' }) res.status(400).json({ error: 'Un compte existe déjà' })
return return
} }
@@ -63,6 +64,7 @@ export function authSetup(req: Request, res: Response): void {
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS) const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
writeAuth({ username, passwordHash }) writeAuth({ username, passwordHash })
logger.info('auth', `Compte créé pour "${username}"`)
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' }) const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) 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)) { if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) {
logger.warn('auth', `Échec de connexion pour "${username}"`)
res.status(401).json({ error: 'Identifiants incorrects' }) res.status(401).json({ error: 'Identifiants incorrects' })
return return
} }
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' }) const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
logger.info('auth', `Connexion réussie pour "${username}"`)
res.json({ success: true }) res.json({ success: true })
} }
// POST /api/auth/logout // 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.clearCookie('fankarr_token')
res.json({ success: true }) 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 { export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const token = req.cookies?.fankarr_token const token = req.cookies?.fankarr_token
if (!token) { if (!token) {
logger.debug('auth', `Accès refusé — non authentifié (${req.method} ${req.path})`)
res.status(401).json({ error: 'Non authentifié' }) res.status(401).json({ error: 'Non authentifié' })
return return
} }
@@ -110,6 +122,7 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
jwt.verify(token, JWT_SECRET) jwt.verify(token, JWT_SECRET)
next() next()
} catch { } catch {
logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`)
res.status(401).json({ error: 'Token invalide' }) res.status(401).json({ error: 'Token invalide' })
} }
} }
+187 -386
View File
@@ -7,16 +7,19 @@ import { readSettings, writeSettings } from './settings.js'
import { import {
registerDriver, getDriver, getAvailableClients, registerDriver, getDriver, getAvailableClients,
listClients, addClient, removeClient, getClient, listClients, addClient, removeClient, getClient,
sanitizeClient, dispatchDownload, dispatchList sanitizeClient, dispatchDownload, dispatchList, updateClient
} from './torrent-clients/index.js' } from './torrent-clients/index.js'
import qbittorrentDriver from './torrent-clients/qbittorrent.js' import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js' import transmissionDriver from './torrent-clients/transmission.js'
import { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js'
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js' import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js' import { DATA_DIR, BASE_DIR } from './config.js'
import { systemInfo } from './system.js'
registerDriver(qbittorrentDriver) registerDriver(qbittorrentDriver)
registerDriver(transmissionDriver)
const app = express() const app = express()
const PORT = Number(process.env.PORT) || 9898 const PORT = Number(process.env.PORT) || 9898
const FANKAI_API = 'https://metadata.fankai.fr' const FANKAI_API = 'https://metadata.fankai.fr'
const GITHUB_BASE = process.env.GITHUB_BASE const GITHUB_BASE = process.env.GITHUB_BASE
@@ -25,7 +28,6 @@ const GITHUB_BASE = process.env.GITHUB_BASE
app.use(express.json()) app.use(express.json())
app.use(cookieParser()) app.use(cookieParser())
// ── Static frontend (prod) ─────────────────────────────────────
const PUBLIC_PATH = path.join(BASE_DIR, 'public') const PUBLIC_PATH = path.join(BASE_DIR, 'public')
if (fs.existsSync(PUBLIC_PATH)) { if (fs.existsSync(PUBLIC_PATH)) {
app.use(express.static(PUBLIC_PATH)) app.use(express.static(PUBLIC_PATH))
@@ -37,13 +39,18 @@ app.post('/api/auth/setup', authSetup)
app.post('/api/auth/login', authLogin) app.post('/api/auth/login', authLogin)
app.post('/api/auth/logout', authLogout) app.post('/api/auth/logout', authLogout)
// system info
app.get('/api/system/info', systemInfo)
// ── Settings ─────────────────────────────────────────────────── // ── Settings ───────────────────────────────────────────────────
app.get('/api/settings', requireAuth, (_req, res) => { app.get('/api/settings', requireAuth, (_req, res) => {
res.json(readSettings()) res.json(readSettings())
}) })
app.post('/api/settings', requireAuth, (req, res) => { app.post('/api/settings', requireAuth, (req, res) => {
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport } = req.body const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode } = req.body
res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport })) const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode })
logger.info('api', 'Paramètres mis à jour')
res.json(updated)
}) })
// ── Torrent clients ──────────────────────────────────────────── // ── Torrent clients ────────────────────────────────────────────
@@ -56,19 +63,39 @@ app.get('/api/torrent-clients', requireAuth, (_req, res) => {
app.post('/api/torrent-clients', requireAuth, (req, res) => { app.post('/api/torrent-clients', requireAuth, (req, res) => {
const { name, type, config } = req.body const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return } if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return } if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
res.json(sanitizeClient(addClient(name, type, config))) res.json(sanitizeClient(addClient(name, type, config)))
}) })
app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => { app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
const ok = removeClient(String(req.params.uuid)) const ok = removeClient(String(req.params.uuid))
if (!ok) res.status(404).json({ error: 'Client introuvable' }) if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return }
else res.json({ ok: true }) res.json({ ok: true })
})
app.put('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
const updated = updateClient(String(req.params.uuid), name, type, config)
if (!updated) { res.status(404).json({ error: 'Client introuvable' }); return }
res.json(sanitizeClient(updated))
}) })
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => { app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
const { type, config } = req.body const { type, config, uuid } = req.body
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return } if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
const driver = getDriver(type) const driver = getDriver(type)
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return } if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
if (uuid) {
const oldClient = getClient(uuid)
if (oldClient) {
for (const field of driver.definition.fields) {
if (field.type === 'password' && config[field.key] === '••••••••') {
config[field.key] = oldClient.config[field.key]
}
}
}
}
res.json(await driver.test(config)) res.json(await driver.test(config))
}) })
app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => { app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
@@ -87,8 +114,7 @@ app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res)
}) })
// ── Cache GitHub ─────────────────────────────────────────────── // ── Cache GitHub ───────────────────────────────────────────────
const CACHE_TTL_MS = 6 * 60 * 60 * 1000
const CACHE_TTL_MS = 6 * 60 * 60 * 1000 // 6 heures
interface CacheEntry<T> { data: T; expiresAt: number } interface CacheEntry<T> { data: T; expiresAt: number }
const _cache = new Map<string, CacheEntry<any>>() const _cache = new Map<string, CacheEntry<any>>()
@@ -107,122 +133,59 @@ function cacheSet<T>(key: string, data: T): void {
function cacheClear(): void { _cache.clear() } function cacheClear(): void { _cache.clear() }
async function githubGet(urlPath: string): Promise<any> { async function githubGet(urlPath: string): Promise<any> {
const key = urlPath const cached = cacheGet(urlPath)
const cached = cacheGet(key)
if (cached) return cached if (cached) return cached
const res = await fetch(`${GITHUB_BASE}/${urlPath}`) const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`) if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
const data = await res.json() const data = await res.json()
cacheSet(key, data) cacheSet(urlPath, data)
return data return data
} }
async function readAvailable(): Promise<number[]> { async function readAvailable(): Promise<number[]> {
try { try { return await githubGet('available.json') as number[] }
return await githubGet('available.json') as number[] catch { return [] }
} catch { return [] }
} }
async function readSerieData(serieId: number): Promise<any | null> { async function readSerieData(serieId: number): Promise<any | null> {
try { try { return await githubGet(`series/${serieId}.json`) }
return await githubGet(`series/${serieId}.json`) catch { return null }
} catch { return null }
} }
// Extrait tous les torrents d'un series/{id}.json avec leur type et niveau hiérarchique
// Retourne une liste plate compatible avec l'ancienne interface { infohash, torrent_url, magnet, raw, type, ... }
function extractTorrentsFromSerieData(sd: any): any[] { function extractTorrentsFromSerieData(sd: any): any[] {
const result: any[] = [] const result: any[] = []
if (!sd) return result if (!sd) return result
// Torrents au niveau série → pack_integrale (ou pack_saison si une seule saison)
for (const t of sd.torrents ?? []) { for (const t of sd.torrents ?? []) {
result.push({ result.push({ ...t, raw: t.title, type: 'pack_integrale', serie_id: sd.id, serie_title: sd.title, _serieLevel: true, _serieData: sd, _torrentIdx: (sd.torrents ?? []).indexOf(t) })
...t,
raw : t.title,
type : 'pack_integrale',
serie_id : sd.id,
serie_title: sd.title,
// episodes couverts : tous les épisodes de toutes les saisons qui ont paths[i]
_serieLevel: true,
_serieData : sd,
_torrentIdx: (sd.torrents ?? []).indexOf(t),
})
} }
// Torrents au niveau saison → pack_saison
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) { for (const t of season.torrents ?? []) {
result.push({ result.push({ ...t, raw: t.title, type: 'pack_saison', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, _seasonLevel: true, _season: season, _torrentIdx: (season.torrents ?? []).indexOf(t) })
...t,
raw : t.title,
type : 'pack_saison',
serie_id : sd.id,
serie_title : sd.title,
season_id : season.id,
season_number: season.season_number,
_seasonLevel : true,
_season : season,
_torrentIdx : (season.torrents ?? []).indexOf(t),
})
} }
// Torrents au niveau épisode → episode
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
for (const t of ep.torrents ?? []) { for (const t of ep.torrents ?? []) {
result.push({ result.push({ ...t, raw: t.title, type: 'episode', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, episode_id: ep.id, episode_number: ep.episode_number, _episodeLevel: true, _episode: ep, _torrentIdx: (ep.torrents ?? []).indexOf(t) })
...t,
raw : t.title,
type : 'episode',
serie_id : sd.id,
serie_title : sd.title,
season_id : season.id,
season_number: season.season_number,
episode_id : ep.id,
episode_number: ep.episode_number,
_episodeLevel: true,
_episode : ep,
_torrentIdx : (ep.torrents ?? []).indexOf(t),
})
} }
} }
} }
return result return result
} }
// Construit resolved_episodes pour un torrent à partir des paths[torrentIdx]
// Construit les épisodes résolus pour un torrent donné par son infohash
function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] { function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] {
const resolved: any[] = [] const resolved: any[] = []
const h = hash.toLowerCase() const h = hash.toLowerCase()
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
const paths: any[] = ep.paths ?? [] const paths: any[] = ep.paths ?? []
// Nouveau format { infohash, path } ou legacy string
let filePath: string | null = null let filePath: string | null = null
for (const p of paths) { for (const p of paths) {
if (typeof p === 'string') { if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
if (!filePath) filePath = p.replace(/\\/g, '/') // legacy fallback else if (p?.infohash?.toLowerCase() === h) { filePath = p.path.replace(/\\/g, '/'); break }
} else if (p?.infohash?.toLowerCase() === h) {
filePath = p.path.replace(/\\/g, '/')
break
}
} }
if (!filePath) continue if (!filePath) continue
const filename = filePath.split('/').pop() ?? filePath const filename = filePath.split('/').pop() ?? filePath
resolved.push({ resolved.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, season_id: season.id, filename, original_filename: filename })
episode_id : ep.id,
episode_number : ep.episode_number,
season_number : season.season_number,
season_id : season.id,
filename,
original_filename: filename,
})
} }
} }
return resolved return resolved
@@ -234,15 +197,6 @@ async function fankaiGet(endpoint: string): Promise<any> {
return res.json() return res.json()
} }
function normalizeTitle(s: string): string {
return s.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function imgProxy(url: string | null | undefined, w: number, q = 70): string | null { function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
if (!url) return null if (!url) return null
const stripped = url.replace(/^https?:\/\//, '') const stripped = url.replace(/^https?:\/\//, '')
@@ -250,167 +204,66 @@ function imgProxy(url: string | null | undefined, w: number, q = 70): string | n
} }
function normalizeSerie(serie: any): any { function normalizeSerie(serie: any): any {
const poster = serie.poster_image ?? serie.images?.poster ?? null return { ...serie, poster_image: imgProxy(serie.poster_image ?? serie.images?.poster ?? null, 300), fanart_image: imgProxy(serie.fanart_image ?? serie.images?.fanart ?? null, 1280, 60) }
const fanart = serie.fanart_image ?? serie.images?.fanart ?? null
return {
...serie,
poster_image: imgProxy(poster, 300),
fanart_image: imgProxy(fanart, 1280, 60),
}
} }
function normalizeSeason(season: any): any { function normalizeSeason(season: any): any {
const poster = season.poster_image ?? season.images?.poster ?? null return { ...season, poster_image: imgProxy(season.poster_image ?? season.images?.poster ?? null, 300) }
return {
...season,
poster_image: imgProxy(poster, 300),
}
} }
function normalizeEpisode(ep: any): any { function normalizeEpisode(ep: any): any {
const thumb = ep.thumb_image ?? ep.thumbnail ?? null return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 400) }
return {
...ep,
thumb_image: imgProxy(thumb, 400),
}
} }
// ── Enrichissement seriesData avec original_filename depuis l'API ──────────────
// Fetche /seasons/{id}/episodes pour chaque saison et injecte original_filename
// dans chaque épisode du serieData, indexé par episode_id
async function enrichSeriesDataWithOriginalFilenames(seriesData: any[]): Promise<any[]> {
return Promise.all(seriesData.map(async (sd) => {
if (!sd) return sd
const enrichedSeasons = await Promise.all((sd.seasons ?? []).map(async (season: any) => {
let epsData: any[] = []
try {
const res = await fankaiGet(`/seasons/${season.id}/episodes`)
epsData = Array.isArray(res) ? res : (res.episodes ?? [])
} catch { return season }
// Index episode_id → original_filename + formatted_name
const origMap = new Map<number, { original_filename: string; formatted_name: string }>()
for (const ep of epsData) {
if (ep.id) origMap.set(ep.id, {
original_filename: ep.original_filename ?? '',
formatted_name : ep.formatted_name ?? '',
})
}
// Injecter dans les épisodes du serieData
const enrichedEpisodes = (season.episodes ?? []).map((ep: any) => ({
...ep,
original_filename: origMap.get(ep.id)?.original_filename ?? null,
formatted_name : origMap.get(ep.id)?.formatted_name ?? null,
}))
return { ...season, episodes: enrichedEpisodes }
}))
return { ...sd, seasons: enrichedSeasons }
}))
}
// Helper réutilisable : charge + enrichit toutes les seriesData du cache
async function loadEnrichedSeriesData(): Promise<any[]> { async function loadEnrichedSeriesData(): Promise<any[]> {
const ids = await readAvailable() const ids = await readAvailable()
const allSd = await Promise.all(ids.map(id => readSerieData(id))) const allSd = await Promise.all(ids.map(id => readSerieData(id)))
const valid = allSd.filter(Boolean) return allSd.filter(Boolean)
return enrichSeriesDataWithOriginalFilenames(valid)
} }
function computeSerieDownloadState( function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, string>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
serieData : any | null,
organized : Record<string, Record<string, string>>,
activeTorrents: Set<string>
): 'none' | 'downloading' | 'partial' | 'complete' {
if (!serieData) return 'none' if (!serieData) return 'none'
const allTorrents = extractTorrentsFromSerieData(serieData) const allTorrents = extractTorrentsFromSerieData(serieData)
if (allTorrents.length === 0) return 'none' if (allTorrents.length === 0) return 'none'
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
const hasActive = allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))
if (hasActive) return 'downloading'
// Collecter tous les episode_id de la série et ceux organisés
const allEpisodeIds = new Set<number>() const allEpisodeIds = new Set<number>()
const organizedEpisodeIds = new Set<number>() const organizedEpisodeIds = new Set<number>()
for (const season of serieData.seasons ?? []) { for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
if ((ep.torrents ?? []).length > 0 || (ep.paths ?? []).length > 0) { if ((ep.torrents ?? []).length > 0 || (ep.paths ?? []).length > 0) allEpisodeIds.add(ep.id)
allEpisodeIds.add(ep.id)
}
} }
} }
// Vérifier via organized.json : si le filename du path est organisé
for (const t of allTorrents) { for (const t of allTorrents) {
const hash = t.infohash?.toLowerCase() const hash = t.infohash?.toLowerCase()
if (!hash) continue if (!hash) continue
const orgFiles = organized[hash] ?? {} const orgFiles = organized[hash] ?? {}
if (Object.keys(orgFiles).length === 0) continue if (Object.keys(orgFiles).length === 0) continue
const resolved = buildResolvedEpisodes(serieData, hash, t.season_number) for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) {
for (const ep of resolved) {
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
} }
} }
if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none'
if (allEpisodeIds.size === 0) return 'none'
if (organizedEpisodeIds.size === 0) return 'none'
if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete' if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete'
return 'partial' return 'partial'
} }
// ── Routes série ───────────────────────────────────────────────
app.get('/api/series', requireAuth, async (_req, res) => { app.get('/api/series', requireAuth, async (_req, res) => {
try { try {
const [apiData, availableIds] = await Promise.all([ const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()])
fankaiGet('/series'),
readAvailable(),
])
const availableSet = new Set<number>(availableIds) const availableSet = new Set<number>(availableIds)
// Charger organized.json
let organized: Record<string, Record<string, string>> = {} let organized: Record<string, Record<string, string>> = {}
try { try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {}
// Récupérer les hashes actifs depuis qB (best effort)
const activeTorrents = new Set<string>() const activeTorrents = new Set<string>()
try { try { const { category } = readSettings(); const active = await dispatchList(category ?? 'fankai'); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
const { category } = readSettings()
const active = await dispatchList(category ?? 'fankai')
for (const t of active) {
if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase())
}
} catch {}
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie) const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
// Charger les series data en parallèle uniquement pour celles qui ont des torrents
const serieDataMap = new Map<number, any>() const serieDataMap = new Map<number, any>()
await Promise.all( await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
seriesRaw
.filter((s: any) => availableSet.has(s.id))
.map(async (s: any) => {
const sd = await readSerieData(s.id)
if (sd) serieDataMap.set(s.id, sd)
})
)
res.json({ series: seriesRaw.map((serie: any) => { res.json({ series: seriesRaw.map((serie: any) => {
const hasTorrents = availableSet.has(serie.id) const hasTorrents = availableSet.has(serie.id)
const serieData = serieDataMap.get(serie.id) ?? null const serieData = serieDataMap.get(serie.id) ?? null
return { return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) }
...serie,
torrent_count : serieData ? extractTorrentsFromSerieData(serieData).length : 0,
has_torrents : hasTorrents,
download_state: computeSerieDownloadState(serieData, organized, activeTorrents),
}
})}) })})
} catch (err) { } catch (err) {
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
} }
}) })
@@ -418,131 +271,76 @@ app.get('/api/series', requireAuth, async (_req, res) => {
app.get('/api/series/:id', requireAuth, async (req, res) => { app.get('/api/series/:id', requireAuth, async (req, res) => {
const id = Number(req.params.id) const id = Number(req.params.id)
try { try {
const [serieRaw, seasonsData, serieData] = await Promise.all([ const [serieRaw, seasonsData, serieData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`), readSerieData(id)])
fankaiGet(`/series/${id}`),
fankaiGet(`/series/${id}/seasons`),
readSerieData(id),
])
const serie = normalizeSerie(serieRaw) const serie = normalizeSerie(serieRaw)
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? []) const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
// Fetch épisodes depuis l'API Fankai (avec original_filename, thumb_image, etc.)
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => { const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`) const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode) const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
return { ...normalizeSeason(season), episodes } return { ...normalizeSeason(season), episodes }
})) }))
// Charger organized.json
let organized: Record<string, Record<string, string>> = {} let organized: Record<string, Record<string, string>> = {}
try { try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const orgPath = path.join(DATA_DIR, 'organized.json') const availableEpisodeIds = new Set<number>()
if (fs.existsSync(orgPath)) const episodeTorrentMap : Record<number, any> = {}
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) const seasonTorrentMapBySn : Record<number, any> = {}
} catch {} const integraleTorrents : any[] = []
const packEpisodesTorrents : any[] = []
// ── Construire les maps torrent depuis series/{id}.json ─────────── const organizedEpisodeIds = new Set<number>()
const availableEpisodeIds = new Set<number>()
const episodeTorrentMap : Record<number, any> = {} // episode_id → ref
const seasonTorrentMapBySn : Record<number, any> = {} // season_number → ref
const integraleTorrents : any[] = []
const packEpisodesTorrents : any[] = []
const organizedEpisodeIds = new Set<number>()
if (serieData) { if (serieData) {
// ── Torrents niveau série (pack_integrale) ──────────────────
for (const t of (serieData.torrents ?? [])) { for (const t of (serieData.torrents ?? [])) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title, manual: t.manual ?? false }
integraleTorrents.push({ ...t, raw: t.title }) integraleTorrents.push({ ...t, raw: t.title })
const resolved = buildResolvedEpisodes(serieData, t.infohash) const resolved = buildResolvedEpisodes(serieData, t.infohash)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id) for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const ep of resolved) { for (const ep of resolved) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) }
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
}
} }
// ── Torrents niveau saison (pack_saison) ────────────────────
for (const season of serieData.seasons ?? []) { for (const season of serieData.seasons ?? []) {
for (const t of (season.torrents ?? [])) { for (const t of (season.torrents ?? [])) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false } seasonTorrentMapBySn[season.season_number] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false }
seasonTorrentMapBySn[season.season_number] = ref
const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number) const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id) for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const ep of resolved) { for (const ep of resolved) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) }
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
}
} }
// ── Torrents niveau épisode ─────────────────────────────
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
for (const t of (ep.torrents ?? [])) { for (const t of (ep.torrents ?? [])) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false } episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true }
episodeTorrentMap[ep.id] = ref
availableEpisodeIds.add(ep.id) availableEpisodeIds.add(ep.id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
const paths: any[] = ep.paths ?? [] const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
const match = paths.find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
const filename = match ? match.path.split('/').pop() : null const filename = match ? match.path.split('/').pop() : null
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id) if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
} }
} }
} }
// Pack_saison multi-saisons → packEpisodesTorrents si couvre plusieurs saisons
// (cas où un seul torrent couvre plusieurs saisons mais pas toutes → affiché en hero)
// Pour l'instant on le traite comme pack_integrale si au niveau série
} }
// ── Promotion intégrale ───────────────────────────────────────────
// Si pas de pack_integrale explicite mais un pack_saison couvre toutes les saisons
// → déjà géré car on met les torrents série en integraleTorrents directement
// ── Enrichissement des saisons API ───────────────────────────────
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => { const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
const eps = season.episodes.map((ep: any) => { const eps = season.episodes.map((ep: any) => {
// Chercher le torrent : niveau épisode d'abord, sinon le torrent de saison/intégrale sert juste de disponibilité
const epTorrent = episodeTorrentMap[ep.id] ?? null const epTorrent = episodeTorrentMap[ep.id] ?? null
// original_filename vient de l'API Fankai directement let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null
return { if (fankai === null && serieData) {
...ep, outer: for (const sd_season of serieData.seasons ?? []) {
available : availableEpisodeIds.has(ep.id), for (const sdEp of sd_season.episodes ?? []) {
torrent : epTorrent, if (sdEp.id !== ep.id) continue
organized : organizedEpisodeIds.has(ep.id), for (const p of sdEp.paths ?? []) {
const ih = typeof p === 'object' ? p.infohash?.toLowerCase() : null
if (!ih) continue
for (const t of serieData.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } }
for (const s of serieData.seasons ?? []) { for (const t of s.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } } }
}
}
}
} }
return { ...ep, available: availableEpisodeIds.has(ep.id), torrent: epTorrent ? { ...epTorrent, fankai: fankai ?? true } : null, fankai, organized: organizedEpisodeIds.has(ep.id) }
}) })
const total = eps.filter((e: any) => e.available).length const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).length const orgCount = eps.filter((e: any) => e.organized).length
const seasonOrganizedState = return { ...season, torrent: seasonTorrentMapBySn[season.season_number] ?? null, organized_state: orgCount === 0 ? 'none' : orgCount >= total && total > 0 ? 'complete' : 'partial', organized_count: orgCount, episodes: eps }
orgCount === 0 ? 'none' :
orgCount >= total && total > 0 ? 'complete' : 'partial'
return {
...season,
torrent : seasonTorrentMapBySn[season.season_number] ?? null,
organized_state : seasonOrganizedState,
organized_count : orgCount,
episodes : eps,
}
}) })
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })) })
// ── Hero bundles ──────────────────────────────────────────────────
function packEpisodesLabel(t: any): string {
return t.raw ?? t.title ?? 'Pack'
}
const heroBundles: any[] = [
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })),
...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw }))
]
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: heroBundles })
} catch (err) { } catch (err) {
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
} }
}) })
@@ -551,67 +349,46 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
app.post('/api/download', requireAuth, async (req, res) => { app.post('/api/download', requireAuth, async (req, res) => {
const url = req.body.magnet ?? req.body.torrent_url const url = req.body.magnet ?? req.body.torrent_url
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return } if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
logger.info('api', 'Téléchargement demandé')
const results = await dispatchDownload(url) const results = await dispatchDownload(url)
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results }) res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
}) })
// ── Downloads — liste torrents Fankai ────────────────────────── // ── Downloads list ─────────────────────────────────────────────
app.get('/api/downloads', requireAuth, async (_req, res) => { app.get('/api/downloads', requireAuth, async (_req, res) => {
try { try {
const { category } = readSettings() const { category } = readSettings()
const torrents = await dispatchList(category ?? 'fankai') const torrents = await dispatchList(category ?? 'fankai')
let organized: Record<string, Record<string, string>> = {} let organized: Record<string, Record<string, string>> = {}
try { try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {}
const enriched = await Promise.all(torrents.map(async (t: any) => { const enriched = await Promise.all(torrents.map(async (t: any) => {
const orgFiles = organized[t.hash] ?? {} const orgFiles = organized[t.hash] ?? {}
// Chercher les métadonnées via le cache GitHub
let totalFiles = 1 let totalFiles = 1
try { try {
const availableIds = await readAvailable() const availableIds = await readAvailable()
for (const id of availableIds) { for (const id of availableIds) {
const sd = await readSerieData(id) const sd = await readSerieData(id)
if (!sd) continue if (!sd) continue
const allT = extractTorrentsFromSerieData(sd) const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
const match = allT.find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase()) if (match) { totalFiles = buildResolvedEpisodes(sd, match.infohash, match.season_number).length || 1; break }
if (match) {
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
totalFiles = resolved.length || 1
break
}
} }
} catch {} } catch {}
const doneFiles = Object.keys(orgFiles).length const doneFiles = Object.keys(orgFiles).length
let organizeState: 'none' | 'partial' | 'done' = 'none' const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
if (doneFiles >= totalFiles) organizeState = 'done'
else if (doneFiles > 0) organizeState = 'partial'
const notif = recentOrganized.find(n => n.hash === t.hash) const notif = recentOrganized.find(n => n.hash === t.hash)
const errorFiles = notif?.errorFiles ?? [] return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [] }
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles }
})) }))
res.json(enriched) res.json(enriched)
} catch (err) { } catch (err) {
logger.error('api', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
} }
}) })
// ── Résultats d'organisation récents ────────────────────────── // ── Organize ───────────────────────────────────────────────────
interface OrganizeNotif { interface OrganizeNotif {
hash : string hash: string; name: string; done: number; skipped: number; errors: number
name : string errorFiles: { file: string; error: string }[]; at: string
done : number
skipped : number
errors : number
errorFiles : { file: string; error: string }[]
at : string
} }
const recentOrganized: OrganizeNotif[] = [] const recentOrganized: OrganizeNotif[] = []
const MAX_NOTIFS = 20 const MAX_NOTIFS = 20
@@ -624,128 +401,152 @@ function pushNotif(n: OrganizeNotif) {
app.get('/api/organize/recent', requireAuth, (_req, res) => { app.get('/api/organize/recent', requireAuth, (_req, res) => {
res.json(recentOrganized) res.json(recentOrganized)
}) })
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => { app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
recentOrganized.length = 0 recentOrganized.length = 0
logger.info('api', 'Historique des imports effacé')
res.json({ ok: true }) res.json({ ok: true })
}) })
app.post('/api/organize', requireAuth, async (req, res) => { app.post('/api/organize', requireAuth, async (req, res) => {
const { hash, name, save_path } = req.body const { hash, name, save_path } = req.body
if (!hash || !name || !save_path) { if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
res.status(400).json({ error: 'hash, name et save_path requis' })
return
}
try { try {
const seriesData = await loadEnrichedSeriesData() const seriesData = await loadEnrichedSeriesData()
const result = await organizeTorrent(hash, name, save_path, seriesData) const result = await organizeTorrent(hash, name, save_path, seriesData)
if (result.done > 0 || result.errors.length > 0) {
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
}
res.json(result) res.json(result)
} catch (err) { } catch (err) {
logger.error('api', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
} }
}) })
// ── Logs ────────────────────────────────────────────────────── // ── Logs ───────────────────────────────────────────────────────
app.get('/api/logs', requireAuth, (req, res) => { app.get('/api/logs', requireAuth, (req, res) => {
const limit = Number(req.query.limit) || 100 const limit = Number(req.query.limit) || 100
const level = (req.query.level as string) || 'all' const level = (req.query.level as string) || 'all'
const source = (req.query.source as string) || undefined const source = (req.query.source as string) || undefined
const entries = readLogs({ limit, level: level as any, source }) res.json({ entries: readLogs({ limit, level: level as any, source }), size: logsFileSize() })
res.json({ entries, size: logsFileSize() })
}) })
app.post('/api/logs/clear', requireAuth, (_req, res) => { app.post('/api/logs/clear', requireAuth, (_req, res) => {
clearLogs() clearLogs()
logger.info('api', 'Logs effacés') logger.info('api', 'Logs effacés')
res.json({ ok: true }) res.json({ ok: true })
}) })
// ── System info ──────────────────────────────────────────────── // ── Système ────────────────────────────────────────────────────
app.get('/api/system', requireAuth, (_req, res) => { app.get('/api/system', requireAuth, (_req, res) => {
const isDocker = fs.existsSync('/.dockerenv') res.json({ isDocker: fs.existsSync('/.dockerenv') })
res.json({ isDocker })
}) })
app.get('/api/browse', requireAuth, (req, res) => { app.get('/api/browse', requireAuth, (req, res) => {
const dirPath = (req.query.path as string) || '/' const dirPath = (req.query.path as string) || '/'
try { try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true }) const entries = fs.readdirSync(dirPath, { withFileTypes: true })
const dirs = entries const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b))
.filter(e => e.isDirectory()) res.json({ path: dirPath, parent: dirPath === '/' ? null : path.dirname(dirPath), dirs })
.map(e => e.name)
.filter(n => !n.startsWith('.'))
.sort((a, b) => a.localeCompare(b))
const parent = dirPath === '/' ? null : path.dirname(dirPath)
res.json({ path: dirPath, parent, dirs })
} catch (err) { } catch (err) {
logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' }) res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
} }
}) })
app.get('/api/torrents/status', requireAuth, async (_req, res) => { app.get('/api/torrents/status', requireAuth, async (_req, res) => {
try { try {
const available = await readAvailable() const available = await readAvailable()
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 }) res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
} catch { res.json({ exists: false, count: 0, empty: true }) } } catch { res.json({ exists: false, count: 0, empty: true }) }
}) })
// ── Update : vide le cache pour forcer le rechargement depuis GitHub ──────────
app.post('/api/update', requireAuth, async (_req, res) => { app.post('/api/update', requireAuth, async (_req, res) => {
try { try {
cacheClear() cacheClear()
// Pré-charger available.json pour vérifier que GitHub répond logger.info('api', 'Cache GitHub vidé — rechargement forcé')
const availableIds = await readAvailable() const availableIds = await readAvailable()
if (!Array.isArray(availableIds)) throw new Error('available.json invalide') if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
res.json({ ok: true, count: availableIds.length }) res.json({ ok: true, count: availableIds.length })
} catch (err) { } catch (err) {
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
} }
}) })
app.post('/api/scan', requireAuth, async (_req, res) => { app.post('/api/scan', requireAuth, async (_req, res) => {
try { try {
const { mediaPath } = readSettings() const { mediaPath } = readSettings()
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') logger.info('api', 'Scan médiathèque manuel lancé')
const seriesData = await loadEnrichedSeriesData() const seriesData = await loadEnrichedSeriesData()
const result = await scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData) const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`)
res.json({ ok: true, ...result }) res.json({ ok: true, ...result })
} catch (err) { } catch (err) {
logger.error('api', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
} }
}) })
// ── SPA fallback (prod) ──────────────────────────────────────── // ── Debug (dev mode uniquement) ───────────────────────────────
if (fs.existsSync(PUBLIC_PATH)) { app.get('/api/debug/stats', requireAuth, (req, res) => {
app.get('*path', (_req, res) => { const { devMode } = readSettings()
res.sendFile(path.join(PUBLIC_PATH, 'index.html')) if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
const mem = process.memoryUsage()
const uptimeS = Math.floor(process.uptime())
const h = Math.floor(uptimeS / 3600)
const m = Math.floor((uptimeS % 3600) / 60)
const s = uptimeS % 60
let organizedCount = 0
try {
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath)) {
const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
organizedCount = Object.values(org).reduce((acc: number, files: any) => acc + Object.keys(files).length, 0)
}
} catch {}
res.json({
memory: {
heapUsed : Math.round(mem.heapUsed / 1024 / 1024),
heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
rss : Math.round(mem.rss / 1024 / 1024),
},
cache: {
entries : _cache.size,
ttlHours : 6,
},
uptime: `${h}h ${m}m ${s}s`,
uptimeSeconds: uptimeS,
worker: {
running: workerRunning,
},
organized: {
trackedFiles: organizedCount,
},
requests: {
notifs: recentOrganized.length,
},
}) })
})
if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
} }
// ── Start ────────────────────────────────────────────────────── // ── Démarrage ──────────────────────────────────────────────────
app.listen(PORT, async () => { app.listen(PORT, async () => {
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`) logger.info('api', `Serveur démarré sur le port ${PORT}`)
console.log(`[fankarr] Cache GitHub TTL: 6h — premier fetch au démarrage...`)
// Pré-chauffer le cache available.json au démarrage
try { try {
const available = await readAvailable() const available = await readAvailable()
console.log(`[fankarr] ${available.length} séries disponibles (via GitHub)`) logger.info('api', `Cache GitHub initialisé — ${available.length} séries disponibles`)
} catch (err) { } catch (err) {
console.warn(`[fankarr] ⚠ Impossible de charger available.json: ${err instanceof Error ? err.message : err}`) logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`)
} }
// Scan initial du mediaPath
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
const { mediaPath } = readSettings() const { mediaPath } = readSettings()
loadEnrichedSeriesData().then(seriesData => loadEnrichedSeriesData()
scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData) .then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData))
).catch(err => .catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
logger.error('organize', `scanMediaPath échoué: ${err}`)
)
// Auto-organise — polling différentiel toutes les 5 minutes
// Le worker ne se lance que si un torrent vient de passer en seeding
const autoOrganize = async () => { const autoOrganize = async () => {
try { try {
const { category } = readSettings() const { category } = readSettings()
@@ -760,10 +561,10 @@ app.listen(PORT, async () => {
} }
) )
} catch (err) { } catch (err) {
console.error('[organize] Erreur auto-organise:', err) logger.error('api', `Auto-organise échoué : ${err instanceof Error ? err.message : err}`)
} }
} }
// Premier run 10s après démarrage, puis toutes les 5 minutes
setTimeout(() => { setTimeout(() => {
autoOrganize() autoOrganize()
setInterval(autoOrganize, 5 * 60_000) setInterval(autoOrganize, 5 * 60_000)
+115 -86
View File
@@ -2,13 +2,6 @@
* organize-worker.ts * organize-worker.ts
* ================== * ==================
* Worker thread dédié à l'organisation des fichiers. * 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' import fs from 'fs'
@@ -53,33 +46,38 @@ function isOrganized(hash: string, filename: string): boolean {
return !!loadOrganized()[hash]?.[filename] return !!loadOrganized()[hash]?.[filename]
} }
// ─── 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 { function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
const h = hash.toLowerCase()
for (const sd of seriesData) { for (const sd of seriesData) {
// Niveau série
for (const t of sd.torrents ?? []) { for (const t of sd.torrents ?? []) {
if (t.infohash?.toLowerCase() === hash.toLowerCase()) if (t.infohash?.toLowerCase() === h)
return { torrent: t, serieData: sd } 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 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) => 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) { if (matchingEpisodes.length === 1) {
// Torrent propre à un seul épisode → cas épisode unique
const ep = matchingEpisodes[0] 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 } return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
} else if (matchingEpisodes.length > 1) { } 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() === h)
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
return { torrent: { ...t, _season: season }, serieData: sd } return { torrent: { ...t, _season: season }, serieData: sd }
} }
} }
@@ -87,16 +85,14 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
return null 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( function buildFileMap(
serieData : any, serieData : any,
hash : string, hash : string,
nfoSupport : boolean, nfoSupport : boolean,
seasonFilter?: number seasonFilter?: number,
): Map<string, { season_number: number; original_filename: string; fullPath: string }> { ): Map<string, { season_number: number; nfo_filename: string; fullPath: string }> {
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>() // FIX : type de retour utilise nfo_filename au lieu de original_filename
const map = new Map<string, { season_number: number; nfo_filename: string; fullPath: string }>()
const h = hash.toLowerCase() const h = hash.toLowerCase()
for (const season of serieData.seasons ?? []) { for (const season of serieData.seasons ?? []) {
@@ -117,25 +113,33 @@ function buildFileMap(
if (!matchedPath) continue if (!matchedPath) continue
const filename = matchedPath.split('/').pop() ?? matchedPath const filename = matchedPath.split('/').pop() ?? matchedPath
// nfoSupport → original_filename (correspond aux .nfo GitLab) const fmtName = ep.formatted_name?.trim()
// sinon → formatted_name de l'API (nommage Jellyfin/Plex standardisé) ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
const destName = nfoSupport : null
? (ep.original_filename ?? filename)
: (ep.formatted_name if (!nfoSupport && !fmtName) {
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`)
: (ep.original_filename ?? filename)) }
// FIX : priorité à nfo_filename, fallback sur formatted_name puis filename brut
// On remplace l'extension du nfo_filename par celle du fichier source (.nfo → .mkv)
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, { map.set(filename, {
season_number : season.season_number, season_number: season.season_number,
original_filename: destName, nfo_filename : destName,
fullPath : matchedPath, fullPath : matchedPath,
}) })
} }
} }
return map return map
} }
// ─── Dossiers exclus ───────────────────────────────────────── // ─── Dossiers exclus ──────────────────────────────────────────
const EXCLUDED_FOLDERS = new Set([ const EXCLUDED_FOLDERS = new Set([
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork', 'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced', 'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
@@ -153,7 +157,7 @@ function isInExcludedFolder(filePath: string[]): boolean {
return false return false
} }
// ─── Map correction titres API → titres GitLab ─────────────── // ─── Map titres API → GitLab ──────────────────────────────────
const SERIE_TITLE_GITLAB_MAP: Record<string, string> = { const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū', 'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū', 'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
@@ -177,21 +181,21 @@ const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
async function fetchJson(url: string): Promise<any> { async function fetchJson(url: string): Promise<any> {
const { default: nodeFetch } = await import('node-fetch') const { default: nodeFetch } = await import('node-fetch')
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } }) 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() return res.json()
} }
async function fetchBinary(url: string): Promise<Buffer> { async function fetchBinary(url: string): Promise<Buffer> {
const { default: nodeFetch } = await import('node-fetch') const { default: nodeFetch } = await import('node-fetch')
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } }) 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()) return Buffer.from(await res.arrayBuffer())
} }
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> { async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
const gitlabTitle = getGitlabSerieTitle(serieTitle) const gitlabTitle = getGitlabSerieTitle(serieTitle)
const folderPath = `pack/${gitlabTitle}` 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[] = [] let files: any[] = []
try { try {
@@ -206,63 +210,81 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
page++ page++
} }
} catch (err) { } 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 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') 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) { for (const entry of fileEntries) {
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '') const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
const destPath = path.join(destRoot, relativePath) const destPath = path.join(destRoot, relativePath)
if (fs.existsSync(destPath)) continue if (fs.existsSync(destPath)) { skipped++; continue }
try { try {
fs.mkdirSync(path.dirname(destPath), { recursive: true }) fs.mkdirSync(path.dirname(destPath), { recursive: true })
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}` const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
const data = await fetchBinary(rawUrl) const data = await fetchBinary(rawUrl)
fs.writeFileSync(destPath, data) fs.writeFileSync(destPath, data)
log(`[nfo] ✓ ${relativePath}`) downloaded++
} catch (err) { } 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 ─────────────────────────────────────────── // ─── Filesystem ops ───────────────────────────────────────────
function seasonFolder(n: number): string { return `Saison ${n}` }
// FIX : remplace l'extension d'un nom de fichier par celle du fichier source
// ex: "[Shalouf] Dandadan 01.nfo" + ".mkv" → "[Shalouf] Dandadan 01.mkv"
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 { function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true } try { fs.linkSync(src, dest); return true }
catch (err) { 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 return false
} }
} }
function sanitizeDirName(name: string): string {
return name
.replace(/:/g, ' -')
.replace(/[<>"/\\|?*]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
// ─── Organise un torrent ────────────────────────────────────── // ─── Organise un torrent ──────────────────────────────────────
async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) { async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) {
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings() const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] } 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) const found = findTorrentByHash(hash, seriesData)
if (!found) { 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}`) throw new Error(`Torrent introuvable : ${hash}`)
} }
const { torrent, serieData } = found const { torrent, serieData } = found
const serieTitle = serieData.title ?? serieData.show_title ?? name const rawTitle = serieData.title ?? serieData.show_title ?? name
const torrentTitle = torrent.title ?? name const serieTitle = sanitizeDirName(rawTitle)
log(`[organize] ✓ Trouvé : ${torrentTitle} — série: ${serieTitle}`)
debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
// ── NFO/images depuis GitLab ──────────────────────────────
if (nfoSupport) { if (nfoSupport) {
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle)) await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
} }
@@ -278,11 +300,18 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break } else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break }
} }
const filename = filePath?.split('/').pop() ?? name const filename = filePath?.split('/').pop() ?? name
const destName = nfoSupport const fmtName = ep.formatted_name?.trim()
? (ep.original_filename ?? filename) ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: (ep.formatted_name : null
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: (ep.original_filename ?? filename)) // FIX : priorité à nfo_filename pour épisode unique aussi
// On swap l'extension (.nfo → extension du fichier source)
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 destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
const dest = path.join(destDir, destName) const dest = path.join(destDir, destName)
@@ -302,7 +331,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } } for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) { if (!src) {
error(`[organize] ✗ ${filename} introuvable`) error(`Fichier source introuvable : ${filename}`)
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] } return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] }
} }
@@ -316,26 +345,25 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
} }
} }
markOrganized(hash, filename) markOrganized(hash, filename)
log(`[organize] ✓ ${destName} → Saison ${season.season_number}`) log(`${destName} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 } return { ...result, total: 1, done: 1 }
} }
// ── Pack saison / intégrale ─────────────────────────────── // ── Pack saison / intégrale ───────────────────────────────
const seasonFilter = torrent._season?.season_number const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter) const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter)
if (fileMap.size === 0) { if (fileMap.size === 0) {
warn(`[organize] Aucun fichier mappé pour ${name}`) warn(`Aucun fichier mappé pour "${name}"`)
return result return result
} }
result.total = fileMap.size result.total = fileMap.size
for (const [filename, { season_number, original_filename, fullPath }] of fileMap) { // FIX : destructure nfo_filename au lieu de original_filename
for (const [filename, { season_number, nfo_filename, fullPath }] of fileMap) {
if (isOrganized(hash, filename)) { result.skipped++; continue } if (isOrganized(hash, filename)) { 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 = [ const candidates = [
path.join(savePath, fullPath), path.join(savePath, fullPath),
path.join(completePath, fullPath), path.join(completePath, fullPath),
@@ -346,13 +374,13 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } } for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) { if (!src) {
error(`[organize] ✗ ${filename} introuvable`) error(`Fichier source introuvable : ${filename}`)
result.errors.push({ file: filename, error: 'Source introuvable' }) result.errors.push({ file: filename, error: 'Source introuvable' })
continue continue
} }
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number)) 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, filename); result.skipped++; continue }
@@ -366,51 +394,52 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
} }
markOrganized(hash, filename) markOrganized(hash, filename)
result.done++ result.done++
log(`[organize] ✓ ${original_filename} → Saison ${season_number}`) debug(`${nfo_filename} → Saison ${season_number}`)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' 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 }) result.errors.push({ file: filename, error: msg })
} }
} }
log(`[organize] ── ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
return result return result
} }
// ─── Boucle principale du worker ───────────────────────────── // ─── Boucle principale ────────────────────────────────────────
parentPort?.on('message', async (msg: any) => { parentPort?.on('message', async (msg: any) => {
if (msg.type !== 'run') return if (msg.type !== 'run') return
const torrents: any[] = msg.torrents const torrents: any[] = msg.torrents
const seriesData: any[] = msg.seriesData ?? [] const seriesData: any[] = msg.seriesData ?? []
const organized = loadOrganized() const organized = loadOrganized()
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
for (const t of torrents) { for (const t of torrents) {
if (t.state !== 'seeding') continue if (t.state !== 'seeding') continue
const found = findTorrentByHash(t.hash, seriesData) const found = findTorrentByHash(t.hash, seriesData)
if (!found) { 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 continue
} }
// Vérifier si déjà tout organisé
const { torrent, serieData } = found const { torrent, serieData } = found
const seasonFilter = torrent._season?.season_number const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter) const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
const allDone = fileMap.size > 0 const allDone = fileMap.size > 0
? [...fileMap.keys()].every(f => organized[t.hash]?.[f]) ? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
: !!organized[t.hash]?.[t.name] : !!organized[t.hash]?.[t.name]
if (allDone) continue if (allDone) {
debug(`"${t.name}" déjà entièrement importé — skip`)
continue
}
try { try {
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData) const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result }) parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
} catch (err) { } 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}`)
} }
} }
+69 -48
View File
@@ -4,8 +4,8 @@
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express. * Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
*/ */
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { logger } from './logger.js' import { logger } from './logger.js'
import { DATA_DIR } from './config.js' import { DATA_DIR } from './config.js'
@@ -13,10 +13,7 @@ import { readSettings } from './settings.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// Un seul worker à la fois export let workerRunning = false
let workerRunning = false
// État précédent des torrents pour détecter les passages downloading → seeding
const _prevStates = new Map<string, string>() const _prevStates = new Map<string, string>()
function resolveWorkerPath(): string { function resolveWorkerPath(): string {
@@ -33,10 +30,8 @@ export interface OrganizeResult {
errors : { file: string; error: string }[] errors : { file: string; error: string }[]
} }
/** // ── Scan initial ──────────────────────────────────────────────
* Scan initial du mediaPath au démarrage.
* Construit l'index filename → infohash depuis les seriesData passées en paramètre.
*/
export async function scanMediaPath( export async function scanMediaPath(
mediaPath : string, mediaPath : string,
organizedPath: string, organizedPath: string,
@@ -45,20 +40,18 @@ export async function scanMediaPath(
const result = { found: 0, added: 0 } const result = { found: 0, added: 0 }
if (!fs.existsSync(mediaPath)) { if (!fs.existsSync(mediaPath)) {
logger.warn('organize', `scanMediaPath: ${mediaPath} inexistant, skip`) logger.warn('organize', `Dossier médiathèque introuvable : ${mediaPath}`)
return result return result
} }
// Construire index filename → infohash depuis les seriesData logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
const filenameIndex = new Map<string, string>() // filename → infohash
const filenameIndex = new Map<string, string>()
for (const sd of seriesData) { for (const sd of seriesData) {
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
for (const p of ep.paths ?? []) { for (const p of ep.paths ?? []) {
if (typeof p === 'string') { if (typeof p === 'string') continue
// Legacy : pas d'infohash disponible, on skip pour le scan
continue
}
const hash = p.infohash?.toLowerCase() const hash = p.infohash?.toLowerCase()
if (!hash || !p.path) continue if (!hash || !p.path) continue
const filename = p.path.replace(/\\/g, '/').split('/').pop() const filename = p.path.replace(/\\/g, '/').split('/').pop()
@@ -68,7 +61,6 @@ export async function scanMediaPath(
} }
} }
// Charger organized.json existant
let organized: Record<string, Record<string, string>> = {} let organized: Record<string, Record<string, string>> = {}
try { try {
if (fs.existsSync(organizedPath)) if (fs.existsSync(organizedPath))
@@ -100,7 +92,6 @@ export async function scanMediaPath(
walk(mediaPath) walk(mediaPath)
// Nettoyer les entrées orphelines
let removed = 0 let removed = 0
for (const [hash, files] of Object.entries(organized)) { for (const [hash, files] of Object.entries(organized)) {
for (const filename of Object.keys(files)) { for (const filename of Object.keys(files)) {
@@ -114,28 +105,29 @@ export async function scanMediaPath(
if (result.added > 0 || removed > 0) { if (result.added > 0 || removed > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') 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`) logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`)
} else { } else {
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`) logger.info('organize', `Scan terminé — ${result.found} fichiers, rien de nouveau`)
} }
return result return result
} }
/** // ── Auto-import ───────────────────────────────────────────────
* Auto-import — appelée depuis index.ts avec les seriesData du cache GitHub.
*/
export async function autoOrganizeAll( export async function autoOrganizeAll(
listFn : () => Promise<any[]>, listFn : () => Promise<any[]>,
seriesData : any[], seriesData : any[],
onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
): Promise<void> { ): Promise<void> {
// Vérifier si l'import auto est activé
const { autoImport } = readSettings() const { autoImport } = readSettings()
if (!autoImport) return if (!autoImport) {
logger.debug('organize', 'Import automatique désactivé — skip')
return
}
if (workerRunning) { if (workerRunning) {
console.log('[organize] Worker déjà en cours, skip') logger.debug('organize', 'Worker déjà en cours — skip')
return return
} }
@@ -143,7 +135,7 @@ export async function autoOrganizeAll(
try { try {
torrents = await listFn() torrents = await listFn()
} catch (err) { } 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 return
} }
@@ -159,7 +151,6 @@ export async function autoOrganizeAll(
for (const t of torrents) _prevStates.set(t.hash, t.state) for (const t of torrents) _prevStates.set(t.hash, t.state)
// Charger organized.json pour vérifier s'il reste des torrents non organisés
let organized: Record<string, Record<string, string>> = {} let organized: Record<string, Record<string, string>> = {}
try { try {
const orgPath = path.join(DATA_DIR, 'organized.json') const orgPath = path.join(DATA_DIR, 'organized.json')
@@ -175,21 +166,31 @@ export async function autoOrganizeAll(
if (seedingCount === 0) return if (seedingCount === 0) return
if (newlySeeding.length > 0 && !isFirstRun) { 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 { } 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) => { worker.on('message', (msg: any) => {
if (msg.type === 'log') { if (msg.type === 'log') {
if (msg.level === 'error') console.error(msg.msg) logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
else if (msg.level === 'warn') console.warn(msg.msg)
else console.log(msg.msg)
} else if (msg.type === 'result') { } 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?.({ onResult?.({
hash : msg.hash, hash : msg.hash,
name : msg.name, name : msg.name,
@@ -199,27 +200,35 @@ export async function autoOrganizeAll(
errorFiles: msg.errors, errorFiles: msg.errors,
}) })
} else if (msg.type === 'done') { } else if (msg.type === 'done') {
console.log('[organize] Worker terminé') logger.debug('organize', 'Worker terminé')
workerRunning = false workerRunning = false
worker.terminate() worker.terminate()
} }
}) })
worker.on('error', (err) => { console.error('[organize] Worker erreur:', err); workerRunning = false }) worker.on('error', (err) => {
worker.on('exit', (code) => { if (code !== 0) console.error(`[organize] Worker exit ${code}`); workerRunning = false }) 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 }) worker.postMessage({ type: 'run', torrents, seriesData })
} }
/** // ── Import manuel ─────────────────────────────────────────────
* Organisation manuelle d'un torrent.
*/
export async function organizeTorrent( export async function organizeTorrent(
hash : string, hash : string,
name : string, name : string,
savePath : string, savePath : string,
seriesData: any[] seriesData: any[]
): Promise<OrganizeResult> { ): Promise<OrganizeResult> {
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const worker = new Worker(resolveWorkerPath()) const worker = new Worker(resolveWorkerPath())
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' } const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
@@ -227,22 +236,34 @@ export async function organizeTorrent(
worker.on('message', (msg: any) => { worker.on('message', (msg: any) => {
if (msg.type === 'log') { if (msg.type === 'log') {
if (msg.level === 'error') console.error(msg.msg) logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
else if (msg.level === 'warn') console.warn(msg.msg)
else console.log(msg.msg)
} else if (msg.type === 'result') { } else if (msg.type === 'result') {
result.total = msg.total result.total = msg.total
result.skipped = msg.skipped result.skipped = msg.skipped
result.done = msg.done result.done = msg.done
result.errors = msg.errors 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') { } else if (msg.type === 'done') {
worker.terminate() worker.terminate()
resolve(result) resolve(result)
} }
}) })
worker.on('error', (err) => { worker.terminate(); reject(err) }) worker.on('error', (err) => {
worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) }) 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 }) worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
}) })
+14 -12
View File
@@ -5,21 +5,23 @@ import { DATA_DIR } from './config.js'
const DATA_PATH = path.join(DATA_DIR, 'settings.json') const DATA_PATH = path.join(DATA_DIR, 'settings.json')
export interface Settings { export interface Settings {
mediaPath : string mediaPath : string
completePath : string completePath : string
organizeMode : 'hardlink' | 'move' organizeMode : 'hardlink' | 'move'
category : string category : string
nfoSupport : boolean nfoSupport : boolean
autoImport : boolean // import automatique quand un torrent passe en seeding autoImport : boolean
devMode : boolean
} }
const defaults: Settings = { const defaults: Settings = {
mediaPath : '', mediaPath : '',
completePath : '', completePath: '',
organizeMode : 'hardlink', organizeMode: 'hardlink',
category : 'fankai', category : 'fankai',
nfoSupport : false, nfoSupport : false,
autoImport : true, autoImport : true,
devMode : false,
} }
export function readSettings(): Settings { 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/' : '/',
})
}
+58 -31
View File
@@ -2,14 +2,13 @@
* Torrent Client Registry * Torrent Client Registry
* ======================= * =======================
* Interface commune + registry de tous les clients disponibles. * 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 { randomUUID } from 'crypto'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { DATA_DIR } from '../config.js' import { DATA_DIR } from '../config.js'
import { logger } from '../logger.js'
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -22,36 +21,32 @@ export interface FieldDef {
default? : string | number default? : string | number
} }
/** Définition statique d'un type de client (ce qu'il expose au front) */
export interface TorrentClientDefinition { export interface TorrentClientDefinition {
id : string // "qbittorrent", "transmission", etc. id : string
label : string // "qBittorrent" label : string
fields : FieldDef[] // schema du formulaire fields : FieldDef[]
} }
/** Instance sauvegardée d'un client configuré par l'utilisateur */
export interface SavedClient { export interface SavedClient {
uuid : string // identifiant unique de cette instance uuid : string
name : string // nom custom donné par l'utilisateur name : string
type : string // id du TorrentClientDefinition type : string
config: Record<string, string | number> config: Record<string, string | number>
} }
/** État normalisé d'un torrent (commun à tous les clients) */
export interface TorrentInfo { export interface TorrentInfo {
hash : string hash : string
name : string name : string
state : 'downloading' | 'seeding' | 'paused' | 'checking' | 'error' | 'unknown' state : 'downloading' | 'seeding' | 'paused' | 'checking' | 'error' | 'unknown'
progress : number // 0-100 progress : number
size : number // bytes size : number
downloaded: number // bytes downloaded: number
speed : number // bytes/s speed : number
eta : number // secondes, -1 si inconnu eta : number
save_path : string // dossier de complétion save_path : string
category : string category : string
} }
/** Interface que chaque client doit implémenter */
export interface TorrentClientDriver { export interface TorrentClientDriver {
definition : TorrentClientDefinition definition : TorrentClientDefinition
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }> test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
@@ -66,6 +61,7 @@ const drivers = new Map<string, TorrentClientDriver>()
export function registerDriver(driver: TorrentClientDriver) { export function registerDriver(driver: TorrentClientDriver) {
drivers.set(driver.definition.id, driver) drivers.set(driver.definition.id, driver)
logger.debug('torrent-clients', `Driver enregistré : ${driver.definition.label}`)
} }
export function getDriver(type: string): TorrentClientDriver | undefined { export function getDriver(type: string): TorrentClientDriver | undefined {
@@ -76,7 +72,7 @@ export function getAvailableClients(): TorrentClientDefinition[] {
return [...drivers.values()].map(d => d.definition) return [...drivers.values()].map(d => d.definition)
} }
// ─── Persistence (clients enregistrés) ──────────────────────────────────────── // ─── Persistence ──────────────────────────────────────────────────────────────
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json') const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
@@ -103,14 +99,41 @@ export function addClient(name: string, type: string, config: Record<string, str
const client: SavedClient = { uuid: randomUUID(), name, type, config } const client: SavedClient = { uuid: randomUUID(), name, type, config }
clients.push(client) clients.push(client)
saveClients(clients) saveClients(clients)
logger.info('torrent-clients', `Client ajouté : "${name}" (${type})`)
return client
}
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
const oldClient = clients[index]
const definition = getDriver(type)?.definition
// Conserver les mots de passe si "••••••••" est envoyé
if (definition) {
for (const field of definition.fields) {
if (field.type === 'password' && config[field.key] === '••••••••') {
config[field.key] = oldClient.config[field.key]
}
}
}
const client: SavedClient = { uuid, name, type, config }
clients[index] = client
saveClients(clients)
logger.info('torrent-clients', `Client mis à jour : "${name}" (${type})`)
return client return client
} }
export function removeClient(uuid: string): boolean { 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) const filtered = clients.filter(c => c.uuid !== uuid)
if (filtered.length === clients.length) return false if (filtered.length === clients.length) return false
saveClients(filtered) saveClients(filtered)
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
return true return true
} }
@@ -118,7 +141,6 @@ export function getClient(uuid: string): SavedClient | undefined {
return loadClients().find(c => c.uuid === uuid) return loadClients().find(c => c.uuid === uuid)
} }
/** Masque les champs password pour l'envoi au front */
export function sanitizeClient(client: SavedClient, definition?: TorrentClientDefinition): SavedClient { export function sanitizeClient(client: SavedClient, definition?: TorrentClientDefinition): SavedClient {
if (!definition) definition = getDriver(client.type)?.definition if (!definition) definition = getDriver(client.type)?.definition
if (!definition) return client if (!definition) return client
@@ -131,35 +153,40 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
return { ...client, config } return { ...client, config }
} }
// ─── Dispatch download vers tous les clients ────────────────────────────────── // ─── Dispatch download ─────────────────────────────────────────────────────────
export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> { export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
const clients = loadClients() const clients = loadClients()
if (clients.length === 0) {
logger.warn('torrent-clients', 'Aucun client configuré — téléchargement impossible')
return []
}
const results = [] const results = []
for (const client of clients) { for (const client of clients) {
const driver = getDriver(client.type) const driver = getDriver(client.type)
if (!driver) { 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` }) results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Driver "${client.type}" introuvable` })
continue continue
} }
try { try {
await driver.add(client.config, url) 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 }) results.push({ uuid: client.uuid, name: client.name, ok: true })
} catch (err) { } catch (err) {
results.push({ const msg = err instanceof Error ? err.message : 'Erreur inconnue'
uuid : client.uuid, logger.error('torrent-clients', `Échec envoi vers "${client.name}" : ${msg}`)
name : client.name, results.push({ uuid: client.uuid, name: client.name, ok: false, error: msg })
ok : false,
error: err instanceof Error ? err.message : 'Erreur inconnue'
})
} }
} }
return results return results
} }
// ─── Dispatch list vers tous les clients ───────────────────────────────────── // ─── Dispatch list ─────────────────────────────────────────────────────────────
export async function dispatchList(category?: string): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> { export async function dispatchList(category?: string): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
const clients = loadClients() const clients = loadClients()
@@ -169,14 +196,14 @@ export async function dispatchList(category?: string): Promise<(TorrentInfo & {
const driver = getDriver(client.type) const driver = getDriver(client.type)
if (!driver) continue if (!driver) continue
try { try {
// La catégorie du client est prioritaire sur la catégorie globale
const clientCategory = (client.config.category as string) || category const clientCategory = (client.config.category as string) || category
const torrents = await driver.list(client.config, clientCategory) const torrents = await driver.list(client.config, clientCategory)
for (const t of torrents) { for (const t of torrents) {
results.push({ ...t, client_uuid: client.uuid, client_name: client.name }) results.push({ ...t, client_uuid: client.uuid, client_name: client.name })
} }
} catch (err) { } 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}`)
} }
} }
+21 -14
View File
@@ -3,8 +3,8 @@
*/ */
import type { TorrentClientDriver, TorrentInfo } from './index.js' import type { TorrentClientDriver, TorrentInfo } from './index.js'
import { logger } from '../logger.js'
// Mapping état qBittorrent → état normalisé
function mapState(state: string): TorrentInfo['state'] { function mapState(state: string): TorrentInfo['state'] {
if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading' if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading'
if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding' if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding'
@@ -29,7 +29,7 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`) if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
const cookie = res.headers.get('set-cookie') ?? '' 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') if (!sid) throw new Error('Cookie SID introuvable')
return sid return sid
} }
@@ -39,19 +39,22 @@ const QB: TorrentClientDriver = {
id : 'qbittorrent', id : 'qbittorrent',
label : 'qBittorrent', label : 'qBittorrent',
fields: [ fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true }, { key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true }, { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', 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: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
], ],
}, },
async test(config) { async test(config) {
try { try {
await qbLogin(config) await qbLogin(config)
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' } return { ok: true, message: 'Connexion réussie' }
} catch (err) { } 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 +62,26 @@ const QB: TorrentClientDriver = {
try { try {
const sid = await qbLogin(config) const sid = await qbLogin(config)
const res = await fetch(`${config.url}/api/v2/app/version`, { const res = await fetch(`${config.url}/api/v2/app/version`, {
headers: { Cookie: `SID=${sid}` } headers: { Cookie: `SID=${sid}` },
}) })
if (!res.ok) return { online: false } if (!res.ok) return { online: false }
return { online: true, version: (await res.text()).trim() } const version = (await res.text()).trim()
} catch { 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 } return { online: false }
} }
}, },
async list(config, category) { async list(config, category) {
const sid = await qbLogin(config) const sid = await qbLogin(config)
const params = new URLSearchParams() const params = new URLSearchParams()
if (category) params.set('category', category) if (category) params.set('category', category)
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, { const res = await 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() const data: any[] = await res.json()
return data.map(t => ({ return data.map(t => ({
hash : t.hash, hash : t.hash,
@@ -92,7 +98,7 @@ const QB: TorrentClientDriver = {
}, },
async add(config, url) { async add(config, url) {
const sid = await qbLogin(config) const sid = await qbLogin(config)
const form = new FormData() const form = new FormData()
form.append('urls', url) form.append('urls', url)
if (config.category) form.append('category', String(config.category)) if (config.category) form.append('category', String(config.category))
@@ -104,6 +110,7 @@ const QB: TorrentClientDriver = {
}) })
const text = await res.text() const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`) if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
logger.info('qbittorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`)
}, },
} }
+135
View File
@@ -0,0 +1,135 @@
/**
* 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' },
],
},
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', 'rateDownload', '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
// Filtrer par label uniquement, comme qBittorrent filtre par catégorie
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,
speed : t.rateDownload,
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'})`)
},
}
export default TR
+2 -8
View File
@@ -1,12 +1,6 @@
<template> <template>
<div class="min-h-screen bg-[#080c10] text-[#c8d8e8] font-mono"> <RouterView />
<RouterView v-slot="{ Component }"> <ToastContainer />
<keep-alive include="CatalogView">
<component :is="Component" />
</keep-alive>
</RouterView>
<ToastContainer />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
+148
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> <template>
<!-- Overlay --> <Teleport to="body">
<div class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')"> <div class="fixed inset-0 bg-black/60 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"> <div class="bg-card border border-border rounded-xl w-full max-w-lg flex flex-col" style="max-height: 70vh">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-[#1e2d3d] shrink-0"> <div class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
<div> <div>
<div class="text-[10px] text-[#e8513a] tracking-widest mb-0.5">CHOISIR UN DOSSIER</div> <p class="text-xs text-accent font-medium mb-0.5">Choisir un dossier</p>
<div class="text-xs text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</div> <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> </div>
<button @click="$emit('cancel')" class="text-[#5a7a94] hover:text-white transition-colors cursor-pointer text-lg leading-none">✕</button>
</div>
<!-- Chemin éditable --> <!-- Chemin éditable -->
<div class="px-5 py-3 border-b border-[#1e2d3d] shrink-0 flex gap-2"> <div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
<input <button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" title="Retour à la racine">
v-model="inputPath" <svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
@keydown.enter="navigateTo(inputPath)" <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
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" <polyline points="9 22 9 12 15 12 15 22"/>
placeholder="/" </svg>
/> </button>
<button @click="navigateTo(inputPath)" <input
class="border border-[#1e2d3d] text-[#5a7a94] px-3 py-2 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer"> v-model="inputPath"
OK @keydown.enter="navigateTo(inputPath)"
</button> class="settings-input flex-1 font-mono text-xs"
</div> placeholder="/"
/>
<!-- Navigation retour --> <button @click="navigateTo(inputPath)" class="btn-secondary px-3 text-xs">
<div v-if="parent !== null" class="px-5 shrink-0"> OK
<button @click="navigateTo(parent)" </button>
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...
</div> </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 --> <!-- Retour parent -->
<div class="flex items-center justify-between px-5 py-4 border-t border-[#1e2d3d] shrink-0"> <div v-if="parent !== null" class="px-5 shrink-0">
<span class="text-[10px] text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</span> <button
<button @click="$emit('select', current)" @click="navigateTo(parent)"
class="bg-[#e8513a] text-white px-5 py-2 text-xs tracking-widest hover:opacity-85 transition-opacity cursor-pointer"> class="flex items-center gap-2 w-full py-2.5 text-xs text-muted hover:text-primary transition-colors border-b border-border"
CHOISIR >
</button> <svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
</div> <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>
</div> </Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -84,8 +92,15 @@ const parent = ref<string | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref('') 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 { 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) { async function navigateTo(p: string) {
@@ -95,11 +110,10 @@ async function navigateTo(p: string) {
try { try {
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' }) const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
if (!res.ok) { if (!res.ok) {
const data = await res.json() error.value = (await res.json()).error ?? 'Erreur'
error.value = data.error ?? 'Erreur'
return return
} }
const data = await res.json() const data = await res.json()
current.value = data.path current.value = data.path
inputPath.value = data.path inputPath.value = data.path
parent.value = data.parent parent.value = data.parent
@@ -111,7 +125,5 @@ async function navigateTo(p: string) {
} }
} }
watch(() => props.initialPath, v => { watch(() => props.initialPath, v => { if (v) navigateTo(v) }, { immediate: true })
if (v) navigateTo(v)
}, { immediate: true })
</script> </script>
+32 -12
View File
@@ -1,22 +1,42 @@
<template> <template>
<Teleport to="body"> <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"> <TransitionGroup name="toast">
<div <div
v-for="t in toasts" v-for="t in toasts"
:key="t.id" :key="t.id"
@click="remove(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="{ :class="{
'border-[#3ae87a] text-[#3ae87a]': t.type === 'success', 'bg-card border-green-500/30 text-green-400' : t.type === 'success',
'border-[#e8513a] text-[#e8513a]': t.type === 'error', 'bg-card border-red-500/30 text-red-400' : t.type === 'error',
'border-[#3a8fe8] text-[#3a8fe8]': t.type === 'info', '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> <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">
<span v-else-if="t.type === 'error'">✕</span> <polyline points="2 7 5.5 10.5 12 3"/>
<span v-else>·</span> </svg>
{{ t.message }} <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> </div>
</TransitionGroup> </TransitionGroup>
</div> </div>
@@ -30,7 +50,7 @@ const { toasts, remove } = useToast()
<style scoped> <style scoped>
.toast-enter-active { transition: all 0.2s ease; } .toast-enter-active { transition: all 0.2s ease; }
.toast-leave-active { transition: all 0.2s ease; } .toast-leave-active { transition: all 0.15s ease; }
.toast-enter-from { opacity: 0; transform: translateX(20px); } .toast-enter-from { opacity: 0; transform: translateY(8px) scale(0.97); }
.toast-leave-to { opacity: 0; transform: translateX(20px); } .toast-leave-to { opacity: 0; transform: translateY(4px) scale(0.97); }
</style> </style>
+53
View File
@@ -0,0 +1,53 @@
<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>
<!-- Thème -->
<ThemeSwitcher />
</aside>
</template>
<script setup lang="ts">
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()
async function logout() {
await auth.logout()
router.push('/auth')
}
</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' import { ref } from 'vue'
interface Toast { interface Toast {
id: number id : number
message: string message : string
type: 'success' | 'error' | 'info' type : 'success' | 'error' | 'info'
count : number
timer : ReturnType<typeof setTimeout>
} }
const toasts = ref<Toast[]>([]) const toasts = ref<Toast[]>([])
let nextId = 0 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() { 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 } 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 { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import AppLayout from '@/layouts/AppLayout.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
// ─── Page publique hors layout ───────────────────────────
{ {
path: '/auth', path: '/auth',
component: () => import('@/views/AuthView.vue'), component: () => import('@/views/AuthView.vue'),
meta: { public: true } meta: { public: true },
}, },
// ─── App principale avec layout sidebar ──────────────────
{ {
path: '/', path: '/',
name: 'catalog', component: AppLayout,
component: () => import('@/views/CatalogView.vue') 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 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) => { router.beforeEach(async (to) => {
const auth = useAuthStore() const auth = useAuthStore()
@@ -49,7 +105,7 @@ router.beforeEach(async (to) => {
} }
if (to.path === '/auth' && auth.loggedIn) { 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> <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"> <div class="w-full max-w-sm">
<!-- Logo --> <!-- Logo -->
<div class="flex items-baseline gap-1 mb-8 justify-center"> <div class="flex items-center justify-center gap-2.5 mb-8">
<span class="font-sans text-3xl font-black text-white tracking-tight">Fank</span> <svg class="w-8 h-8" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg">
<span class="font-sans text-3xl font-black text-[#e8513a] tracking-tight">Arr</span> <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> </div>
<!-- Card --> <!-- Card -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-8"> <div class="settings-card p-8 flex flex-col gap-5">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1"> <div>
{{ isSetup ? 'PREMIÈRE CONNEXION' : 'CONNEXION' }} <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> </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 class="flex flex-col gap-4">
<div> <div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">IDENTIFIANT</label> <label class="settings-label mb-1.5">Identifiant</label>
<input <input v-model="username" type="text" placeholder="admin" class="settings-input" @keyup.enter="submit" />
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"
/>
</div> </div>
<div> <div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">MOT DE PASSE</label> <label class="settings-label mb-1.5">Mot de passe</label>
<input <input v-model="password" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
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"
/>
</div> </div>
<div v-if="isSetup"> <div v-if="isSetup">
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">CONFIRMER</label> <label class="settings-label mb-1.5">Confirmer</label>
<input <input v-model="confirm" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
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"
/>
</div> </div>
<!-- Error --> <p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<div v-if="error" class="text-[#e8513a] text-xs">{{ error }}</div>
<!-- Submit --> <button @click="submit" :disabled="submitting" class="btn-primary w-full py-2.5 mt-1">
<button {{ submitting ? '...' : isSetup ? 'Créer le compte' : 'Se connecter' }}
@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> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
@@ -74,42 +54,28 @@ import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
const confirm = ref('') const confirm = ref('')
const error = ref<string | null>(null) const error = ref<string | null>(null)
const submitting = ref(false) const submitting = ref(false)
const isSetup = computed(() => !auth.setup) const isSetup = computed(() => !auth.setup)
async function submit() { async function submit() {
error.value = null error.value = null
if (!username.value || !password.value) { error.value = 'Tous les champs sont requis'; return }
if (!username.value || !password.value) { if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas'; return }
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 submitting.value = true
const err = isSetup.value const err = isSetup.value
? await auth.setupAccount(username.value, password.value) ? await auth.setupAccount(username.value, password.value)
: await auth.login(username.value, password.value) : await auth.login(username.value, password.value)
submitting.value = false submitting.value = false
if (err) { if (err) { error.value = err; return }
error.value = err
return
}
await router.push('/') await router.push('/')
} }
</script> </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>
+301 -183
View File
@@ -1,171 +1,242 @@
<template> <template>
<div class="max-w-4xl mx-auto px-8 py-8"> <div>
<!-- Header --> <div class="px-8 py-8 max-w-4xl">
<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>
<!-- Résumé --> <!-- Header -->
<div class="grid grid-cols-4 gap-3 mb-6"> <div class="flex items-center justify-between mb-6">
<div v-for="stat in stats" :key="stat.label" class="bg-[#0d1219] border border-[#1e2d3d] p-4"> <div class="flex items-center gap-3">
<div class="text-[10px] text-[#5a7a94] tracking-widest mb-1">{{ stat.label }}</div> <h1 class="text-lg font-semibold text-primary">Activité</h1>
<div class="text-lg font-bold text-white">{{ stat.value }}</div> <div class="flex items-center gap-1.5">
</div> <span
</div> class="w-1.5 h-1.5 rounded-full transition-colors"
:class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'"
<!-- Onglets --> />
<div class="flex items-center justify-between mb-4"> <span class="text-xs text-muted">{{ polling ? 'En direct' : 'Pause' }}</span>
<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>
</div> </div>
</div> </div>
<div class="flex items-center gap-2">
<button @click="importAll" :disabled="importingAll" class="btn-primary">
{{ importingAll ? '...' : 'Importer tout' }}
</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"> <!-- Stats -->
<div class="h-full rounded-full transition-all duration-500" <div class="grid grid-cols-4 gap-3 mb-6">
:class="t.state === 'seeding' ? 'bg-green-500' : <div v-for="stat in stats" :key="stat.label" class="settings-card flex flex-col gap-1">
t.state === 'error' ? 'bg-red-500' : <span class="text-xs text-muted">{{ stat.label }}</span>
t.state === 'paused' ? 'bg-[#5a7a94]' : 'bg-[#e8513a]'" <span class="text-xl font-semibold text-primary">{{ stat.value }}</span>
:style="{ width: `${t.progress}%` }"/> </div>
</div>
<!-- Toolbar : recherche + tri -->
<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>
<div class="flex items-center justify-between"> <div class="relative shrink-0" ref="sortRef">
<div class="flex items-center gap-4 text-[10px] text-[#5a7a94]"> <button
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span> @click="sortOpen = !sortOpen"
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span> class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
<span v-if="t.state === 'seeding'" class="text-green-500">✓ Complété</span> :class="activeSort !== 'none' ? 'border-accent text-accent' : ''"
</div> >
<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="t.state === 'seeding'" class="flex items-center gap-2"> <div
<div v-if="t.errorFiles?.length > 0" class="relative group/err"> v-if="sortOpen"
<span class="text-[9px] tracking-widest px-2 py-0.5 border border-red-500/40 text-red-400 cursor-default"> 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"
{{ t.errorFiles.length }} ERREUR{{ t.errorFiles.length > 1 ? 'S' : '' }} >
</span> <button
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80"> v-for="s in sortOptions" :key="s.value"
<div class="bg-[#0d1219] border border-red-500/30 p-3 text-[10px] shadow-xl"> @click="setSort(s.value)"
<div class="text-red-400 tracking-widest mb-2">FICHIERS EN ERREUR</div> class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0"> :class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
<p class="text-white truncate">{{ e.file }}</p> >
<p class="text-[#5a7a94]">{{ e.error }}</p> {{ s.label }}
</div> <span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
</div> <span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
</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' }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Onglets + filtre -->
<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-2">
<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>
</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 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 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>
<span v-if="t.state === 'seeding'" class="text-green-500">Complété</span>
<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>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { Search, ArrowUpDown } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
const { add: toast } = useToast() const { add: toast } = useToast()
@@ -178,30 +249,84 @@ const importing = ref<Record<string, boolean>>({})
const importingAll = ref(false) const importingAll = ref(false)
const activeTab = ref<'active' | 'done'>('active') const activeTab = ref<'active' | 'done'>('active')
const hideImported = ref(false) const hideImported = ref(false)
const search = ref('')
const sortOpen = ref(false)
const activeSort = ref<'none' | 'name' | 'size' | 'progress' | 'state'>('none')
const sortDir = ref<'asc' | 'desc'>('asc')
const sortRef = ref<HTMLElement | null>(null)
onClickOutside(sortRef, () => { sortOpen.value = false })
const sortOptions = [
{ label: 'Nom', value: 'name' },
{ label: 'Taille', value: 'size' },
{ label: 'Progression', value: 'progress' },
{ label: 'État', value: 'state' },
]
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
}
let pollInterval: ReturnType<typeof setInterval> | null = null let pollInterval: ReturnType<typeof setInterval> | null = null
// FIX : unknown retiré de activeTorrents, ajouté dans doneTorrents
const activeTorrents = 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(() => const doneTorrents = computed(() =>
torrents.value.filter(t => t.state === 'seeding') torrents.value.filter(t => t.state === 'seeding' || t.state === 'unknown')
) )
const visibleTorrents = computed(() => { const visibleTorrents = computed(() => {
if (activeTab.value === 'active') return activeTorrents.value let list = activeTab.value === 'active'
if (hideImported.value) return doneTorrents.value.filter(t => t.organizeState !== 'done') ? activeTorrents.value
return doneTorrents.value : hideImported.value
? doneTorrents.value.filter(t => t.organizeState !== 'done')
: doneTorrents.value
// Recherche
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(t => t.name.toLowerCase().includes(q))
}
// Tri
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
default : return 0
}
})
}
return list
}) })
const stats = computed(() => [ const stats = computed(() => [
{ label: 'EN COURS', value: torrents.value.filter(t => t.state === 'downloading').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: '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: 'En pause', value: torrents.value.filter(t => t.state === 'paused').length },
{ label: 'ERREURS', value: torrents.value.filter(t => t.state === 'error').length }, { label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
]) ])
// FIX : progress cappé à 100 pour éviter "278.2 GB / 278.1 GB" → 100%
function clampedProgress(t: any): number {
return Math.min(100, Math.max(0, t.progress ?? 0))
}
async function fetchTorrents() { async function fetchTorrents() {
try { try {
const res = await fetch('/api/downloads', { credentials: 'include' }) const res = await fetch('/api/downloads', { credentials: 'include' })
@@ -226,8 +351,7 @@ function stopPolling() {
function togglePolling() { function togglePolling() {
polling.value = !polling.value polling.value = !polling.value
if (polling.value) startPolling() polling.value ? startPolling() : stopPolling()
else stopPolling()
} }
onMounted(startPolling) onMounted(startPolling)
@@ -240,7 +364,7 @@ async function importTorrent(torrent: any) {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', 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) { if (res.ok) {
toast(`${torrent.name} importé ✓`, 'success') toast(`${torrent.name} importé ✓`, 'success')
@@ -258,14 +382,10 @@ async function importTorrent(torrent: any) {
async function importAll() { async function importAll() {
const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done') const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done')
if (toImport.length === 0) { if (toImport.length === 0) { toast('Tous les torrents sont déjà importés', 'success'); return }
toast('Tous les torrents sont déjà importés', 'success')
return
}
importingAll.value = true importingAll.value = true
let done = 0 let done = 0, errors = 0
let errors = 0
for (const t of toImport) { for (const t of toImport) {
try { try {
@@ -273,30 +393,28 @@ async function importAll() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', 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++ res.ok ? done++ : errors++
else errors++ } catch { errors++ }
} catch {
errors++
}
} }
importingAll.value = false importingAll.value = false
await fetchTorrents() await fetchTorrents()
errors === 0
if (errors === 0) toast(`${done} torrent(s) importé(s) ✓`, 'success') ? toast(`${done} torrent(s) importé(s) ✓`, 'success')
else toast(`${done} OK, ${errors} erreur(s)`, 'error') : toast(`${done} OK, ${errors} erreur(s)`, 'error')
} }
function stateBadge(state: string) { function stateBadge(state: string) {
const map: Record<string, { label: string; class: string }> = { const map: Record<string, { label: string; class: string }> = {
downloading: { label: 'TÉLÉCHARGEMENT', class: 'border-[#e8513a]/40 text-[#e8513a]' }, downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
seeding : { label: 'COMPLÉTÉ', class: 'border-green-500/40 text-green-500' }, seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
paused : { label: 'EN PAUSE', class: 'border-[#5a7a94]/40 text-[#5a7a94]' }, paused : { label: 'En pause', class: 'border-border text-muted' },
checking : { label: 'VÉRIFICATION', class: 'border-yellow-500/40 text-yellow-500' }, checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
error : { label: 'ERREUR', class: 'border-red-500/40 text-red-500' }, error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
unknown : { label: 'INCONNU', class: 'border-[#1e2d3d] text-[#5a7a94]' }, // FIX : badge jaune pour signaler état dégradé sans alarmer
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
} }
return map[state] ?? map.unknown return map[state] ?? map.unknown
} }
-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>
+249 -254
View File
@@ -1,66 +1,68 @@
<template> <template>
<div class="min-h-screen bg-zinc-950 text-zinc-100"> <div>
<!-- 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>
<!-- Loading --> <!-- Loading -->
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600"> <div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" /> <div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-sm">Chargement…</p> <p class="text-sm">Chargement…</p>
</div> </div>
<!-- Error --> <!-- Error -->
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400"> <div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
<p class="text-sm">{{ store.error }}</p> <p class="text-sm text-red-400">{{ store.error }}</p>
<button class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm" @click="load">Réessayer</button> <button class="btn-primary" @click="load">Réessayer</button>
</div> </div>
<template v-else-if="data"> <template v-else-if="data">
<!-- Hero -->
<!-- Hero avec fanart -->
<div class="relative"> <div class="relative">
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none"> <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-20" <img
@error="e => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'" /> :src="data.serie.fanart_image"
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" /> 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>
<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 --> <!-- 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" /> <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"> <div v-else class="w-full aspect-[2/3] bg-card flex items-center justify-center text-muted">
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <Tv :size="28" />
<rect x="2" y="2" width="20" height="20" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/>
</svg>
</div> </div>
</div> </div>
<!-- Info --> <!-- Infos -->
<div class="flex flex-col gap-3 justify-end min-w-0"> <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>
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ data.serie.title }}</h1>
<div class="flex flex-wrap items-center gap-2"> <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 <span
v-if="data.serie.status" 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' :class="data.serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/15 text-green-400 border-green-500/20' ? 'bg-green-500/15 text-green-400 border-green-500/20'
: 'bg-white/5 text-zinc-500 border-white/10'" : 'bg-hover text-muted border-border'"
>{{ data.serie.status }}</span> >
{{ data.serie.status }}
</span>
</div> </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 }} {{ data.serie.plot }}
</p> </p>
<!-- Boutons hero : intégrale, pack_saison promu, pack_episodes --> <!-- Boutons intégrale -->
<div class="flex flex-wrap gap-2 mt-1"> <div class="flex flex-wrap gap-2 mt-1">
<button <button
v-for="(t, i) in data.torrents_integrale" v-for="(t, i) in data.torrents_integrale"
@@ -69,271 +71,233 @@
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition" class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
:class="[ :class="[
t.label === 'Intégrale' t.label === 'Intégrale'
? 'bg-orange-500 hover:bg-orange-600 text-white' ? 'bg-accent text-white hover:bg-accent-hover'
: 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20', : 'bg-accent-muted text-accent border border-accent/20 hover:bg-accent/20',
(downloading.has(`integrale-${i}`) || downloaded.has(`integrale-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : '' (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)" @click="download(`integrale-${i}`, t.torrent_url, t.magnet)"
> >
<DownloadIcon :spinning="downloading.has(`integrale-${i}`)" /> <EpStateIcon :state="epBtnState(`integrale-${i}`, t)" />
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : downloaded.has(`integrale-${i}`) ? 'Envoyé ✓' : t.label }} {{ isAlreadyQueued(t) ? 'Déjà ajouté' : isDownloaded(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
</button> </button>
</div> </div>
<!-- Barre progression intégrale -->
<!-- Progression intégrale -->
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`"> <template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
<template v-if="torrentProgress(extractHash(t))"> <ProgressBar :progress="torrentProgress(extractHash(t))?.progress" />
<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>
</template> </template>
</div> </div>
</div> </div>
</div> </div>
<!-- Seasons --> <!-- Saisons -->
<div class="max-w-7xl mx-auto px-6 pb-16 flex flex-col gap-6"> <div class="px-4 md:px-8 pb-16 flex flex-col gap-4">
<div <div
v-for="season in data.seasons" v-for="season in data.seasons"
:key="season.id" :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 --> <!-- Header saison -->
<div class="flex items-center justify-between px-5 py-4 border-b border-white/5"> <div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button class="text-zinc-500 hover:text-zinc-100 transition" @click="toggleSeason(season.id)"> <button class="text-muted hover:text-primary transition-colors" @click="toggleSeason(season.id)">
<svg <ChevronUp
width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" :size="15"
class="transition-transform duration-200" class="transition-transform duration-200"
:class="collapsedSeasons.has(season.id) ? '' : 'rotate-180'" :class="collapsedSeasons.has(season.id) ? 'rotate-180' : ''"
> />
<path d="M18 15l-6-6-6 6"/>
</svg>
</button> </button>
<div> <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}` }} {{ 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 }} — {{ season.title }}
</span> </span>
</h2> </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' : '' }} {{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo · {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
<span v-if="season.organized_state !== 'none'" <span
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px] font-medium" v-if="season.organized_state !== 'none'"
:class="season.organized_state === 'complete' class="ml-1.5 px-1.5 py-0.5 rounded text-[10px]"
? 'bg-green-500/10 text-green-400' :class="season.organized_state === 'complete'
: 'bg-yellow-500/10 text-yellow-500'"> ? 'bg-green-500/10 text-green-400'
: 'bg-yellow-500/10 text-yellow-500'"
>
{{ season.organized_state === 'complete' {{ season.organized_state === 'complete'
? `✓ ${season.organized_count} organisé${season.organized_count > 1 ? 's' : ''}` ? `✓ ${season.organized_count} importé${season.organized_count > 1 ? 's' : ''}`
: `${season.organized_count}/${seasonAvailableCount(season)} organisés` }} : `${season.organized_count}/${seasonAvailableCount(season)} importés` }}
</span> </span>
</p> </p>
</div> </div>
</div> </div>
<!-- Bouton pack_saison sur le header --> <!-- Bouton pack saison -->
<button <button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="[ :class="season.torrent && !isDownloaded(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
season.torrent && !downloaded.has(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete' ? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20' : 'text-muted border-border cursor-not-allowed opacity-50'"
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed' :disabled="!season.torrent || isDownloading(`season-${season.id}`) || isDownloaded(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
]"
:disabled="!season.torrent || downloading.has(`season-${season.id}`) || downloaded.has(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
@click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)" @click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
> >
<DownloadIcon :spinning="downloading.has(`season-${season.id}`)" :size="12" /> <EpStateIcon :state="seasonBtnState(season)" :size="12" />
{{ season.organized_state === 'complete' ? 'Organisé ✓' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : downloaded.has(`season-${season.id}`) ? 'Envoyé ✓' : 'Saison entière' }} {{ season.organized_state === 'complete' ? 'Importé' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : isDownloaded(`season-${season.id}`) ? 'Envoyé' : 'Saison entière' }}
</button> </button>
</div> </div>
<!-- Barre progression saison --> <!-- Progression saison — dans le header, sous les contrôles -->
<template v-if="torrentProgress(extractHash(season.torrent))"> <div
<div v-if="torrentProgress(extractHash(season.torrent))!.progress === 100" v-if="torrentProgress(extractHash(season.torrent)) && torrentProgress(extractHash(season.torrent))!.progress < 100"
class="px-5 py-2 border-b border-white/5"> class="px-5 pb-3 -mt-1"
<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é <div class="h-0.5 bg-border rounded-full overflow-hidden">
</span> <div
class="h-full bg-accent rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(season.torrent))!.progress}%` }"
/>
</div> </div>
<div v-else class="px-5 py-2 border-b border-white/5"> <p class="text-[11px] text-muted mt-1">{{ torrentProgress(extractHash(season.torrent))!.progress }}%</p>
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1"> </div>
<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>
<!-- Episodes --> <!-- Épisodes -->
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-white/[0.04]"> <div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-border/50">
<div <div
v-for="ep in season.episodes" v-for="ep in season.episodes"
:key="ep.id" :key="ep.id"
class="px-5 py-3 hover:bg-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-3">
<div class="flex items-center gap-4">
<!-- Thumbnail --> <!-- 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" /> <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"> <span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}
</div>
</div> </div>
<!-- Info --> <!-- Infos épisode -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xs text-zinc-600 shrink-0 font-mono"> <span class="text-xs text-muted shrink-0 font-mono">
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }} {{ 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>
<!-- Badge Hors Fankai -->
<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>
<span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
</div> </div>
<div class="flex items-center gap-3 mt-0.5"> <div class="flex items-center gap-2 mt-0.5">
<span v-if="ep.aired" class="text-xs text-zinc-600">{{ formatDate(ep.aired) }}</span> <span v-if="ep.aired" class="text-xs text-muted">{{ formatDate(ep.aired) }}</span>
<span v-if="ep.duration" class="text-xs text-zinc-600">{{ ep.duration }} min</span> <span v-if="ep.duration" class="text-xs text-muted">{{ formatDuration(ep.duration) }}</span>
</div> </div>
</div> </div>
<!-- Badges + bouton download --> <!-- Bouton état unique -->
<div class="shrink-0 flex items-center gap-2"> <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) --> </div>
<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>
<!-- Badge organisé --> <!-- Barre progression épisode -->
<span v-if="ep.organized" <div
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20"> v-if="torrentProgress(extractHash(ep.torrent)) && torrentProgress(extractHash(ep.torrent))!.progress < 100"
✓ organisé class="mt-1.5 sm:pl-[92px]"
</span> >
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<!-- Badge téléchargé à 100% --> <div
<span v-if="torrentProgress(extractHash(ep.torrent))?.progress === 100" class="h-full bg-accent rounded-full transition-all duration-500"
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20"> :style="{ width: `${torrentProgress(extractHash(ep.torrent))!.progress}%` }"
✓ 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>
</div> </div>
</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>
</div> </div>
</div> </div>
</template> </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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue' 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 { useSeriesStore } from '@/stores/series'
import { useToast } from '@/composables/useToast'
import type { Season } from '@/stores/series' import type { Season } from '@/stores/series'
// ── Composant icône download ────────────────────────────────── // ── Icône d'état du bouton ────────────────────────────────────
const DownloadIcon = defineComponent({ // states: 'idle' | 'loading' | 'done' | 'unavailable'
props: { spinning: Boolean, size: { type: Number, default: 14 } }, const EpStateIcon = defineComponent({
props: {
state: { type: String, default: 'idle' },
size : { type: Number, default: 14 },
},
setup(props) { setup(props) {
return () => props.spinning return () => {
? 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` }) if (props.state === 'loading') return h(Loader, { size: props.size, class: 'animate-spin' })
: h('svg', { width: props.size, height: props.size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [ if (props.state === 'done') return h(Check, { size: props.size })
h('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3' }) if (props.state === 'unavailable') return h(X, { size: props.size })
]) return h(Download, { size: props.size })
} }
},
}) })
// ── Setup ───────────────────────────────────────────────────── // ── Barre de progression ──────────────────────────────────────
const route = useRoute() const ProgressBar = defineComponent({
const store = useSeriesStore() props: { progress: { type: Number, default: null } },
setup(props) {
return () => {
if (props.progress == null || props.progress >= 100) return null
return h('div', { class: 'h-0.5 bg-border rounded-full overflow-hidden' }, [
h('div', {
class: 'h-full bg-accent rounded-full transition-all duration-500',
style: { width: `${props.progress}%` },
}),
])
}
},
})
const route = useRoute()
const store = useSeriesStore()
const { add: toast } = useToast()
const collapsedSeasons = ref<Set<number>>(new Set()) const collapsedSeasons = ref<Set<number>>(new Set())
const downloading = ref<Set<string>>(new Set()) const downloading = ref<string[]>([])
const downloaded = ref<Set<string>>(new Set()) const downloaded = ref<string[]>([])
const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null)
// ── Progression downloads ────────────────────────────────────── function isDownloading(key: string) { return downloading.value.includes(key) }
interface ActiveTorrent { hash: string; progress: number; state: string; name: string } 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[]>([]) const activeTorrents = ref<ActiveTorrent[]>([])
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
const data = computed(() => store.currentSerie)
// ── Polling downloads ─────────────────────────────────────────
async function fetchActiveDownloads() { async function fetchActiveDownloads() {
try { try {
const res = await fetch('/api/downloads', { credentials: 'include' }) const res = await fetch('/api/downloads', { credentials: 'include' })
@@ -343,7 +307,6 @@ async function fetchActiveDownloads() {
hash : t.hash, hash : t.hash,
progress: t.progress ?? 0, progress: t.progress ?? 0,
state : t.state, state : t.state,
name : t.name,
})) }))
} catch {} } catch {}
} }
@@ -353,18 +316,58 @@ function torrentProgress(hash: string | null | undefined): ActiveTorrent | null
return activeTorrents.value.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? 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 { function extractHash(torrent: { magnet?: string | null } | null | undefined): string | null {
if (!torrent) return null if (!torrent?.magnet) return null
const magnet = torrent.magnet ?? '' const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
const m = magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
return m ? m[1].toLowerCase() : null return m ? m[1].toLowerCase() : null
} }
// ── Data ─────────────────────────────────────────────────────── function isAlreadyQueued(torrent: { magnet?: string | null } | null | undefined): boolean {
const data = computed(() => store.currentSerie) 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) { 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) { function toggleSeason(id: number) {
@@ -372,33 +375,17 @@ function toggleSeason(id: number) {
else collapsedSeasons.value.add(id) 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) { async function download(key: string, torrent_url: string | null, magnet: string | null) {
if (downloading.value.has(key) || downloaded.value.has(key)) return if (isDownloading(key) || isDownloaded(key)) return
downloading.value = new Set([...downloading.value, key]) addDownloading(key)
const result = await store.download(torrent_url, magnet) const result = await store.download(torrent_url, magnet)
downloading.value = new Set([...downloading.value].filter(k => k !== key)) removeDownloading(key)
if (result.success) { if (result.success) {
downloaded.value = new Set([...downloaded.value, key]) addDownloaded(key)
showToast('success', 'Téléchargement lancé') toast('Téléchargement lancé ✓', 'success')
fetchActiveDownloads() fetchActiveDownloads()
} else { } else {
showToast('error', result.error ?? 'Erreur inconnue') toast(result.error ?? 'Erreur inconnue', 'error')
} }
} }
@@ -406,6 +393,19 @@ function load() {
store.fetchSerieDetail(Number(route.params.id)) 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(() => { onMounted(() => {
load() load()
fetchActiveDownloads() fetchActiveDownloads()
@@ -416,8 +416,3 @@ onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer) if (pollTimer) clearInterval(pollTimer)
}) })
</script> </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>
+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 }"
>
<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>
-495
View File
@@ -1,495 +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>
<!-- 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,
})
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>
+258
View File
@@ -0,0 +1,258 @@
<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 }}</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, 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)
}
}
}
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>
+222
View File
@@ -0,0 +1,222 @@
<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 = '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.)."
/>
</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' | 'move',
nfoSupport : false,
autoImport : true,
})
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>