Compare commits

...
13 Commits
Author SHA1 Message Date
MasutaYunikon 0c8fa42f4f Merge pull request #15 from Masutayunikon/release-please--branches--main
chore(main): release 1.7.0
2026-03-12 23:10:17 +01:00
MasutaYunikon 8f6e0340ff chore(main): release 1.7.0 2026-03-12 22:42:15 +01:00
MasutaYunikon 693f8a96ae feat: UX improvements, folder picker, download state & NFO support
## Settings
- Folder picker pour les paths (navigation dossier côté serveur via GET /api/browse)
- Paths vides par défaut, démarrage à / dans le picker
- Picker accessible même sous Docker (volumes montés accessibles)
- Bouton "Analyser bibliothèque" pour relancer scanMediaPath manuellement (POST /api/scan)
- Toggle NFO support (téléchargement dossier GitLab Fankai Pack à l'organisation)
- nfoSupport ajouté dans Settings interface + defaults

## Catalogue
- Logo "Fankarr" cliquable → retour accueil
- Filtres séparés en 2 groupes : état série (orange) | état téléchargement (bleu)
- Groupe 1 : Toutes / Disponibles / Sans torrent
- Groupe 2 : DL complet / DL partiel
- Tooltip badge torrents → "X torrents téléchargeables"

## Page Série
- Anti-spam boutons download : Set au lieu de string, bouton bloqué après envoi
- Boutons bloqués si torrent déjà dans qBittorrent (cross-session via poll /api/downloads)
- Boutons bloqués si épisode/saison déjà organisé dans la bibliothèque
- Barre de progression sous épisode / saison / intégrale (poll 5s)
- Badge "✓ téléchargé" à 100% à la place de la barre
- Badge "dispo" en bleu, "✓ téléchargé" inline à côté de "dispo"

## Page Téléchargements
- Onglets EN COURS / TERMINÉS (défaut : EN COURS)
- EN COURS : downloading + paused + checking + error
- TERMINÉS : seeding uniquement
- Toggle "Masquer organisés" dans l'onglet TERMINÉS
- Message vide adapté au contexte

## Organizer
- seasonFolder : Saison N sans padding (pour matcher GitLab)
- Map SERIE_TITLE_GITLAB_MAP : 9 corrections titres API → GitLab
- downloadGitlabFolder : liste récursive + téléchargement NFO/images depuis fankai_pack
- Appelé au début de organizeTorrent si nfoSupport activé
- Paths vides par défaut dans readSettings du worker

## Backend
- POST /api/scan : déclenche scanMediaPath manuellement
- GET /api/browse?path= : liste les sous-dossiers d'un chemin
- dispatchList : utilise client.config.category en priorité sur la catégorie globale
- Catégorie configurable par client torrent (fix torrents non affichés page downloads)
2026-03-12 22:41:46 +01:00
MasutaYunikon f38a43d396 Merge remote-tracking branch 'origin/main' 2026-03-11 19:48:02 +01:00
MasutaYunikon 510e1f7b5a fix: rename if the torrent name is not the same as the api 2026-03-11 19:47:53 +01:00
MasutaYunikon 91f5918233 Merge pull request #14 from Masutayunikon/release-please--branches--main
chore(main): release 1.6.0
2026-03-11 15:48:48 +01:00
MasutaYunikon 71e3423ad4 chore(main): release 1.6.0 2026-03-11 15:48:19 +01:00
MasutaYunikon 15b4a99c86 Merge remote-tracking branch 'origin/main' 2026-03-11 15:47:55 +01:00
MasutaYunikon 14f02d1bdd feat: add favicon 2026-03-11 15:47:46 +01:00
MasutaYunikon ac6ac46a14 Merge pull request #13 from Masutayunikon/release-please--branches--main
chore(main): release 1.5.0
2026-03-11 15:47:04 +01:00
MasutaYunikon 66f5771ca7 chore(main): release 1.5.0 2026-03-11 15:45:33 +01:00
MasutaYunikon 40a12a870b Merge remote-tracking branch 'origin/main' 2026-03-11 15:45:08 +01:00
MasutaYunikon e7afe138e1 feat(auth): génération automatique du JWT secret au démarrage
- secret.ts: génère un secret aléatoire 96 chars hex si absent
- secret.ts: persiste dans data/secret.key (mode 600)
- secret.ts: priorité env JWT_SECRET > secret.key > génération
- auth: import JWT_SECRET depuis secret.ts au lieu de process.env
- docker-compose: JWT_SECRET optionnel (géré automatiquement)
- readme: mise à jour — JWT_SECRET n'est plus obligatoire
- feat: add favicon.ico
2026-03-11 15:45:00 +01:00
14 changed files with 1007 additions and 320 deletions

No files matched your search

+26
View File
@@ -1,5 +1,31 @@
# Changelog
## [1.7.0](https://github.com/Masutayunikon/FanKarr/compare/v1.6.0...v1.7.0) (2026-03-12)
### Features
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
### Bug Fixes
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
## [1.6.0](https://github.com/Masutayunikon/FanKarr/compare/v1.5.0...v1.6.0) (2026-03-11)
### Features
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
## [1.5.0](https://github.com/Masutayunikon/FanKarr/compare/v1.4.1...v1.5.0) (2026-03-11)
### Features
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
## [1.4.1](https://github.com/Masutayunikon/FanKarr/compare/v1.4.0...v1.4.1) (2026-03-11)
+3 -1
View File
@@ -109,6 +109,7 @@ Fankarr sera accessible sur `http://localhost:3001`.
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers |
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `data/secret.key` si absent |
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
### Premier démarrage
@@ -165,4 +166,5 @@ Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
- [fankai.fr](https://fankai.fr) — Le projet Fankai
- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) — Reconnaissance des métadonnées dans Jellyfin
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+3 -3
View File
@@ -3,9 +3,9 @@ import path from 'path'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import type { Request, Response, NextFunction } from 'express'
import { JWT_SECRET } from './secret.js'
const DATA_PATH = path.join(process.cwd(), 'data', 'auth.json')
const JWT_SECRET = process.env.JWT_SECRET || 'fankarr-secret-change-me'
const SALT_ROUNDS = 10
interface AuthData {
@@ -63,7 +63,7 @@ export function authSetup(req: Request, res: Response): void {
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
writeAuth({ username, passwordHash })
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '30d' })
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true })
}
@@ -87,7 +87,7 @@ export function authLogin(req: Request, res: Response): void {
return
}
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '30d' })
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true })
}
+29 -1
View File
@@ -8,7 +8,7 @@ import {
registerDriver, getDriver, getAvailableClients,
listClients, addClient, removeClient, getClient,
sanitizeClient, dispatchDownload, dispatchList
} from './torrent-clients/index.js'
} from './torrent-clients'
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
@@ -433,6 +433,22 @@ app.get('/api/system', requireAuth, (_req, res) => {
res.json({ isDocker })
})
app.get('/api/browse', requireAuth, (req, res) => {
const dirPath = (req.query.path as string) || '/'
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
const dirs = entries
.filter(e => e.isDirectory())
.map(e => e.name)
.filter(n => !n.startsWith('.'))
.sort((a, b) => a.localeCompare(b))
const parent = dirPath === '/' ? null : path.dirname(dirPath)
res.json({ path: dirPath, parent, dirs })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
}
})
app.get('/api/torrents/status', requireAuth, (_req, res) => {
try {
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
@@ -459,6 +475,18 @@ app.post('/api/update', requireAuth, async (_req, res) => {
}
})
app.post('/api/scan', requireAuth, async (_req, res) => {
try {
const { mediaPath } = readSettings()
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
const result = await scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH)
logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`)
res.json({ ok: true, ...result })
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── SPA fallback (prod) — doit être après toutes les routes API ─
if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => {
+104 -6
View File
@@ -20,19 +20,22 @@ const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
// ─── Utils log ────────────────────────────────────────────────
// Le worker tourne dans un thread séparé — il passe les logs au thread principal
// qui les écrit dans le fichier via logger.ts
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
// ─── Settings (lu directement, pas d'import circulaire) ──────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string } {
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
try {
const p = path.join(process.cwd(), 'data', 'settings.json')
if (!fs.existsSync(p)) return { mediaPath: '/media/Kai', completePath: '/downloads/complete', organizeMode: 'hardlink' }
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '/media/Kai', completePath: '/downloads/complete', organizeMode: 'hardlink' } }
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
}
// ─── Organized log ────────────────────────────────────────────
@@ -84,10 +87,98 @@ function isInExcludedFolder(filePath: string[]): boolean {
return false
}
// ─── Map correction titres API → titres GitLab ───────────────
const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
'Tower Of God Henshū' : 'Tower of God Henshū',
}
function getGitlabSerieTitle(serieTitle: string): string {
return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle
}
// ─── GitLab NFO downloader ────────────────────────────────────
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
async function fetchJson(url: string): Promise<any> {
const { default: nodeFetch } = await import('node-fetch')
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
return res.json()
}
async function fetchBinary(url: string): Promise<Buffer> {
const { default: nodeFetch } = await import('node-fetch')
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
return Buffer.from(await res.arrayBuffer())
}
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
const gitlabTitle = getGitlabSerieTitle(serieTitle)
const folderPath = `pack/${gitlabTitle}`
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
// Lister récursivement tous les fichiers du dossier
let files: any[]
try {
files = await fetchJson(
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&ref=main`
)
} catch (err) {
warn(`[nfo] Dossier GitLab introuvable pour "${gitlabTitle}": ${err instanceof Error ? err.message : err}`)
return
}
if (!Array.isArray(files) || files.length === 0) {
warn(`[nfo] Aucun fichier trouvé sur GitLab pour "${gitlabTitle}"`)
return
}
const fileEntries = files.filter((f: any) => f.type === 'blob')
log(`[nfo] ${fileEntries.length} fichiers à télécharger`)
for (const entry of fileEntries) {
// entry.path = "pack/Black Lagoon Henshū/Saison 1/fichier.nfo"
// On retire le préfixe "pack/{gitlabTitle}/" pour obtenir le chemin relatif
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
const destPath = path.join(destRoot, relativePath)
const destDir = path.dirname(destPath)
// Skip si déjà présent
if (fs.existsSync(destPath)) continue
try {
fs.mkdirSync(destDir, { recursive: true })
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
const data = await fetchBinary(rawUrl)
fs.writeFileSync(destPath, data)
log(`[nfo] ✓ ${relativePath}`)
} catch (err) {
warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`)
}
}
log(`[nfo] Dossier GitLab synchronisé pour "${serieTitle}"`)
}
// ─── Filesystem ops ───────────────────────────────────────────
function seasonFolder(n: number): string {
return `Saison ${String(n).padStart(3, '0')}`
return `Saison ${n}`
}
function tryHardlink(src: string, dest: string): boolean {
@@ -101,7 +192,7 @@ function tryHardlink(src: string, dest: string): boolean {
// ─── Organise un torrent ──────────────────────────────────────
async function organizeTorrent(hash: string, name: string, savePath: string) {
const { mediaPath, completePath, organizeMode } = readSettings()
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
log(`[organize] ── ${name} ──`)
@@ -118,13 +209,20 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
const torrentFiles: any[] = torrent.torrent_files ?? []
log(`[organize] serie=${serieTitle} fichiers=${torrentFiles.length}`)
// ── Téléchargement NFO/images depuis GitLab ───────────────
if (nfoSupport) {
const serieDestRoot = path.join(mediaPath, serieTitle)
await downloadGitlabFolder(serieTitle, serieDestRoot)
}
// ── Cas fichier unique ────────────────────────────────────
if (torrentFiles.length === 0) {
log(`[organize] torrent_files vide → tentative fichier unique`)
const candidates = [path.join(savePath, name), path.join(completePath, name)]
let src: string | null = null
for (const c of candidates) {
log(`[organize] candidate: ${c} → ${fs.existsSync(c) ? '✓' : '✗'}`)
if (fs.existsSync(c)) log(`[organize] ✓ source trouvée: ${c}`)
else debug(`[organize] candidate absent: ${c}`)
if (fs.existsSync(c)) { src = c; break }
}
if (!src) throw new Error(`Source introuvable pour "${name}"`)
+44
View File
@@ -0,0 +1,44 @@
/**
* secret.ts
* =========
* Génère et persiste le JWT secret dans data/secret.key
* Si JWT_SECRET est défini en variable d'environnement, il est utilisé tel quel.
* Sinon, on lit data/secret.key — s'il n'existe pas, on en génère un et on le sauvegarde.
*/
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
const SECRET_PATH = path.join(process.cwd(), 'data', 'secret.key')
function generateSecret(): string {
return crypto.randomBytes(48).toString('hex')
}
function loadOrCreateSecret(): string {
// 1. Variable d'environnement — priorité absolue
if (process.env.JWT_SECRET && process.env.JWT_SECRET !== 'fankarr-secret-change-me') {
return process.env.JWT_SECRET
}
// 2. Fichier existant
if (fs.existsSync(SECRET_PATH)) {
const secret = fs.readFileSync(SECRET_PATH, 'utf-8').trim()
if (secret.length > 0) return secret
}
// 3. Génération + sauvegarde
const secret = generateSecret()
try {
fs.mkdirSync(path.dirname(SECRET_PATH), { recursive: true })
fs.writeFileSync(SECRET_PATH, secret, { encoding: 'utf-8', mode: 0o600 })
console.log('[auth] JWT secret généré et sauvegardé dans data/secret.key')
} catch (err) {
console.error('[auth] Impossible de sauvegarder le secret:', err)
}
return secret
}
export const JWT_SECRET: string = loadOrCreateSecret()
+7 -5
View File
@@ -4,17 +4,19 @@ import path from 'path'
const DATA_PATH = path.join(process.cwd(), 'data', 'settings.json')
export interface Settings {
mediaPath : string // dossier Jellyfin destination ex: /media/Kai
completePath : string // dossier qB complétion ex: /downloads/complete
mediaPath : string
completePath : string
organizeMode : 'hardlink' | 'move'
category : string // catégorie qBittorrent
category : string
nfoSupport : boolean // téléchargement NFO/images depuis GitLab Fankai Pack
}
const defaults: Settings = {
mediaPath : '/media/Kai',
completePath : '/downloads/complete',
mediaPath : '',
completePath : '',
organizeMode : 'hardlink',
category : 'fankai',
nfoSupport : false,
}
export function readSettings(): Settings {
+3 -1
View File
@@ -168,7 +168,9 @@ export async function dispatchList(category?: string): Promise<(TorrentInfo & {
const driver = getDriver(client.type)
if (!driver) continue
try {
const torrents = await driver.list(client.config, category)
// La catégorie du client est prioritaire sur la catégorie globale
const clientCategory = (client.config.category as string) || category
const torrents = await driver.list(client.config, clientCategory)
for (const t of torrents) {
results.push({ ...t, client_uuid: client.uuid, client_name: client.name })
}
+117
View File
@@ -0,0 +1,117 @@
<template>
<!-- Overlay -->
<div class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')">
<div class="bg-[#0d1219] border border-[#1e2d3d] w-full max-w-lg flex flex-col" style="max-height: 70vh">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-[#1e2d3d] shrink-0">
<div>
<div class="text-[10px] text-[#e8513a] tracking-widest mb-0.5">CHOISIR UN DOSSIER</div>
<div class="text-xs text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</div>
</div>
<button @click="$emit('cancel')" class="text-[#5a7a94] hover:text-white transition-colors cursor-pointer text-lg leading-none">✕</button>
</div>
<!-- Chemin éditable -->
<div class="px-5 py-3 border-b border-[#1e2d3d] shrink-0 flex gap-2">
<input
v-model="inputPath"
@keydown.enter="navigateTo(inputPath)"
class="flex-1 bg-[#121920] border border-[#1e2d3d] text-xs text-white px-3 py-2 font-mono outline-none focus:border-[#e8513a] transition-colors"
placeholder="/"
/>
<button @click="navigateTo(inputPath)"
class="border border-[#1e2d3d] text-[#5a7a94] px-3 py-2 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
OK
</button>
</div>
<!-- Navigation retour -->
<div v-if="parent !== null" class="px-5 shrink-0">
<button @click="navigateTo(parent)"
class="flex items-center gap-2 w-full py-2.5 text-xs text-[#5a7a94] hover:text-white transition-colors cursor-pointer border-b border-[#1e2d3d]">
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
..
</button>
</div>
<!-- Liste dossiers -->
<div class="overflow-y-auto flex-1">
<div v-if="loading" class="flex items-center justify-center py-10 text-[#5a7a94] text-xs">
<div class="w-4 h-4 border border-white/10 border-t-[#e8513a] rounded-full animate-spin mr-2"/>
Chargement...
</div>
<div v-else-if="error" class="px-5 py-4 text-xs text-red-400">{{ error }}</div>
<div v-else-if="dirs.length === 0" class="px-5 py-4 text-xs text-[#5a7a94]">Aucun sous-dossier</div>
<button
v-for="dir in dirs"
:key="dir"
@click="navigateTo(joinPath(current, dir))"
class="flex items-center gap-3 w-full px-5 py-2.5 text-xs text-[#c8d8e8] hover:bg-white/5 transition-colors cursor-pointer border-b border-[#1e2d3d]/50 text-left"
>
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
</svg>
{{ dir }}
</button>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-5 py-4 border-t border-[#1e2d3d] shrink-0">
<span class="text-[10px] text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</span>
<button @click="$emit('select', current)"
class="bg-[#e8513a] text-white px-5 py-2 text-xs tracking-widest hover:opacity-85 transition-opacity cursor-pointer">
CHOISIR
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{ initialPath?: string }>()
const emit = defineEmits<{ select: [path: string]; cancel: [] }>()
const current = ref(props.initialPath || '/')
const inputPath = ref(current.value)
const dirs = ref<string[]>([])
const parent = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
function joinPath(base: string, name: string): string {
return base === '/' ? `/${name}` : `${base}/${name}`
}
async function navigateTo(p: string) {
if (!p) return
loading.value = true
error.value = ''
try {
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
if (!res.ok) {
const data = await res.json()
error.value = data.error ?? 'Erreur'
return
}
const data = await res.json()
current.value = data.path
inputPath.value = data.path
parent.value = data.parent
dirs.value = data.dirs
} catch {
error.value = 'Impossible de lire ce dossier'
} finally {
loading.value = false
}
}
watch(() => props.initialPath, v => {
if (v) navigateTo(v)
}, { immediate: true })
</script>
+35 -11
View File
@@ -4,9 +4,9 @@
<!-- 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">
<span class="text-2xl font-serif tracking-tight">
<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>
</span>
</router-link>
<div class="flex items-center gap-2">
<!-- Icône logs -->
<router-link to="/logs"
@@ -67,9 +67,10 @@
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 gap-1">
<div class="flex items-center gap-1">
<!-- Groupe 1 : état de la série -->
<button
v-for="f in filters"
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
@@ -77,6 +78,20 @@
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
@click="activeFilter = f.value"
>{{ f.label }}</button>
<!-- Séparateur -->
<span class="w-px h-5 bg-white/10 mx-1" />
<!-- Groupe 2 : état du téléchargement -->
<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>
<span class="text-xs text-zinc-600 ml-auto">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
</div>
@@ -133,8 +148,14 @@
</div>
<!-- Badge torrents dispo -->
<div v-if="serie.has_torrents" class="absolute top-2 right-2 bg-orange-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full leading-none">
<div v-if="serie.has_torrents"
class="absolute top-2 right-2 bg-orange-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full leading-none cursor-default group/badge">
{{ serie.torrent_count }}
<div class="absolute bottom-full right-0 mb-1.5 hidden group-hover/badge:block z-10">
<div class="bg-zinc-800 border border-white/10 text-white text-[10px] font-normal px-2 py-1 rounded whitespace-nowrap shadow-lg">
{{ serie.torrent_count }} torrent{{ serie.torrent_count > 1 ? 's' : '' }} téléchargeables
</div>
</div>
</div>
<!-- Badge état téléchargement -->
@@ -201,12 +222,15 @@ const search = ref('')
const activeFilter = ref('all')
const activeDownloads = ref(0)
const filters = [
{ label: 'Toutes', value: 'all' },
{ label: 'Disponibles', value: 'available' },
{ label: 'Téléchargés', value: 'complete' },
{ label: 'En cours', value: 'downloading' },
{ label: 'Sans torrent', value: 'unavailable' },
const filtersGroupe1 = [
{ label: 'Toutes', value: 'all' },
{ label: 'Disponibles', value: 'available' },
{ label: 'Sans torrent', value: 'unavailable' },
]
const filtersGroupe2 = [
{ label: 'DL complet', value: 'complete' },
{ label: 'DL partiel', value: 'downloading' },
]
const filtered = computed(() => {
+77 -29
View File
@@ -10,7 +10,6 @@
<h1 class="text-xl font-black text-white font-sans">Téléchargements</h1>
</div>
<div class="flex items-center gap-3">
<!-- Indicateur de polling -->
<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' }}
@@ -30,6 +29,47 @@
</div>
</div>
<!-- Onglets -->
<div class="flex items-center justify-between mb-4">
<div class="flex gap-0 border border-[#1e2d3d]">
<button
@click="activeTab = 'active'"
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer"
:class="activeTab === 'active'
? 'bg-[#e8513a] text-white border-[#e8513a]'
: '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>
<!-- Toggle masquer organisés (onglet TERMINÉS uniquement) -->
<div v-if="activeTab === 'done'" class="flex items-center gap-2">
<button @click="hideOrganized = !hideOrganized"
class="relative shrink-0 w-8 h-4 rounded-full transition-colors cursor-pointer"
:class="hideOrganized ? '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="hideOrganized ? 'translate-x-4' : 'translate-x-0'" />
</button>
<span class="text-[10px] text-[#5a7a94] tracking-widest">MASQUER ORGANISÉ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>
@@ -46,14 +86,20 @@
</div>
<!-- Vide -->
<div v-else-if="!loading && torrents.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
<p class="text-sm">Aucun téléchargement en cours</p>
<p class="text-[11px]">Les torrents lancés depuis Fankarr apparaîtront ici</p>
<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'
: hideOrganized ? 'Tous les torrents terminés sont organisés' : '' }}
</p>
</div>
<!-- Liste torrents -->
<div v-else class="flex flex-col gap-2">
<div v-for="t in torrents" :key="t.hash"
<div v-for="t in visibleTorrents" :key="t.hash"
class="bg-[#0d1219] border border-[#1e2d3d] p-4">
<!-- Ligne principale -->
@@ -61,17 +107,14 @@
<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">
<!-- Badge état -->
<span class="text-[10px] tracking-widest px-2 py-0.5 border"
:class="stateBadge(t.state).class">
{{ stateBadge(t.state).label }}
</span>
<!-- Client -->
<span class="text-[10px] text-[#5a7a94]">{{ t.client_name }}</span>
</div>
</div>
<!-- Infos droite -->
<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">
@@ -92,26 +135,17 @@
<!-- Infos bas -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-[10px] text-[#5a7a94]">
<span v-if="t.state === 'downloading'">
↓ {{ formatSpeed(t.speed) }}
</span>
<span v-if="t.state === 'downloading' && t.eta > 0">
ETA {{ formatEta(t.eta) }}
</span>
<span v-if="t.state === 'seeding'" class="text-green-500">
✓ Complété
</span>
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
<span v-if="t.state === 'seeding'" class="text-green-500">✓ Complété</span>
</div>
<!-- Badge organisation -->
<!-- Badge organisation (seulement pour les terminés) -->
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
<!-- Badge erreurs avec tooltip -->
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
<span class="text-[9px] tracking-widest px-2 py-0.5 border border-red-500/40 text-red-400 cursor-default">
{{ t.errorFiles.length }} ERREUR{{ t.errorFiles.length > 1 ? 'S' : '' }}
</span>
<!-- Tooltip -->
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
<div class="bg-[#0d1219] border border-red-500/30 p-3 text-[10px] shadow-xl">
<div class="text-red-400 tracking-widest mb-2">FICHIERS EN ERREUR</div>
@@ -123,7 +157,6 @@
</div>
</div>
<!-- Badge statut organisation -->
<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' :
@@ -133,7 +166,6 @@
'NON ORGANISÉ' }}
</span>
<!-- Bouton organiser si pas encore fait -->
<button v-if="t.organizeState !== 'done'"
@click="organize(t)"
:disabled="organizing[t.hash]"
@@ -153,14 +185,31 @@ import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const torrents = ref<any[]>([])
const loading = ref(true)
const polling = ref(true)
const noClients = ref(false)
const organizing = ref<Record<string, boolean>>({})
const torrents = ref<any[]>([])
const loading = ref(true)
const polling = ref(true)
const noClients = ref(false)
const organizing = ref<Record<string, boolean>>({})
const activeTab = ref<'active' | 'done'>('active')
const hideOrganized = ref(false)
let pollInterval: ReturnType<typeof setInterval> | null = null
// ── Filtres onglets ────────────────────────────────────────────
const activeTorrents = computed(() =>
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error', 'unknown'].includes(t.state))
)
const doneTorrents = computed(() =>
torrents.value.filter(t => t.state === 'seeding')
)
const visibleTorrents = computed(() => {
if (activeTab.value === 'active') return activeTorrents.value
if (hideOrganized.value) return doneTorrents.value.filter(t => t.organizeState !== 'done')
return doneTorrents.value
})
// ── Stats ──────────────────────────────────────────────────────
const stats = computed(() => [
{ label: 'EN COURS', value: torrents.value.filter(t => t.state === 'downloading').length },
@@ -213,7 +262,6 @@ async function organize(torrent: any) {
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name })
})
if (res.ok) {
organized.value[torrent.hash] = true
toast(`${torrent.name} organisé ✓`, 'success')
} else {
const { error } = await res.json()
+457 -252
View File
@@ -1,290 +1,495 @@
<template>
<div class="min-h-screen bg-zinc-950 text-zinc-100">
<!-- Back -->
<div class="max-w-7xl mx-auto px-6 pt-6">
<router-link to="/" class="inline-flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-100 transition">
<svg width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
Catalogue
</router-link>
<div 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>
<!-- Loading -->
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
<p class="text-sm">Chargement…</p>
<!-- 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 fichier de torrents est vide.' : 'Aucun fichier de torrents 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>
<!-- Error -->
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
<p class="text-sm">{{ store.error }}</p>
<button class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm" @click="load">Réessayer</button>
<!-- 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">Torrents Fankai</h2>
<p class="text-[11px] text-[#5a7a94] mb-5">
{{ torrentsStatus.empty ? 'Aucune donnée chargée' : `${torrentsStatus.count} torrents chargés` }}
</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>
<template v-else-if="data">
<!-- Hero -->
<div class="relative">
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none">
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20" />
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
</div>
<div class="relative max-w-7xl mx-auto px-6 pt-8 pb-10 flex gap-8">
<!-- Poster -->
<div class="shrink-0 w-36 rounded-xl overflow-hidden border border-white/10 shadow-2xl hidden sm:block">
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" />
<div v-else class="w-full aspect-[2/3] bg-zinc-900 flex items-center justify-center text-zinc-700">
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="20" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/>
</svg>
</div>
</div>
<!-- Info -->
<div class="flex flex-col gap-3 justify-end min-w-0">
<h1 class="text-3xl font-bold tracking-tight">{{ data.serie.title }}</h1>
<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.status"
class="text-xs px-2 py-0.5 rounded-full font-medium border"
:class="data.serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/15 text-green-400 border-green-500/20'
: 'bg-white/5 text-zinc-500 border-white/10'"
>{{ data.serie.status }}</span>
</div>
<p v-if="data.serie.plot" class="text-sm text-zinc-400 leading-relaxed max-w-2xl line-clamp-3">
{{ data.serie.plot }}
</p>
<!-- Boutons hero : intégrale, pack_saison promu, pack_episodes -->
<div class="flex flex-wrap gap-2 mt-1">
<button
v-for="(t, i) in data.torrents_integrale"
:key="i"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
:class="t.label === 'Intégrale'
? 'bg-orange-500 hover:bg-orange-600 text-white'
: 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20'"
:disabled="downloading === `integrale-${i}`"
@click="download(`integrale-${i}`, t.torrent_url, t.magnet)"
>
<DownloadIcon :spinning="downloading === `integrale-${i}`" />
{{ t.label }}
</button>
</div>
</div>
</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>
<!-- Seasons -->
<div class="max-w-7xl mx-auto px-6 pb-16 flex flex-col gap-6">
<div
v-for="season in data.seasons"
:key="season.id"
class="bg-zinc-900/50 border border-white/5 rounded-2xl overflow-hidden"
>
<!-- Season header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-white/5">
<div class="flex items-center gap-3">
<button class="text-zinc-500 hover:text-zinc-100 transition" @click="toggleSeason(season.id)">
<svg
width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
class="transition-transform duration-200"
:class="collapsedSeasons.has(season.id) ? '' : 'rotate-180'"
>
<path d="M18 15l-6-6-6 6"/>
</svg>
</button>
<div>
<h2 class="font-semibold text-sm">
{{ 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">
— {{ season.title }}
</span>
</h2>
<p class="text-xs text-zinc-600 mt-0.5">
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
<span v-if="season.organized_state !== 'none'"
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px] font-medium"
:class="season.organized_state === 'complete'
? 'bg-green-500/10 text-green-400'
: 'bg-yellow-500/10 text-yellow-500'">
{{ season.organized_state === 'complete'
? `✓ ${season.organized_count} organisé${season.organized_count > 1 ? 's' : ''}`
: `${season.organized_count}/${seasonAvailableCount(season)} organisés` }}
</span>
</p>
</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>
<!-- Bouton pack_saison sur le header -->
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
:class="season.torrent
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'"
:disabled="!season.torrent || downloading === `season-${season.id}`"
@click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<DownloadIcon :spinning="downloading === `season-${season.id}`" :size="12" />
Saison entière
</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>
<!-- Episodes -->
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-white/[0.04]">
<div
v-for="ep in season.episodes"
:key="ep.id"
class="flex items-center gap-4 px-5 py-3 hover:bg-white/[0.02] transition"
>
<!-- Thumbnail -->
<div class="shrink-0 w-24 aspect-video rounded-lg overflow-hidden bg-zinc-800 hidden sm:block">
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700 text-xs font-mono">
{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}
</div>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-xs text-zinc-600 shrink-0 font-mono">
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
</span>
<span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
</div>
<div class="flex items-center gap-3 mt-0.5">
<span v-if="ep.aired" class="text-xs text-zinc-600">{{ formatDate(ep.aired) }}</span>
<span v-if="ep.duration" class="text-xs text-zinc-600">{{ ep.duration }} min</span>
</div>
</div>
<!-- Bouton épisode (type episode uniquement) -->
<div class="shrink-0 flex items-center gap-2">
<!-- Badge organisé -->
<span v-if="ep.organized"
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ organisé
</span>
<!-- Badge dispo/indispo basé sur available (episode résolu dans un torrent) -->
<span
class="inline-block text-[10px] px-2 py-0.5 rounded-full border"
:class="ep.available
? 'bg-green-500/10 text-green-400 border-green-500/20'
: 'bg-white/5 text-zinc-600 border-white/8'"
>
{{ ep.available ? 'dispo' : 'indispo' }}
</span>
<!-- Bouton download — visible seulement si type=episode, sinon grisé -->
<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
? '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 === `ep-${ep.id}`"
@click="ep.torrent && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
>
<DownloadIcon :spinning="downloading === `ep-${ep.id}`" :size="12" />
Ep.
</button>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- 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 }}
<!-- Médias & Organisation -->
<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">Organisation</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 COMPLÉTION</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 de complétion de qBittorrent</p>
</div>
<!-- Dossier Jellyfin -->
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER JELLYFIN</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 la bibliothèque Fankai dans Jellyfin</p>
</div>
<!-- Mode organisation -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">MODE</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]'">
MOVE
</button>
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
<span class="text-white">Hardlink recommandé</span> — le fichier reste dans qBittorrent pour le ratio
</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> au moment de l'organisation
</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 BIBLIOTHÈ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>
</Transition>
</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 qBittorrent"
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, defineComponent, h } from 'vue'
import { useRoute } from 'vue-router'
import { useSeriesStore } from '@/stores/series'
import type { Season } from '@/stores/series'
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'
// ── Composant icône download ──────────────────────────────────
const DownloadIcon = defineComponent({
props: { spinning: Boolean, size: { type: Number, default: 14 } },
setup(props) {
return () => props.spinning
? h('div', { class: `w-${props.size === 12 ? '3' : '3.5'} h-${props.size === 12 ? '3' : '3.5'} border border-current/30 border-t-current rounded-full animate-spin` })
: h('svg', { width: props.size, height: props.size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
h('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3' })
])
}
const router = useRouter()
const auth = useAuthStore()
const { add: toast } = useToast()
// ── State ──────────────────────────────────────────────────────
const form = ref({
mediaPath : '',
completePath: '',
organizeMode: 'hardlink' as 'hardlink' | 'move',
nfoSupport : false,
})
// ── Setup ─────────────────────────────────────────────────────
const route = useRoute()
const store = useSeriesStore()
const saving = ref(false)
const updating = ref(false)
const scanning = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
const collapsedSeasons = ref<Set<number>>(new Set())
const downloading = ref<string | null>(null)
const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null)
// ── Folder picker ──────────────────────────────────────────────
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
open: false, field: 'completePath', currentPath: '/'
})
const data = computed(() => store.currentSerie)
function seasonAvailableCount(season: Season) {
return season.episodes.filter(e => e.available).length
function openPicker(field: 'completePath' | 'mediaPath') {
picker.value = {
open : true,
field,
currentPath: (form.value as any)[field] || '/',
}
}
function toggleSeason(id: number) {
if (collapsedSeasons.value.has(id)) collapsedSeasons.value.delete(id)
else collapsedSeasons.value.add(id)
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
}
function formatDate(d: string) {
if (!d) return ''
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
// ── 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
}
}
function showToast(type: 'success' | 'error', message: string) {
toast.value = { type, message }
setTimeout(() => { toast.value = null }, 3500)
// ── 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 download(key: string, torrent_url: string | null, magnet: string | null) {
downloading.value = key
const result = await store.download(torrent_url, magnet)
downloading.value = null
if (result.success) showToast('success', 'Ajouté à qBittorrent !')
else showToast('error', result.error ?? 'Erreur inconnue')
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 load() {
store.fetchSerieDetail(Number(route.params.id))
// ── Modal ──────────────────────────────────────────────────────
function openAddModal() {
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
}
onMounted(load)
</script>
function closeModal() {
modal.value.open = false
}
<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>
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 torrents ───────────────────────────────────────────
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} torrents téléchargés ✓`, '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 bibliothèque ──────────────────────────────────────────
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 scannés — ${data.added} ajoutés`
: `${data.found} fichiers scannés — rien de nouveau`,
'success'
)
} else {
toast('Erreur lors du scan', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
scanning.value = false
}
}
// ── Sauvegarder settings ───────────────────────────────────────
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>
+102 -11
View File
@@ -91,10 +91,15 @@
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER COMPLÉTION</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>
<input v-model="form.completePath" :disabled="isDocker"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 outline-none transition-colors"
:class="isDocker ? 'text-[#5a7a94] cursor-not-allowed opacity-60' : 'text-white focus:border-[#e8513a]'"
placeholder="/downloads/complete"/>
<button v-if="!isDocker"
@click="openPicker('completePath')"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left text-white hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate">
{{ form.completePath || '/downloads/complete' }}
</button>
<div v-else
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-[#5a7a94] opacity-60 font-mono truncate">
{{ form.completePath || '/downloads/complete' }}
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
<span v-if="isDocker">Défini par le volume Docker — non modifiable ici</span>
<span v-else>Dossier de complétion de qBittorrent</span>
@@ -107,10 +112,15 @@
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER JELLYFIN</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>
<input v-model="form.mediaPath" :disabled="isDocker"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 outline-none transition-colors"
:class="isDocker ? 'text-[#5a7a94] cursor-not-allowed opacity-60' : 'text-white focus:border-[#e8513a]'"
placeholder="/media/Kai"/>
<button v-if="!isDocker"
@click="openPicker('mediaPath')"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left text-white hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate">
{{ form.mediaPath || '/media/Kai' }}
</button>
<div v-else
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-[#5a7a94] opacity-60 font-mono truncate">
{{ form.mediaPath || '/media/Kai' }}
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
<span v-if="isDocker">Défini par le volume Docker — non modifiable ici</span>
<span v-else>Dossier racine de la bibliothèque Fankai dans Jellyfin</span>
@@ -141,11 +151,37 @@
</p>
</div>
<div class="pt-1">
<!-- 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> au moment de l'organisation
</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 BIBLIOTHÈ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>
@@ -160,6 +196,14 @@
</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">
@@ -217,6 +261,7 @@ 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()
@@ -227,10 +272,31 @@ const form = ref({
mediaPath : '/media/Kai',
completePath: '/downloads/complete',
organizeMode: 'hardlink' as 'hardlink' | 'move',
nfoSupport : false,
})
const saving = ref(false)
const updating = ref(false)
const scanning = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
// ── Folder picker ──────────────────────────────────────────────
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
open: false, field: 'completePath', currentPath: '/'
})
function openPicker(field: 'completePath' | 'mediaPath') {
picker.value = {
open : true,
field,
currentPath: (form.value as any)[field] || '/',
}
}
function onPickerSelect(path: string) {
(form.value as any)[picker.value.field] = path
picker.value.open = false
}
const isDocker = ref(false)
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
@@ -272,7 +338,7 @@ onMounted(async () => {
if (availRes.ok) availableClients.value = await availRes.json()
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
for (const c of clients.value) await refreshHealth(c.uuid)
for (const c of clients.value) refreshHealth(c.uuid)
})
// ── Healthcheck ────────────────────────────────────────────────
@@ -362,7 +428,7 @@ async function saveClient() {
if (res.ok) {
const client = await res.json()
clients.value.push(client)
await refreshHealth(client.uuid)
refreshHealth(client.uuid)
closeModal()
toast('Client enregistré ✓', 'success')
} else {
@@ -394,6 +460,31 @@ async function update() {
}
}
// ── Scan bibliothèque ──────────────────────────────────────────
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 scannés — ${data.added} ajoutés`
: `${data.found} fichiers scannés — rien de nouveau`,
'success'
)
} else {
toast('Erreur lors du scan', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
scanning.value = false
}
}
// ── Sauvegarder settings ───────────────────────────────────────
async function save() {
saving.value = true