Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b780e4fa1 | ||
|
|
444b30ef3d | ||
|
|
42b46496d1 | ||
|
|
f092da4108 | ||
|
|
8e635c1d28 | ||
|
|
3b8b7a0454 | ||
|
|
9ef1516b0b | ||
|
|
1af210058c | ||
|
|
6caad83081 | ||
|
|
520bfcfae1 | ||
|
|
2e5bc8953a | ||
|
|
f304562f01 | ||
|
|
c9690d9a1b | ||
|
|
5daface279 | ||
|
|
5687aaa5d9 | ||
|
|
ba67817ea0 | ||
|
|
e0452dbd0c | ||
|
|
c376061333 | ||
|
|
bb02ea6cf8 | ||
|
|
887beae614 | ||
|
|
1290d5c925 | ||
|
|
dffb340250 | ||
|
|
059298f537 | ||
|
|
0ba2144f2f | ||
|
|
83a2fb9860 | ||
|
|
5445fadf94 | ||
|
|
6d110b86dd | ||
|
|
2ef6d4738a | ||
|
|
65f7d4e109 | ||
|
|
6166c370ef | ||
|
|
ad1cade6bf | ||
|
|
803519062a | ||
|
|
6246c44994 | ||
|
|
9780aee40d | ||
|
|
e5e8baf1fd | ||
|
|
79e73b603f | ||
|
|
49c3a09129 | ||
|
|
4a24b7fa0f | ||
|
|
80aef10230 | ||
|
|
92055caf4d | ||
|
|
d8e284b0de | ||
|
|
0b98269695 | ||
|
|
402946b462 | ||
|
|
c374ceb0a7 | ||
|
|
6f21b0a5cd | ||
|
|
1c36bb7688 | ||
|
|
82a9d51731 | ||
|
|
ac03aa3c11 |
No files matched your search
@@ -53,6 +53,11 @@ jobs:
|
||||
bun-target: bun-linux-x64
|
||||
binary: binaries/fankarr
|
||||
archive: fankarr-linux-x64.zip
|
||||
- target: linux-arm64
|
||||
os: ubuntu-latest
|
||||
bun-target: bun-linux-arm64
|
||||
binary: binaries/fankarr
|
||||
archive: fankarr-linux-arm64-termux.zip
|
||||
- target: macos
|
||||
os: macos-latest
|
||||
bun-target: bun-darwin-arm64
|
||||
|
||||
@@ -1,5 +1,102 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
+6
-4
@@ -17,17 +17,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.3",
|
||||
"bcryptjs": "^3.0.3"
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
@@ -38,7 +41,6 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5",
|
||||
"@types/bcrypt": "^5.0.2"
|
||||
"vue-tsc": "^3.1.5"
|
||||
}
|
||||
}
|
||||
Generated
+46
@@ -11,6 +11,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vueuse/core':
|
||||
specifier: ^14.2.1
|
||||
version: 14.2.1(vue@3.5.29(typescript@5.9.3))
|
||||
bcryptjs:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
@@ -23,6 +26,9 @@ importers:
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.3
|
||||
version: 9.0.3
|
||||
lucide-vue-next:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0(vue@3.5.29(typescript@5.9.3))
|
||||
node-fetch:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
@@ -536,6 +542,9 @@ packages:
|
||||
'@types/serve-static@2.2.0':
|
||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@vitejs/plugin-vue@6.0.4':
|
||||
resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -622,6 +631,19 @@ packages:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@vueuse/core@14.2.1':
|
||||
resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/metadata@14.2.1':
|
||||
resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==}
|
||||
|
||||
'@vueuse/shared@14.2.1':
|
||||
resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -1039,6 +1061,11 @@ packages:
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lucide-vue-next@1.0.0:
|
||||
resolution: {integrity: sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -1758,6 +1785,8 @@ snapshots:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.2
|
||||
@@ -1886,6 +1915,19 @@ snapshots:
|
||||
typescript: 5.9.3
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@vueuse/core@14.2.1(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 14.2.1
|
||||
'@vueuse/shared': 14.2.1(vue@3.5.29(typescript@5.9.3))
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@vueuse/metadata@14.2.1': {}
|
||||
|
||||
'@vueuse/shared@14.2.1(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
@@ -2303,6 +2345,10 @@ snapshots:
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lucide-vue-next@1.0.0(vue@3.5.29(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
dependencies:
|
||||
magic-string: 0.30.21
|
||||
|
||||
+22
-9
@@ -5,12 +5,13 @@ import jwt from 'jsonwebtoken'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { JWT_SECRET } from './secret.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
|
||||
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
|
||||
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
interface AuthData {
|
||||
username: string
|
||||
username : string
|
||||
passwordHash: string
|
||||
}
|
||||
|
||||
@@ -32,25 +33,25 @@ function writeAuth(data: AuthData): void {
|
||||
// GET /api/auth/status
|
||||
export function authStatus(req: Request, res: Response): void {
|
||||
const existing = readAuth()
|
||||
const token = req.cookies?.fankarr_token
|
||||
const token = req.cookies?.fankarr_token
|
||||
|
||||
let loggedIn = false
|
||||
if (token) {
|
||||
try {
|
||||
jwt.verify(token, JWT_SECRET)
|
||||
loggedIn = true
|
||||
} catch {}
|
||||
} catch {
|
||||
logger.debug('auth', 'Token invalide ou expiré')
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
setup: !!existing,
|
||||
loggedIn
|
||||
})
|
||||
res.json({ setup: !!existing, loggedIn })
|
||||
}
|
||||
|
||||
// POST /api/auth/setup
|
||||
export function authSetup(req: Request, res: Response): void {
|
||||
if (readAuth()) {
|
||||
logger.warn('auth', 'Tentative de setup alors qu\'un compte existe déjà')
|
||||
res.status(400).json({ error: 'Un compte existe déjà' })
|
||||
return
|
||||
}
|
||||
@@ -63,6 +64,7 @@ export function authSetup(req: Request, res: Response): void {
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
|
||||
writeAuth({ username, passwordHash })
|
||||
logger.info('auth', `Compte créé pour "${username}"`)
|
||||
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
@@ -84,17 +86,26 @@ export function authLogin(req: Request, res: Response): void {
|
||||
}
|
||||
|
||||
if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) {
|
||||
logger.warn('auth', `Échec de connexion pour "${username}"`)
|
||||
res.status(401).json({ error: 'Identifiants incorrects' })
|
||||
return
|
||||
}
|
||||
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
logger.info('auth', `Connexion réussie pour "${username}"`)
|
||||
res.json({ success: true })
|
||||
}
|
||||
|
||||
// POST /api/auth/logout
|
||||
export function authLogout(_req: Request, res: Response): void {
|
||||
export function authLogout(req: Request, res: Response): void {
|
||||
const token = req.cookies?.fankarr_token
|
||||
if (token) {
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as any
|
||||
logger.info('auth', `Déconnexion de "${payload.username}"`)
|
||||
} catch {}
|
||||
}
|
||||
res.clearCookie('fankarr_token')
|
||||
res.json({ success: true })
|
||||
}
|
||||
@@ -103,6 +114,7 @@ export function authLogout(_req: Request, res: Response): void {
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const token = req.cookies?.fankarr_token
|
||||
if (!token) {
|
||||
logger.debug('auth', `Accès refusé — non authentifié (${req.method} ${req.path})`)
|
||||
res.status(401).json({ error: 'Non authentifié' })
|
||||
return
|
||||
}
|
||||
@@ -110,6 +122,7 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
|
||||
jwt.verify(token, JWT_SECRET)
|
||||
next()
|
||||
} catch {
|
||||
logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`)
|
||||
res.status(401).json({ error: 'Token invalide' })
|
||||
}
|
||||
}
|
||||
+165
-384
@@ -10,13 +10,16 @@ import {
|
||||
sanitizeClient, dispatchDownload, dispatchList
|
||||
} from './torrent-clients/index.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 { DATA_DIR, BASE_DIR } from './config.js'
|
||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||
import { systemInfo } from './system.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
registerDriver(transmissionDriver)
|
||||
|
||||
const app = express()
|
||||
const app = express()
|
||||
const PORT = Number(process.env.PORT) || 9898
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
const GITHUB_BASE = process.env.GITHUB_BASE
|
||||
@@ -25,7 +28,6 @@ const GITHUB_BASE = process.env.GITHUB_BASE
|
||||
app.use(express.json())
|
||||
app.use(cookieParser())
|
||||
|
||||
// ── Static frontend (prod) ─────────────────────────────────────
|
||||
const PUBLIC_PATH = path.join(BASE_DIR, 'public')
|
||||
if (fs.existsSync(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/logout', authLogout)
|
||||
|
||||
// system info
|
||||
app.get('/api/system/info', systemInfo)
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────
|
||||
app.get('/api/settings', requireAuth, (_req, res) => {
|
||||
res.json(readSettings())
|
||||
})
|
||||
app.post('/api/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport } = req.body
|
||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport }))
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode } = req.body
|
||||
const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode })
|
||||
logger.info('api', 'Paramètres mis à jour')
|
||||
res.json(updated)
|
||||
})
|
||||
|
||||
// ── Torrent clients ────────────────────────────────────────────
|
||||
@@ -56,13 +63,13 @@ app.get('/api/torrent-clients', requireAuth, (_req, res) => {
|
||||
app.post('/api/torrent-clients', requireAuth, (req, res) => {
|
||||
const { name, type, config } = req.body
|
||||
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
res.json(sanitizeClient(addClient(name, type, config)))
|
||||
})
|
||||
app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
const ok = removeClient(String(req.params.uuid))
|
||||
if (!ok) res.status(404).json({ error: 'Client introuvable' })
|
||||
else res.json({ ok: true })
|
||||
if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
res.json({ ok: true })
|
||||
})
|
||||
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
|
||||
const { type, config } = req.body
|
||||
@@ -87,8 +94,7 @@ app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res)
|
||||
})
|
||||
|
||||
// ── Cache GitHub ───────────────────────────────────────────────
|
||||
|
||||
const CACHE_TTL_MS = 6 * 60 * 60 * 1000 // 6 heures
|
||||
const CACHE_TTL_MS = 6 * 60 * 60 * 1000
|
||||
|
||||
interface CacheEntry<T> { data: T; expiresAt: number }
|
||||
const _cache = new Map<string, CacheEntry<any>>()
|
||||
@@ -107,122 +113,59 @@ function cacheSet<T>(key: string, data: T): void {
|
||||
function cacheClear(): void { _cache.clear() }
|
||||
|
||||
async function githubGet(urlPath: string): Promise<any> {
|
||||
const key = urlPath
|
||||
const cached = cacheGet(key)
|
||||
const cached = cacheGet(urlPath)
|
||||
if (cached) return cached
|
||||
const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
|
||||
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
|
||||
const data = await res.json()
|
||||
cacheSet(key, data)
|
||||
cacheSet(urlPath, data)
|
||||
return data
|
||||
}
|
||||
|
||||
async function readAvailable(): Promise<number[]> {
|
||||
try {
|
||||
return await githubGet('available.json') as number[]
|
||||
} catch { return [] }
|
||||
try { return await githubGet('available.json') as number[] }
|
||||
catch { return [] }
|
||||
}
|
||||
|
||||
async function readSerieData(serieId: number): Promise<any | null> {
|
||||
try {
|
||||
return await githubGet(`series/${serieId}.json`)
|
||||
} catch { return null }
|
||||
try { return await githubGet(`series/${serieId}.json`) }
|
||||
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[] {
|
||||
const result: any[] = []
|
||||
if (!sd) return result
|
||||
|
||||
// Torrents au niveau série → pack_integrale (ou pack_saison si une seule saison)
|
||||
for (const t of sd.torrents ?? []) {
|
||||
result.push({
|
||||
...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),
|
||||
})
|
||||
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) })
|
||||
}
|
||||
|
||||
// Torrents au niveau saison → pack_saison
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
result.push({
|
||||
...t,
|
||||
raw : t.title,
|
||||
type : 'pack_saison',
|
||||
serie_id : sd.id,
|
||||
serie_title : sd.title,
|
||||
season_id : season.id,
|
||||
season_number: season.season_number,
|
||||
_seasonLevel : true,
|
||||
_season : season,
|
||||
_torrentIdx : (season.torrents ?? []).indexOf(t),
|
||||
})
|
||||
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) })
|
||||
}
|
||||
|
||||
// Torrents au niveau épisode → episode
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
result.push({
|
||||
...t,
|
||||
raw : t.title,
|
||||
type : 'episode',
|
||||
serie_id : sd.id,
|
||||
serie_title : sd.title,
|
||||
season_id : season.id,
|
||||
season_number: season.season_number,
|
||||
episode_id : ep.id,
|
||||
episode_number: ep.episode_number,
|
||||
_episodeLevel: true,
|
||||
_episode : ep,
|
||||
_torrentIdx : (ep.torrents ?? []).indexOf(t),
|
||||
})
|
||||
result.push({ ...t, raw: t.title, type: 'episode', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, episode_id: ep.id, episode_number: ep.episode_number, _episodeLevel: true, _episode: ep, _torrentIdx: (ep.torrents ?? []).indexOf(t) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 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[] {
|
||||
const resolved: any[] = []
|
||||
const h = hash.toLowerCase()
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const paths: any[] = ep.paths ?? []
|
||||
|
||||
// Nouveau format { infohash, path } ou legacy string
|
||||
let filePath: string | null = null
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') {
|
||||
if (!filePath) filePath = p.replace(/\\/g, '/') // legacy fallback
|
||||
} else if (p?.infohash?.toLowerCase() === h) {
|
||||
filePath = p.path.replace(/\\/g, '/')
|
||||
break
|
||||
}
|
||||
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
|
||||
else if (p?.infohash?.toLowerCase() === h) { filePath = p.path.replace(/\\/g, '/'); break }
|
||||
}
|
||||
if (!filePath) continue
|
||||
|
||||
const filename = filePath.split('/').pop() ?? filePath
|
||||
resolved.push({
|
||||
episode_id : ep.id,
|
||||
episode_number : ep.episode_number,
|
||||
season_number : season.season_number,
|
||||
season_id : season.id,
|
||||
filename,
|
||||
original_filename: filename,
|
||||
})
|
||||
resolved.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, season_id: season.id, filename, original_filename: filename })
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
@@ -234,15 +177,6 @@ async function fankaiGet(endpoint: string): Promise<any> {
|
||||
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 {
|
||||
if (!url) return null
|
||||
const stripped = url.replace(/^https?:\/\//, '')
|
||||
@@ -250,167 +184,66 @@ function imgProxy(url: string | null | undefined, w: number, q = 70): string | n
|
||||
}
|
||||
|
||||
function normalizeSerie(serie: any): any {
|
||||
const poster = serie.poster_image ?? serie.images?.poster ?? null
|
||||
const fanart = serie.fanart_image ?? serie.images?.fanart ?? null
|
||||
return {
|
||||
...serie,
|
||||
poster_image: imgProxy(poster, 300),
|
||||
fanart_image: imgProxy(fanart, 1280, 60),
|
||||
}
|
||||
return { ...serie, poster_image: imgProxy(serie.poster_image ?? serie.images?.poster ?? null, 300), fanart_image: imgProxy(serie.fanart_image ?? serie.images?.fanart ?? null, 1280, 60) }
|
||||
}
|
||||
|
||||
function normalizeSeason(season: any): any {
|
||||
const poster = season.poster_image ?? season.images?.poster ?? null
|
||||
return {
|
||||
...season,
|
||||
poster_image: imgProxy(poster, 300),
|
||||
}
|
||||
return { ...season, poster_image: imgProxy(season.poster_image ?? season.images?.poster ?? null, 300) }
|
||||
}
|
||||
|
||||
function normalizeEpisode(ep: any): any {
|
||||
const thumb = ep.thumb_image ?? ep.thumbnail ?? null
|
||||
return {
|
||||
...ep,
|
||||
thumb_image: imgProxy(thumb, 400),
|
||||
}
|
||||
return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 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[]> {
|
||||
const ids = await readAvailable()
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
|
||||
const valid = allSd.filter(Boolean)
|
||||
return enrichSeriesDataWithOriginalFilenames(valid)
|
||||
return allSd.filter(Boolean)
|
||||
}
|
||||
|
||||
function computeSerieDownloadState(
|
||||
serieData : any | null,
|
||||
organized : Record<string, Record<string, string>>,
|
||||
activeTorrents: Set<string>
|
||||
): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, string>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
if (!serieData) return 'none'
|
||||
|
||||
const allTorrents = extractTorrentsFromSerieData(serieData)
|
||||
if (allTorrents.length === 0) return 'none'
|
||||
|
||||
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
|
||||
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
|
||||
const allEpisodeIds = new Set<number>()
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if ((ep.torrents ?? []).length > 0 || (ep.paths ?? []).length > 0) {
|
||||
allEpisodeIds.add(ep.id)
|
||||
}
|
||||
if ((ep.torrents ?? []).length > 0 || (ep.paths ?? []).length > 0) allEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier via organized.json : si le filename du path est organisé
|
||||
for (const t of allTorrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (!hash) continue
|
||||
const orgFiles = organized[hash] ?? {}
|
||||
if (Object.keys(orgFiles).length === 0) continue
|
||||
const resolved = buildResolvedEpisodes(serieData, hash, t.season_number)
|
||||
for (const ep of resolved) {
|
||||
for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) {
|
||||
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (allEpisodeIds.size === 0) return 'none'
|
||||
if (organizedEpisodeIds.size === 0) return 'none'
|
||||
if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none'
|
||||
if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete'
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
// ── Routes série ───────────────────────────────────────────────
|
||||
app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const [apiData, availableIds] = await Promise.all([
|
||||
fankaiGet('/series'),
|
||||
readAvailable(),
|
||||
])
|
||||
const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()])
|
||||
const availableSet = new Set<number>(availableIds)
|
||||
|
||||
// Charger organized.json
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
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)
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const activeTorrents = new Set<string>()
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const active = await dispatchList(category ?? 'fankai')
|
||||
for (const t of active) {
|
||||
if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase())
|
||||
}
|
||||
} catch {}
|
||||
|
||||
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 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>()
|
||||
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)
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
|
||||
res.json({ series: seriesRaw.map((serie: any) => {
|
||||
const hasTorrents = availableSet.has(serie.id)
|
||||
const serieData = serieDataMap.get(serie.id) ?? null
|
||||
return {
|
||||
...serie,
|
||||
torrent_count : serieData ? extractTorrentsFromSerieData(serieData).length : 0,
|
||||
has_torrents : hasTorrents,
|
||||
download_state: computeSerieDownloadState(serieData, organized, activeTorrents),
|
||||
}
|
||||
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) }
|
||||
})})
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
@@ -418,131 +251,76 @@ app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const [serieRaw, seasonsData, serieData] = await Promise.all([
|
||||
fankaiGet(`/series/${id}`),
|
||||
fankaiGet(`/series/${id}/seasons`),
|
||||
readSerieData(id),
|
||||
])
|
||||
const [serieRaw, seasonsData, serieData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`), readSerieData(id)])
|
||||
const serie = normalizeSerie(serieRaw)
|
||||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
||||
|
||||
// Fetch épisodes depuis l'API Fankai (avec original_filename, thumb_image, etc.)
|
||||
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
|
||||
return { ...normalizeSeason(season), episodes }
|
||||
}))
|
||||
|
||||
// Charger organized.json
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
// ── Construire les maps torrent depuis series/{id}.json ───────────
|
||||
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>()
|
||||
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
const episodeTorrentMap : Record<number, any> = {}
|
||||
const seasonTorrentMapBySn : Record<number, any> = {}
|
||||
const integraleTorrents : any[] = []
|
||||
const packEpisodesTorrents : any[] = []
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
if (serieData) {
|
||||
// ── Torrents niveau série (pack_integrale) ──────────────────
|
||||
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 })
|
||||
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of resolved) {
|
||||
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
for (const ep of resolved) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) }
|
||||
}
|
||||
|
||||
// ── Torrents niveau saison (pack_saison) ────────────────────
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
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] = ref
|
||||
|
||||
seasonTorrentMapBySn[season.season_number] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false }
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of resolved) {
|
||||
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
for (const ep of resolved) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) }
|
||||
}
|
||||
|
||||
// ── Torrents niveau épisode ─────────────────────────────
|
||||
for (const ep of season.episodes ?? []) {
|
||||
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] = ref
|
||||
episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true }
|
||||
availableEpisodeIds.add(ep.id)
|
||||
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
const paths: any[] = ep.paths ?? []
|
||||
const match = paths.find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
const filename = match ? match.path.split('/').pop() : null
|
||||
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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
|
||||
// original_filename vient de l'API Fankai directement
|
||||
return {
|
||||
...ep,
|
||||
available : availableEpisodeIds.has(ep.id),
|
||||
torrent : epTorrent,
|
||||
organized : organizedEpisodeIds.has(ep.id),
|
||||
let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null
|
||||
if (fankai === null && serieData) {
|
||||
outer: for (const sd_season of serieData.seasons ?? []) {
|
||||
for (const sdEp of sd_season.episodes ?? []) {
|
||||
if (sdEp.id !== ep.id) continue
|
||||
for (const p of sdEp.paths ?? []) {
|
||||
const ih = typeof p === 'object' ? p.infohash?.toLowerCase() : null
|
||||
if (!ih) continue
|
||||
for (const t of serieData.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } }
|
||||
for (const s of serieData.seasons ?? []) { for (const t of s.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...ep, available: availableEpisodeIds.has(ep.id), torrent: epTorrent ? { ...epTorrent, fankai: fankai ?? true } : null, fankai, organized: organizedEpisodeIds.has(ep.id) }
|
||||
})
|
||||
const total = eps.filter((e: any) => e.available).length
|
||||
const orgCount = eps.filter((e: any) => e.organized).length
|
||||
const seasonOrganizedState =
|
||||
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,
|
||||
}
|
||||
return { ...season, torrent: seasonTorrentMapBySn[season.season_number] ?? null, organized_state: orgCount === 0 ? 'none' : orgCount >= total && total > 0 ? 'complete' : 'partial', organized_count: orgCount, episodes: eps }
|
||||
})
|
||||
|
||||
// ── 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 })
|
||||
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })) })
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
@@ -551,67 +329,46 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
app.post('/api/download', requireAuth, async (req, res) => {
|
||||
const url = req.body.magnet ?? req.body.torrent_url
|
||||
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
|
||||
logger.info('api', 'Téléchargement demandé')
|
||||
const results = await dispatchDownload(url)
|
||||
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
|
||||
})
|
||||
|
||||
// ── Downloads — liste torrents Fankai ──────────────────────────
|
||||
// ── Downloads list ─────────────────────────────────────────────
|
||||
app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const torrents = await dispatchList(category ?? 'fankai')
|
||||
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const enriched = await Promise.all(torrents.map(async (t: any) => {
|
||||
const orgFiles = organized[t.hash] ?? {}
|
||||
// Chercher les métadonnées via le cache GitHub
|
||||
const orgFiles = organized[t.hash] ?? {}
|
||||
let totalFiles = 1
|
||||
try {
|
||||
const availableIds = await readAvailable()
|
||||
for (const id of availableIds) {
|
||||
const sd = await readSerieData(id)
|
||||
if (!sd) continue
|
||||
const allT = extractTorrentsFromSerieData(sd)
|
||||
const match = allT.find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
|
||||
if (match) {
|
||||
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
|
||||
totalFiles = resolved.length || 1
|
||||
break
|
||||
}
|
||||
const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
|
||||
if (match) { totalFiles = buildResolvedEpisodes(sd, match.infohash, match.season_number).length || 1; break }
|
||||
}
|
||||
} catch {}
|
||||
const doneFiles = Object.keys(orgFiles).length
|
||||
let organizeState: 'none' | 'partial' | 'done' = 'none'
|
||||
if (doneFiles >= totalFiles) organizeState = 'done'
|
||||
else if (doneFiles > 0) organizeState = 'partial'
|
||||
|
||||
const doneFiles = Object.keys(orgFiles).length
|
||||
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
|
||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||
const errorFiles = notif?.errorFiles ?? []
|
||||
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles }
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [] }
|
||||
}))
|
||||
|
||||
res.json(enriched)
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Résultats d'organisation récents ──────────────────────────
|
||||
// ── Organize ───────────────────────────────────────────────────
|
||||
interface OrganizeNotif {
|
||||
hash : string
|
||||
name : string
|
||||
done : number
|
||||
skipped : number
|
||||
errors : number
|
||||
errorFiles : { file: string; error: string }[]
|
||||
at : string
|
||||
hash: string; name: string; done: number; skipped: number; errors: number
|
||||
errorFiles: { file: string; error: string }[]; at: string
|
||||
}
|
||||
const recentOrganized: OrganizeNotif[] = []
|
||||
const MAX_NOTIFS = 20
|
||||
@@ -624,128 +381,152 @@ function pushNotif(n: OrganizeNotif) {
|
||||
app.get('/api/organize/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentOrganized)
|
||||
})
|
||||
|
||||
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
|
||||
recentOrganized.length = 0
|
||||
logger.info('api', 'Historique des imports effacé')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/organize', requireAuth, async (req, res) => {
|
||||
const { hash, name, save_path } = req.body
|
||||
if (!hash || !name || !save_path) {
|
||||
res.status(400).json({ error: 'hash, name et save_path requis' })
|
||||
return
|
||||
}
|
||||
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await organizeTorrent(hash, name, save_path, seriesData)
|
||||
if (result.done > 0 || result.errors.length > 0) {
|
||||
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
||||
}
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
logger.error('api', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Logs ──────────────────────────────────────────────────────
|
||||
// ── Logs ───────────────────────────────────────────────────────
|
||||
app.get('/api/logs', requireAuth, (req, res) => {
|
||||
const limit = Number(req.query.limit) || 100
|
||||
const level = (req.query.level as string) || 'all'
|
||||
const source = (req.query.source as string) || undefined
|
||||
const entries = readLogs({ limit, level: level as any, source })
|
||||
res.json({ entries, size: logsFileSize() })
|
||||
const limit = Number(req.query.limit) || 100
|
||||
const level = (req.query.level as string) || 'all'
|
||||
const source = (req.query.source as string) || undefined
|
||||
res.json({ entries: readLogs({ limit, level: level as any, source }), size: logsFileSize() })
|
||||
})
|
||||
|
||||
app.post('/api/logs/clear', requireAuth, (_req, res) => {
|
||||
clearLogs()
|
||||
logger.info('api', 'Logs effacés')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── System info ────────────────────────────────────────────────
|
||||
// ── Système ────────────────────────────────────────────────────
|
||||
app.get('/api/system', requireAuth, (_req, res) => {
|
||||
const isDocker = fs.existsSync('/.dockerenv')
|
||||
res.json({ isDocker })
|
||||
res.json({ isDocker: fs.existsSync('/.dockerenv') })
|
||||
})
|
||||
|
||||
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 })
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b))
|
||||
res.json({ path: dirPath, parent: dirPath === '/' ? null : path.dirname(dirPath), dirs })
|
||||
} catch (err) {
|
||||
logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/torrents/status', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const available = await readAvailable()
|
||||
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
|
||||
} catch { res.json({ exists: false, count: 0, empty: true }) }
|
||||
})
|
||||
|
||||
// ── Update : vide le cache pour forcer le rechargement depuis GitHub ──────────
|
||||
app.post('/api/update', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
cacheClear()
|
||||
// Pré-charger available.json pour vérifier que GitHub répond
|
||||
logger.info('api', 'Cache GitHub vidé — rechargement forcé')
|
||||
const availableIds = await readAvailable()
|
||||
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
|
||||
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
|
||||
res.json({ ok: true, count: availableIds.length })
|
||||
} catch (err) {
|
||||
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/scan', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { mediaPath } = readSettings()
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
logger.info('api', 'Scan médiathèque manuel lancé')
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
|
||||
logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`)
|
||||
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error('api', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── SPA fallback (prod) ────────────────────────────────────────
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.get('*path', (_req, res) => {
|
||||
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
|
||||
// ── Debug (dev mode uniquement) ───────────────────────────────
|
||||
app.get('/api/debug/stats', requireAuth, (req, res) => {
|
||||
const { devMode } = readSettings()
|
||||
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
|
||||
|
||||
const mem = process.memoryUsage()
|
||||
const uptimeS = Math.floor(process.uptime())
|
||||
const h = Math.floor(uptimeS / 3600)
|
||||
const m = Math.floor((uptimeS % 3600) / 60)
|
||||
const s = uptimeS % 60
|
||||
|
||||
let organizedCount = 0
|
||||
try {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath)) {
|
||||
const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
organizedCount = Object.values(org).reduce((acc: number, 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 () => {
|
||||
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
|
||||
console.log(`[fankarr] Cache GitHub TTL: 6h — premier fetch au démarrage...`)
|
||||
logger.info('api', `Serveur démarré sur le port ${PORT}`)
|
||||
|
||||
// Pré-chauffer le cache available.json au démarrage
|
||||
try {
|
||||
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) {
|
||||
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 { mediaPath } = readSettings()
|
||||
loadEnrichedSeriesData().then(seriesData =>
|
||||
scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
|
||||
).catch(err =>
|
||||
logger.error('organize', `scanMediaPath échoué: ${err}`)
|
||||
)
|
||||
const { mediaPath } = readSettings()
|
||||
loadEnrichedSeriesData()
|
||||
.then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData))
|
||||
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : 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 () => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
@@ -760,10 +541,10 @@ app.listen(PORT, async () => {
|
||||
}
|
||||
)
|
||||
} 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(() => {
|
||||
autoOrganize()
|
||||
setInterval(autoOrganize, 5 * 60_000)
|
||||
|
||||
+87
-74
@@ -2,13 +2,6 @@
|
||||
* organize-worker.ts
|
||||
* ==================
|
||||
* Worker thread dédié à l'organisation des fichiers.
|
||||
* Tourne dans un thread séparé pour ne pas bloquer le event loop Express.
|
||||
*
|
||||
* Messages reçus : { type: 'run', torrents: TorrentInfo[], seriesData: SerieData[] }
|
||||
* Messages envoyés :
|
||||
* { type: 'log', level: 'info'|'warn'|'error', msg: string }
|
||||
* { type: 'result', hash: string, name: string, done, skipped, errors }
|
||||
* { type: 'done' }
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
@@ -20,6 +13,7 @@ import { DATA_DIR } from './config.js'
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
|
||||
// ─── Utils log ────────────────────────────────────────────────
|
||||
// Les messages sont routés vers logger via organize.ts — pas besoin de préfixe
|
||||
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 }) }
|
||||
@@ -53,33 +47,44 @@ function isOrganized(hash: string, filename: string): boolean {
|
||||
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 {
|
||||
const h = hash.toLowerCase()
|
||||
for (const sd of seriesData) {
|
||||
// Niveau série
|
||||
// Niveau série (pack intégrale)
|
||||
for (const t of sd.torrents ?? []) {
|
||||
if (t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
if (t.infohash?.toLowerCase() === h)
|
||||
return { torrent: t, serieData: sd }
|
||||
}
|
||||
// Niveau saison
|
||||
|
||||
// Niveau saison — vérifier si le même hash apparaît dans plusieurs saisons
|
||||
// Si oui, pas de _season pour que buildFileMap scanne toutes les saisons
|
||||
const seasonsWithHash = (sd.seasons ?? []).filter((s: any) =>
|
||||
s.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
||||
)
|
||||
if (seasonsWithHash.length === 1) {
|
||||
// Torrent propre à une seule saison → on filtre sur cette saison
|
||||
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) {
|
||||
// Même torrent dans plusieurs saisons → pas de filtre saison
|
||||
const t = seasonsWithHash[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: t, serieData: sd }
|
||||
}
|
||||
|
||||
// Niveau épisode
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
if (t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||
}
|
||||
// Niveau épisode — compter combien d'épisodes partagent ce torrent
|
||||
const matchingEpisodes = season.episodes?.filter((ep: any) =>
|
||||
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
||||
) ?? []
|
||||
|
||||
if (matchingEpisodes.length === 1) {
|
||||
// Torrent propre à un seul épisode → cas épisode unique
|
||||
const ep = matchingEpisodes[0]
|
||||
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
|
||||
} else if (matchingEpisodes.length > 1) {
|
||||
// Même torrent partagé entre plusieurs épisodes → traiter comme pack_saison
|
||||
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||
}
|
||||
}
|
||||
@@ -87,14 +92,11 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
|
||||
return null
|
||||
}
|
||||
|
||||
// Construit la map filename → { season_number, original_filename, fullPath }
|
||||
// Supporte les deux formats : { infohash, path } et string (legacy)
|
||||
// destFilename : original_filename si nfoSupport, formatted_name sinon
|
||||
function buildFileMap(
|
||||
serieData : any,
|
||||
hash : string,
|
||||
nfoSupport : boolean,
|
||||
seasonFilter?: number
|
||||
seasonFilter?: number,
|
||||
): Map<string, { season_number: number; original_filename: string; fullPath: string }> {
|
||||
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
|
||||
const h = hash.toLowerCase()
|
||||
@@ -117,13 +119,17 @@ function buildFileMap(
|
||||
if (!matchedPath) continue
|
||||
const filename = matchedPath.split('/').pop() ?? matchedPath
|
||||
|
||||
// nfoSupport → original_filename (correspond aux .nfo GitLab)
|
||||
// sinon → formatted_name de l'API (nommage Jellyfin/Plex standardisé)
|
||||
const fmtName = ep.formatted_name?.trim()
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: null
|
||||
|
||||
if (!nfoSupport && !fmtName) {
|
||||
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback original_filename`)
|
||||
}
|
||||
|
||||
const destName = nfoSupport
|
||||
? (ep.original_filename ?? filename)
|
||||
: (ep.formatted_name
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: (ep.original_filename ?? filename))
|
||||
: (fmtName ?? ep.original_filename ?? filename)
|
||||
|
||||
map.set(filename, {
|
||||
season_number : season.season_number,
|
||||
@@ -135,7 +141,7 @@ function buildFileMap(
|
||||
return map
|
||||
}
|
||||
|
||||
// ─── Dossiers exclus ─────────────────────────────────────────
|
||||
// ─── Dossiers exclus ──────────────────────────────────────────
|
||||
const EXCLUDED_FOLDERS = new Set([
|
||||
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
|
||||
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
|
||||
@@ -153,7 +159,7 @@ function isInExcludedFolder(filePath: string[]): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Map correction titres API → titres GitLab ───────────────
|
||||
// ─── Map titres API → 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ū',
|
||||
@@ -177,21 +183,21 @@ const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
|
||||
async function fetchJson(url: string): Promise<any> {
|
||||
const { default: nodeFetch } = await import('node-fetch')
|
||||
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function fetchBinary(url: string): Promise<Buffer> {
|
||||
const { default: nodeFetch } = await import('node-fetch')
|
||||
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return Buffer.from(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
|
||||
const gitlabTitle = getGitlabSerieTitle(serieTitle)
|
||||
const folderPath = `pack/${gitlabTitle}`
|
||||
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
|
||||
log(`Téléchargement NFO/images depuis GitLab pour "${gitlabTitle}"`)
|
||||
|
||||
let files: any[] = []
|
||||
try {
|
||||
@@ -206,30 +212,32 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
|
||||
page++
|
||||
}
|
||||
} catch (err) {
|
||||
warn(`[nfo] Dossier GitLab introuvable pour "${serieTitle}": ${err instanceof Error ? err.message : err}`)
|
||||
warn(`Dossier GitLab introuvable pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (files.length === 0) { warn(`[nfo] Aucun fichier trouvé pour "${serieTitle}"`); return }
|
||||
if (files.length === 0) { warn(`Aucun fichier NFO trouvé pour "${gitlabTitle}"`); return }
|
||||
|
||||
const fileEntries = files.filter((f: any) => f.type === 'blob')
|
||||
log(`[nfo] ${fileEntries.length} fichiers à télécharger`)
|
||||
log(`${fileEntries.length} fichiers NFO/images à télécharger pour "${gitlabTitle}"`)
|
||||
|
||||
let downloaded = 0, skipped = 0, failed = 0
|
||||
for (const entry of fileEntries) {
|
||||
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
||||
const destPath = path.join(destRoot, relativePath)
|
||||
if (fs.existsSync(destPath)) continue
|
||||
if (fs.existsSync(destPath)) { skipped++; continue }
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
|
||||
const data = await fetchBinary(rawUrl)
|
||||
fs.writeFileSync(destPath, data)
|
||||
log(`[nfo] ✓ ${relativePath}`)
|
||||
downloaded++
|
||||
} catch (err) {
|
||||
warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`)
|
||||
warn(`Échec téléchargement NFO "${relativePath}" : ${err instanceof Error ? err.message : err}`)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
log(`[nfo] GitLab synchronisé pour "${gitlabTitle}"`)
|
||||
log(`NFO "${gitlabTitle}" — ${downloaded} téléchargés, ${skipped} déjà présents${failed > 0 ? `, ${failed} échecs` : ''}`)
|
||||
}
|
||||
|
||||
// ─── Filesystem ops ───────────────────────────────────────────
|
||||
@@ -238,31 +246,36 @@ function seasonFolder(n: number): string { return `Saison ${n}` }
|
||||
function tryHardlink(src: string, dest: string): boolean {
|
||||
try { fs.linkSync(src, dest); return true }
|
||||
catch (err) {
|
||||
warn(`[organize] hardlink échoué (${err instanceof Error ? err.message : err})`)
|
||||
debug(`Hardlink échoué (${err instanceof Error ? err.message : err}), fallback copie`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeDirName(name: string): string {
|
||||
return name
|
||||
.replace(/:/g, ' -')
|
||||
.replace(/[<>"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// ─── Organise un torrent ──────────────────────────────────────
|
||||
async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) {
|
||||
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
|
||||
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
|
||||
|
||||
log(`[organize] ── ${name} ──`)
|
||||
log(`[organize] hash=${hash} save_path=${savePath} mode=${organizeMode}`)
|
||||
|
||||
const found = findTorrentByHash(hash, seriesData)
|
||||
if (!found) {
|
||||
error(`[organize] ✗ Hash ${hash} introuvable dans les données série`)
|
||||
error(`Hash ${hash} introuvable dans les données série`)
|
||||
throw new Error(`Torrent introuvable : ${hash}`)
|
||||
}
|
||||
|
||||
const { torrent, serieData } = found
|
||||
const serieTitle = serieData.title ?? serieData.show_title ?? name
|
||||
const torrentTitle = torrent.title ?? name
|
||||
log(`[organize] ✓ Trouvé : ${torrentTitle} — série: ${serieTitle}`)
|
||||
const rawTitle = serieData.title ?? serieData.show_title ?? name
|
||||
const serieTitle = sanitizeDirName(rawTitle)
|
||||
|
||||
debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
|
||||
|
||||
// ── NFO/images depuis GitLab ──────────────────────────────
|
||||
if (nfoSupport) {
|
||||
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
|
||||
}
|
||||
@@ -278,11 +291,12 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break }
|
||||
}
|
||||
const filename = filePath?.split('/').pop() ?? name
|
||||
const fmtName = ep.formatted_name?.trim()
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: null
|
||||
const destName = nfoSupport
|
||||
? (ep.original_filename ?? filename)
|
||||
: (ep.formatted_name
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: (ep.original_filename ?? filename))
|
||||
: (fmtName ?? ep.original_filename ?? filename)
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||
const dest = path.join(destDir, destName)
|
||||
|
||||
@@ -302,7 +316,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
||||
|
||||
if (!src) {
|
||||
error(`[organize] ✗ ${filename} introuvable`)
|
||||
error(`Fichier source introuvable : ${filename}`)
|
||||
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] }
|
||||
}
|
||||
|
||||
@@ -316,16 +330,16 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
}
|
||||
}
|
||||
markOrganized(hash, filename)
|
||||
log(`[organize] ✓ ${destName} → Saison ${season.season_number}`)
|
||||
log(`${destName} → Saison ${season.season_number}`)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
}
|
||||
|
||||
// ── Pack saison / intégrale ───────────────────────────────
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter)
|
||||
const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter)
|
||||
|
||||
if (fileMap.size === 0) {
|
||||
warn(`[organize] Aucun fichier mappé pour ${name}`)
|
||||
warn(`Aucun fichier mappé pour "${name}"`)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -334,8 +348,6 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
for (const [filename, { season_number, original_filename, fullPath }] of fileMap) {
|
||||
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 = [
|
||||
path.join(savePath, fullPath),
|
||||
path.join(completePath, fullPath),
|
||||
@@ -346,7 +358,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
||||
|
||||
if (!src) {
|
||||
error(`[organize] ✗ ${filename} introuvable`)
|
||||
error(`Fichier source introuvable : ${filename}`)
|
||||
result.errors.push({ file: filename, error: 'Source introuvable' })
|
||||
continue
|
||||
}
|
||||
@@ -366,51 +378,52 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
}
|
||||
markOrganized(hash, filename)
|
||||
result.done++
|
||||
log(`[organize] ✓ ${original_filename} → Saison ${season_number}`)
|
||||
debug(`${original_filename} → Saison ${season_number}`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
error(`[organize] ✗ ${filename}: ${msg}`)
|
||||
error(`Échec import "${filename}" : ${msg}`)
|
||||
result.errors.push({ file: filename, error: msg })
|
||||
}
|
||||
}
|
||||
|
||||
log(`[organize] ── ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Boucle principale du worker ─────────────────────────────
|
||||
// ─── Boucle principale ────────────────────────────────────────
|
||||
parentPort?.on('message', async (msg: any) => {
|
||||
if (msg.type !== 'run') return
|
||||
|
||||
const torrents: any[] = msg.torrents
|
||||
const seriesData: any[] = msg.seriesData ?? []
|
||||
const organized = loadOrganized()
|
||||
const { nfoSupport } = readSettings()
|
||||
const organized = loadOrganized()
|
||||
const { nfoSupport } = readSettings()
|
||||
|
||||
for (const t of torrents) {
|
||||
if (t.state !== 'seeding') continue
|
||||
|
||||
const found = findTorrentByHash(t.hash, seriesData)
|
||||
if (!found) {
|
||||
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé dans les données série`)
|
||||
debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Vérifier si déjà tout organisé
|
||||
const { torrent, serieData } = found
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
|
||||
const allDone = fileMap.size > 0
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
|
||||
const allDone = fileMap.size > 0
|
||||
? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
|
||||
: !!organized[t.hash]?.[t.name]
|
||||
|
||||
if (allDone) continue
|
||||
if (allDone) {
|
||||
debug(`"${t.name}" déjà entièrement importé — skip`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData)
|
||||
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
|
||||
} catch (err) {
|
||||
error(`[organize] Erreur ${t.name}: ${err instanceof Error ? err.message : err}`)
|
||||
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+69
-48
@@ -4,8 +4,8 @@
|
||||
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { logger } from './logger.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
@@ -13,10 +13,7 @@ import { readSettings } from './settings.js'
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
|
||||
// Un seul worker à la fois
|
||||
let workerRunning = false
|
||||
|
||||
// État précédent des torrents pour détecter les passages downloading → seeding
|
||||
export let workerRunning = false
|
||||
const _prevStates = new Map<string, string>()
|
||||
|
||||
function resolveWorkerPath(): string {
|
||||
@@ -33,10 +30,8 @@ export interface OrganizeResult {
|
||||
errors : { file: string; error: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan initial du mediaPath au démarrage.
|
||||
* Construit l'index filename → infohash depuis les seriesData passées en paramètre.
|
||||
*/
|
||||
// ── Scan initial ──────────────────────────────────────────────
|
||||
|
||||
export async function scanMediaPath(
|
||||
mediaPath : string,
|
||||
organizedPath: string,
|
||||
@@ -45,20 +40,18 @@ export async function scanMediaPath(
|
||||
const result = { found: 0, added: 0 }
|
||||
|
||||
if (!fs.existsSync(mediaPath)) {
|
||||
logger.warn('organize', `scanMediaPath: ${mediaPath} inexistant, skip`)
|
||||
logger.warn('organize', `Dossier médiathèque introuvable : ${mediaPath}`)
|
||||
return result
|
||||
}
|
||||
|
||||
// Construire index filename → infohash depuis les seriesData
|
||||
const filenameIndex = new Map<string, string>() // filename → infohash
|
||||
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
|
||||
|
||||
const filenameIndex = new Map<string, string>()
|
||||
for (const sd of seriesData) {
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p === 'string') {
|
||||
// Legacy : pas d'infohash disponible, on skip pour le scan
|
||||
continue
|
||||
}
|
||||
if (typeof p === 'string') continue
|
||||
const hash = p.infohash?.toLowerCase()
|
||||
if (!hash || !p.path) continue
|
||||
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>> = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
@@ -100,7 +92,6 @@ export async function scanMediaPath(
|
||||
|
||||
walk(mediaPath)
|
||||
|
||||
// Nettoyer les entrées orphelines
|
||||
let removed = 0
|
||||
for (const [hash, files] of Object.entries(organized)) {
|
||||
for (const filename of Object.keys(files)) {
|
||||
@@ -114,28 +105,29 @@ export async function scanMediaPath(
|
||||
|
||||
if (result.added > 0 || removed > 0) {
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`)
|
||||
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`)
|
||||
} else {
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
|
||||
logger.info('organize', `Scan terminé — ${result.found} fichiers, rien de nouveau`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-import — appelée depuis index.ts avec les seriesData du cache GitHub.
|
||||
*/
|
||||
// ── Auto-import ───────────────────────────────────────────────
|
||||
|
||||
export async function autoOrganizeAll(
|
||||
listFn : () => Promise<any[]>,
|
||||
seriesData : any[],
|
||||
onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
||||
): Promise<void> {
|
||||
// Vérifier si l'import auto est activé
|
||||
const { autoImport } = readSettings()
|
||||
if (!autoImport) return
|
||||
if (!autoImport) {
|
||||
logger.debug('organize', 'Import automatique désactivé — skip')
|
||||
return
|
||||
}
|
||||
|
||||
if (workerRunning) {
|
||||
console.log('[organize] Worker déjà en cours, skip')
|
||||
logger.debug('organize', 'Worker déjà en cours — skip')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -143,7 +135,7 @@ export async function autoOrganizeAll(
|
||||
try {
|
||||
torrents = await listFn()
|
||||
} catch (err) {
|
||||
console.error('[organize] Impossible de récupérer la liste des torrents:', err)
|
||||
logger.error('organize', `Impossible de récupérer la liste des torrents : ${err instanceof Error ? err.message : err}`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -159,7 +151,6 @@ export async function autoOrganizeAll(
|
||||
|
||||
for (const t of torrents) _prevStates.set(t.hash, t.state)
|
||||
|
||||
// Charger organized.json pour vérifier s'il reste des torrents non organisés
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
@@ -175,21 +166,31 @@ export async function autoOrganizeAll(
|
||||
if (seedingCount === 0) return
|
||||
|
||||
if (newlySeeding.length > 0 && !isFirstRun) {
|
||||
console.log(`[organize] ${newlySeeding.length} nouveau(x) torrent(s) terminé(s) → lancement worker`)
|
||||
logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) → lancement de l'import automatique`)
|
||||
for (const t of newlySeeding) {
|
||||
logger.debug('organize', `Nouveau seeding : "${t.name}"`)
|
||||
}
|
||||
} else {
|
||||
console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`)
|
||||
logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
|
||||
}
|
||||
workerRunning = true
|
||||
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
workerRunning = true
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
if (msg.level === 'error') console.error(msg.msg)
|
||||
else if (msg.level === 'warn') console.warn(msg.msg)
|
||||
else console.log(msg.msg)
|
||||
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
|
||||
if (msg.errors.length > 0) {
|
||||
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
|
||||
for (const e of msg.errors) {
|
||||
logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
|
||||
}
|
||||
} else if (msg.done > 0) {
|
||||
logger.info('organize', `"${msg.name}" — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
|
||||
} else {
|
||||
logger.debug('organize', `"${msg.name}" — rien à importer (${msg.skipped} skippé(s))`)
|
||||
}
|
||||
onResult?.({
|
||||
hash : msg.hash,
|
||||
name : msg.name,
|
||||
@@ -199,27 +200,35 @@ export async function autoOrganizeAll(
|
||||
errorFiles: msg.errors,
|
||||
})
|
||||
} else if (msg.type === 'done') {
|
||||
console.log('[organize] Worker terminé')
|
||||
logger.debug('organize', 'Worker terminé')
|
||||
workerRunning = false
|
||||
worker.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => { console.error('[organize] Worker erreur:', err); workerRunning = false })
|
||||
worker.on('exit', (code) => { if (code !== 0) console.error(`[organize] Worker exit ${code}`); workerRunning = false })
|
||||
worker.on('error', (err) => {
|
||||
logger.error('organize', `Worker erreur : ${err.message}`)
|
||||
workerRunning = false
|
||||
})
|
||||
worker.on('exit', (code) => {
|
||||
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
|
||||
else logger.debug('organize', `Worker terminé (code ${code})`)
|
||||
workerRunning = false
|
||||
})
|
||||
|
||||
worker.postMessage({ type: 'run', torrents, seriesData })
|
||||
}
|
||||
|
||||
/**
|
||||
* Organisation manuelle d'un torrent.
|
||||
*/
|
||||
// ── Import manuel ─────────────────────────────────────────────
|
||||
|
||||
export async function organizeTorrent(
|
||||
hash : string,
|
||||
name : string,
|
||||
savePath : string,
|
||||
seriesData: any[]
|
||||
): Promise<OrganizeResult> {
|
||||
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
|
||||
@@ -227,22 +236,34 @@ export async function organizeTorrent(
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
if (msg.level === 'error') console.error(msg.msg)
|
||||
else if (msg.level === 'warn') console.warn(msg.msg)
|
||||
else console.log(msg.msg)
|
||||
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
result.total = msg.total
|
||||
result.skipped = msg.skipped
|
||||
result.done = msg.done
|
||||
result.errors = msg.errors
|
||||
if (msg.errors.length > 0) {
|
||||
logger.warn('organize', `Import "${name}" — ${msg.done} OK, ${msg.errors.length} erreur(s)`)
|
||||
for (const e of msg.errors) {
|
||||
logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
|
||||
}
|
||||
} else {
|
||||
logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
|
||||
}
|
||||
} else if (msg.type === 'done') {
|
||||
worker.terminate()
|
||||
resolve(result)
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => { worker.terminate(); reject(err) })
|
||||
worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) })
|
||||
worker.on('error', (err) => {
|
||||
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err.message}`)
|
||||
worker.terminate()
|
||||
reject(err)
|
||||
})
|
||||
worker.on('exit', (code) => {
|
||||
if (code > 1) reject(new Error(`Worker exit inattendu: code ${code}`))
|
||||
})
|
||||
|
||||
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
|
||||
})
|
||||
|
||||
+14
-12
@@ -5,21 +5,23 @@ import { DATA_DIR } from './config.js'
|
||||
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
|
||||
|
||||
export interface Settings {
|
||||
mediaPath : string
|
||||
completePath : string
|
||||
organizeMode : 'hardlink' | 'move'
|
||||
category : string
|
||||
nfoSupport : boolean
|
||||
autoImport : boolean // import automatique quand un torrent passe en seeding
|
||||
mediaPath : string
|
||||
completePath : string
|
||||
organizeMode : 'hardlink' | 'move'
|
||||
category : string
|
||||
nfoSupport : boolean
|
||||
autoImport : boolean
|
||||
devMode : boolean
|
||||
}
|
||||
|
||||
const defaults: Settings = {
|
||||
mediaPath : '',
|
||||
completePath : '',
|
||||
organizeMode : 'hardlink',
|
||||
category : 'fankai',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
mediaPath : '',
|
||||
completePath: '',
|
||||
organizeMode: 'hardlink',
|
||||
category : 'fankai',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
devMode : false,
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
|
||||
@@ -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/' : '/',
|
||||
})
|
||||
}
|
||||
@@ -2,14 +2,13 @@
|
||||
* Torrent Client Registry
|
||||
* =======================
|
||||
* Interface commune + registry de tous les clients disponibles.
|
||||
* Chaque client expose son schema de configuration (FieldDef[])
|
||||
* pour que le frontend génère le formulaire dynamiquement.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,36 +21,32 @@ export interface FieldDef {
|
||||
default? : string | number
|
||||
}
|
||||
|
||||
/** Définition statique d'un type de client (ce qu'il expose au front) */
|
||||
export interface TorrentClientDefinition {
|
||||
id : string // "qbittorrent", "transmission", etc.
|
||||
label : string // "qBittorrent"
|
||||
fields : FieldDef[] // schema du formulaire
|
||||
id : string
|
||||
label : string
|
||||
fields : FieldDef[]
|
||||
}
|
||||
|
||||
/** Instance sauvegardée d'un client configuré par l'utilisateur */
|
||||
export interface SavedClient {
|
||||
uuid : string // identifiant unique de cette instance
|
||||
name : string // nom custom donné par l'utilisateur
|
||||
type : string // id du TorrentClientDefinition
|
||||
uuid : string
|
||||
name : string
|
||||
type : string
|
||||
config: Record<string, string | number>
|
||||
}
|
||||
|
||||
/** État normalisé d'un torrent (commun à tous les clients) */
|
||||
export interface TorrentInfo {
|
||||
hash : string
|
||||
name : string
|
||||
state : 'downloading' | 'seeding' | 'paused' | 'checking' | 'error' | 'unknown'
|
||||
progress : number // 0-100
|
||||
size : number // bytes
|
||||
downloaded: number // bytes
|
||||
speed : number // bytes/s
|
||||
eta : number // secondes, -1 si inconnu
|
||||
save_path : string // dossier de complétion
|
||||
progress : number
|
||||
size : number
|
||||
downloaded: number
|
||||
speed : number
|
||||
eta : number
|
||||
save_path : string
|
||||
category : string
|
||||
}
|
||||
|
||||
/** Interface que chaque client doit implémenter */
|
||||
export interface TorrentClientDriver {
|
||||
definition : TorrentClientDefinition
|
||||
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
|
||||
@@ -66,6 +61,7 @@ const drivers = new Map<string, TorrentClientDriver>()
|
||||
|
||||
export function registerDriver(driver: TorrentClientDriver) {
|
||||
drivers.set(driver.definition.id, driver)
|
||||
logger.debug('torrent-clients', `Driver enregistré : ${driver.definition.label}`)
|
||||
}
|
||||
|
||||
export function getDriver(type: string): TorrentClientDriver | undefined {
|
||||
@@ -76,7 +72,7 @@ export function getAvailableClients(): TorrentClientDefinition[] {
|
||||
return [...drivers.values()].map(d => d.definition)
|
||||
}
|
||||
|
||||
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
|
||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
||||
|
||||
@@ -103,14 +99,17 @@ export function addClient(name: string, type: string, config: Record<string, str
|
||||
const client: SavedClient = { uuid: randomUUID(), name, type, config }
|
||||
clients.push(client)
|
||||
saveClients(clients)
|
||||
logger.info('torrent-clients', `Client ajouté : "${name}" (${type})`)
|
||||
return client
|
||||
}
|
||||
|
||||
export function removeClient(uuid: string): boolean {
|
||||
const clients = loadClients()
|
||||
const clients = loadClients()
|
||||
const target = clients.find(c => c.uuid === uuid)
|
||||
const filtered = clients.filter(c => c.uuid !== uuid)
|
||||
if (filtered.length === clients.length) return false
|
||||
saveClients(filtered)
|
||||
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -118,7 +117,6 @@ export function getClient(uuid: string): SavedClient | undefined {
|
||||
return loadClients().find(c => c.uuid === uuid)
|
||||
}
|
||||
|
||||
/** Masque les champs password pour l'envoi au front */
|
||||
export function sanitizeClient(client: SavedClient, definition?: TorrentClientDefinition): SavedClient {
|
||||
if (!definition) definition = getDriver(client.type)?.definition
|
||||
if (!definition) return client
|
||||
@@ -131,35 +129,40 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
|
||||
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 }[]> {
|
||||
const clients = loadClients()
|
||||
|
||||
if (clients.length === 0) {
|
||||
logger.warn('torrent-clients', 'Aucun client configuré — téléchargement impossible')
|
||||
return []
|
||||
}
|
||||
|
||||
const results = []
|
||||
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) {
|
||||
logger.error('torrent-clients', `Driver "${client.type}" introuvable pour le client "${client.name}"`)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Driver "${client.type}" introuvable` })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await driver.add(client.config, url)
|
||||
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: true })
|
||||
} catch (err) {
|
||||
results.push({
|
||||
uuid : client.uuid,
|
||||
name : client.name,
|
||||
ok : false,
|
||||
error: err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
})
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('torrent-clients', `Échec envoi vers "${client.name}" : ${msg}`)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: false, error: msg })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Dispatch list vers tous les clients ─────────────────────────────────────
|
||||
// ─── Dispatch list ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchList(category?: string): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
|
||||
const clients = loadClients()
|
||||
@@ -169,14 +172,14 @@ export async function dispatchList(category?: string): Promise<(TorrentInfo & {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) continue
|
||||
try {
|
||||
// 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 })
|
||||
}
|
||||
} 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// Mapping état qBittorrent → état normalisé
|
||||
function mapState(state: string): TorrentInfo['state'] {
|
||||
if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading'
|
||||
if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding'
|
||||
@@ -29,7 +29,7 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
|
||||
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
|
||||
|
||||
const cookie = res.headers.get('set-cookie') ?? ''
|
||||
const sid = cookie.match(/SID=([^;]+)/)?.[1]
|
||||
const sid = cookie.match(/SID=([^;]+)/)?.[1]
|
||||
if (!sid) throw new Error('Cookie SID introuvable')
|
||||
return sid
|
||||
}
|
||||
@@ -39,19 +39,22 @@ const QB: TorrentClientDriver = {
|
||||
id : 'qbittorrent',
|
||||
label : 'qBittorrent',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
await qbLogin(config)
|
||||
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
return { ok: false, message: err instanceof Error ? err.message : 'Erreur inconnue' }
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.warn('qbittorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -59,23 +62,26 @@ const QB: TorrentClientDriver = {
|
||||
try {
|
||||
const sid = await qbLogin(config)
|
||||
const res = await fetch(`${config.url}/api/v2/app/version`, {
|
||||
headers: { Cookie: `SID=${sid}` }
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
})
|
||||
if (!res.ok) return { online: false }
|
||||
return { online: true, version: (await res.text()).trim() }
|
||||
} catch {
|
||||
const version = (await res.text()).trim()
|
||||
logger.debug('qbittorrent', `Healthcheck OK — version ${version}`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
logger.debug('qbittorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const sid = await qbLogin(config)
|
||||
const sid = await qbLogin(config)
|
||||
const params = new URLSearchParams()
|
||||
if (category) params.set('category', category)
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
|
||||
headers: { Cookie: `SID=${sid}` }
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
})
|
||||
if (!res.ok) throw new Error(`qB list échoué: ${res.status}`)
|
||||
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
|
||||
const data: any[] = await res.json()
|
||||
return data.map(t => ({
|
||||
hash : t.hash,
|
||||
@@ -92,7 +98,7 @@ const QB: TorrentClientDriver = {
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
const sid = await qbLogin(config)
|
||||
const sid = await qbLogin(config)
|
||||
const form = new FormData()
|
||||
form.append('urls', url)
|
||||
if (config.category) form.append('category', String(config.category))
|
||||
@@ -104,6 +110,7 @@ const QB: TorrentClientDriver = {
|
||||
})
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||
logger.info('qbittorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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: 'Dossier cible',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 ou par dossier contenant la catégorie
|
||||
return t.labels?.includes(category) ||
|
||||
(t.downloadDir as string)?.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)
|
||||
} else if (config.category) {
|
||||
// Si pas de savePath explicite, ajouter la catégorie comme sous-dossier
|
||||
args['download-dir'] = `/sdcard/Download/${config.category}`
|
||||
}
|
||||
|
||||
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
@@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#080c10] text-[#c8d8e8] font-mono">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<keep-alive include="CatalogView">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</RouterView>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
<RouterView />
|
||||
<ToastContainer />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
+149
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,74 +1,75 @@
|
||||
<template>
|
||||
<!-- Overlay -->
|
||||
<div class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')">
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] w-full max-w-lg flex flex-col" style="max-height: 70vh">
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')">
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-lg flex flex-col" style="max-height: 70vh">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-[#1e2d3d] shrink-0">
|
||||
<div>
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-0.5">CHOISIR UN DOSSIER</div>
|
||||
<div class="text-xs text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</div>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-0.5">Choisir un dossier</p>
|
||||
<p class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</p>
|
||||
</div>
|
||||
<button @click="$emit('cancel')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
|
||||
</div>
|
||||
<button @click="$emit('cancel')" class="text-[#5a7a94] hover:text-white transition-colors cursor-pointer text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Chemin éditable -->
|
||||
<div class="px-5 py-3 border-b border-[#1e2d3d] shrink-0 flex gap-2">
|
||||
<input
|
||||
v-model="inputPath"
|
||||
@keydown.enter="navigateTo(inputPath)"
|
||||
class="flex-1 bg-[#121920] border border-[#1e2d3d] text-xs text-white px-3 py-2 font-mono outline-none focus:border-[#e8513a] transition-colors"
|
||||
placeholder="/"
|
||||
/>
|
||||
<button @click="navigateTo(inputPath)"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-3 py-2 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation retour -->
|
||||
<div v-if="parent !== null" class="px-5 shrink-0">
|
||||
<button @click="navigateTo(parent)"
|
||||
class="flex items-center gap-2 w-full py-2.5 text-xs text-[#5a7a94] hover:text-white transition-colors cursor-pointer border-b border-[#1e2d3d]">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
..
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste dossiers -->
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div v-if="loading" class="flex items-center justify-center py-10 text-[#5a7a94] text-xs">
|
||||
<div class="w-4 h-4 border border-white/10 border-t-[#e8513a] rounded-full animate-spin mr-2"/>
|
||||
Chargement...
|
||||
<!-- Chemin éditable -->
|
||||
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
|
||||
<input
|
||||
v-model="inputPath"
|
||||
@keydown.enter="navigateTo(inputPath)"
|
||||
class="settings-input flex-1 font-mono text-xs"
|
||||
placeholder="/"
|
||||
/>
|
||||
<button @click="navigateTo(inputPath)" class="btn-secondary px-3 text-xs">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="error" class="px-5 py-4 text-xs text-red-400">{{ error }}</div>
|
||||
<div v-else-if="dirs.length === 0" class="px-5 py-4 text-xs text-[#5a7a94]">Aucun sous-dossier</div>
|
||||
<button
|
||||
v-for="dir in dirs"
|
||||
:key="dir"
|
||||
@click="navigateTo(joinPath(current, dir))"
|
||||
class="flex items-center gap-3 w-full px-5 py-2.5 text-xs text-[#c8d8e8] hover:bg-white/5 transition-colors cursor-pointer border-b border-[#1e2d3d]/50 text-left"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
|
||||
</svg>
|
||||
{{ dir }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-t border-[#1e2d3d] shrink-0">
|
||||
<span class="text-[10px] text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</span>
|
||||
<button @click="$emit('select', current)"
|
||||
class="bg-[#e8513a] text-white px-5 py-2 text-xs tracking-widest hover:opacity-85 transition-opacity cursor-pointer">
|
||||
CHOISIR
|
||||
</button>
|
||||
</div>
|
||||
<!-- Retour parent -->
|
||||
<div v-if="parent !== null" class="px-5 shrink-0">
|
||||
<button
|
||||
@click="navigateTo(parent)"
|
||||
class="flex items-center gap-2 w-full py-2.5 text-xs text-muted hover:text-primary transition-colors border-b border-border"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
..
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste dossiers -->
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div v-if="loading" class="flex items-center justify-center py-10 text-muted text-xs gap-2">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement...
|
||||
</div>
|
||||
<div v-else-if="error" class="px-5 py-4 text-xs text-red-400">{{ error }}</div>
|
||||
<div v-else-if="dirs.length === 0" class="px-5 py-4 text-xs text-muted">Aucun sous-dossier</div>
|
||||
<button
|
||||
v-for="dir in dirs"
|
||||
:key="dir"
|
||||
@click="navigateTo(joinPath(current, dir))"
|
||||
class="flex items-center gap-3 w-full px-5 py-2.5 text-xs text-primary hover:bg-hover transition-colors border-b border-border/50 text-left"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" fill="none">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
|
||||
</svg>
|
||||
{{ dir }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-t border-border shrink-0">
|
||||
<span class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</span>
|
||||
<button @click="$emit('select', current)" class="btn-primary">
|
||||
Choisir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -95,11 +96,10 @@ async function navigateTo(p: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
error.value = data.error ?? 'Erreur'
|
||||
error.value = (await res.json()).error ?? 'Erreur'
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
const data = await res.json()
|
||||
current.value = data.path
|
||||
inputPath.value = data.path
|
||||
parent.value = data.parent
|
||||
@@ -111,7 +111,5 @@ async function navigateTo(p: string) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.initialPath, v => {
|
||||
if (v) navigateTo(v)
|
||||
}, { immediate: true })
|
||||
watch(() => props.initialPath, v => { if (v) navigateTo(v) }, { immediate: true })
|
||||
</script>
|
||||
@@ -1,22 +1,42 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2">
|
||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
@click="remove(t.id)"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-xl border text-sm cursor-pointer min-w-64 max-w-sm"
|
||||
:class="{
|
||||
'border-[#3ae87a] text-[#3ae87a]': t.type === 'success',
|
||||
'border-[#e8513a] text-[#e8513a]': t.type === 'error',
|
||||
'border-[#3a8fe8] text-[#3a8fe8]': t.type === 'info',
|
||||
'bg-card border-green-500/30 text-green-400' : t.type === 'success',
|
||||
'bg-card border-red-500/30 text-red-400' : t.type === 'error',
|
||||
'bg-card border-accent/30 text-accent' : t.type === 'info',
|
||||
}"
|
||||
class="bg-[#0d1219] border px-4 py-3 text-xs tracking-wider cursor-pointer flex items-center gap-3 min-w-64"
|
||||
>
|
||||
<span v-if="t.type === 'success'">✓</span>
|
||||
<span v-else-if="t.type === 'error'">✕</span>
|
||||
<span v-else>·</span>
|
||||
{{ t.message }}
|
||||
<svg v-if="t.type === 'success'" width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
|
||||
<polyline points="2 7 5.5 10.5 12 3"/>
|
||||
</svg>
|
||||
<svg v-else-if="t.type === 'error'" width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
|
||||
<line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/>
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
|
||||
<circle cx="7" cy="7" r="5"/><line x1="7" y1="5" x2="7" y2="7.5"/><circle cx="7" cy="9.5" r="0.5" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
<span class="flex-1 text-xs leading-snug">{{ t.message }}</span>
|
||||
|
||||
<!-- Badge count si groupé -->
|
||||
<span
|
||||
v-if="t.count && t.count > 1"
|
||||
class="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full"
|
||||
:class="{
|
||||
'bg-green-500/20 text-green-400' : t.type === 'success',
|
||||
'bg-red-500/20 text-red-400' : t.type === 'error',
|
||||
'bg-accent/20 text-accent' : t.type === 'info',
|
||||
}"
|
||||
>
|
||||
×{{ t.count }}
|
||||
</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -30,7 +50,7 @@ const { toasts, remove } = useToast()
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active { transition: all 0.2s ease; }
|
||||
.toast-leave-active { transition: all 0.2s ease; }
|
||||
.toast-enter-from { opacity: 0; transform: translateX(20px); }
|
||||
.toast-leave-to { opacity: 0; transform: translateX(20px); }
|
||||
.toast-leave-active { transition: all 0.15s ease; }
|
||||
.toast-enter-from { opacity: 0; transform: translateY(8px) scale(0.97); }
|
||||
.toast-leave-to { opacity: 0; transform: translateY(4px) scale(0.97); }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -1,25 +1,54 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: 'success' | 'error' | 'info'
|
||||
id : number
|
||||
message : string
|
||||
type : 'success' | 'error' | 'info'
|
||||
count : number
|
||||
timer : ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([])
|
||||
let nextId = 0
|
||||
const DURATION = 3500
|
||||
const GROUP_WINDOW = 800 // ms — toasts du même type dans cette fenêtre sont groupés
|
||||
|
||||
function add(message: string, type: Toast['type'] = 'info') {
|
||||
const now = Date.now()
|
||||
|
||||
// Cherche un toast récent du même type à grouper
|
||||
const existing = toasts.value.find(t => t.type === type)
|
||||
|
||||
if (existing) {
|
||||
// Grouper — incrémenter le count et mettre à jour le message
|
||||
clearTimeout(existing.timer)
|
||||
existing.count++
|
||||
existing.message = buildGroupMessage(type, existing.count)
|
||||
existing.timer = setTimeout(() => remove(existing.id), DURATION)
|
||||
return
|
||||
}
|
||||
|
||||
// Nouveau toast
|
||||
const id = nextId++
|
||||
const timer = setTimeout(() => remove(id), DURATION)
|
||||
|
||||
toasts.value.push({ id, message, type, count: 1, timer })
|
||||
}
|
||||
|
||||
function buildGroupMessage(type: Toast['type'], count: number): string {
|
||||
if (type === 'success') return `${count} opérations réussies`
|
||||
if (type === 'error') return `${count} erreurs`
|
||||
return `${count} notifications`
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
const i = toasts.value.findIndex(t => t.id === id)
|
||||
if (i !== -1) {
|
||||
clearTimeout(toasts.value[i].timer)
|
||||
toasts.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
function add(message: string, type: Toast['type'] = 'info', duration = 3000) {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, message, type })
|
||||
setTimeout(() => remove(id), duration)
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
const idx = toasts.value.findIndex(t => t.id === id)
|
||||
if (idx !== -1) toasts.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
return { toasts, add, remove }
|
||||
}
|
||||
@@ -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
@@ -1,42 +1,98 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppLayout from '@/layouts/AppLayout.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
// ─── Page publique hors layout ───────────────────────────
|
||||
{
|
||||
path: '/auth',
|
||||
component: () => import('@/views/AuthView.vue'),
|
||||
meta: { public: true }
|
||||
meta: { public: true },
|
||||
},
|
||||
|
||||
// ─── App principale avec layout sidebar ──────────────────
|
||||
{
|
||||
path: '/',
|
||||
name: 'catalog',
|
||||
component: () => import('@/views/CatalogView.vue')
|
||||
component: AppLayout,
|
||||
children: [
|
||||
// Redirections des anciennes URLs
|
||||
{ path: '', redirect: '/series' },
|
||||
{ path: 'downloads', redirect: '/activity' },
|
||||
|
||||
// Dashboard
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/DashboardView.vue'),
|
||||
},
|
||||
|
||||
// Médiathèque
|
||||
{
|
||||
path: 'series',
|
||||
name: 'series',
|
||||
component: () => import('@/views/SeriesView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'series/:id',
|
||||
name: 'serie-detail',
|
||||
component: () => import('@/views/SerieView.vue'),
|
||||
},
|
||||
|
||||
// Activité (ex-downloads)
|
||||
{
|
||||
path: 'activity',
|
||||
name: 'activity',
|
||||
component: () => import('@/views/DownloadsView.vue'),
|
||||
},
|
||||
|
||||
// Paramètres — layout imbriqué avec sous-routes
|
||||
{
|
||||
path: 'settings',
|
||||
component: () => import('@/views/settings/SettingsLayout.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/settings/download-client' },
|
||||
{
|
||||
path: 'download-client',
|
||||
name: 'settings-download',
|
||||
component: () => import('@/views/settings/DownloadClientView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'media-management',
|
||||
name: 'settings-media',
|
||||
component: () => import('@/views/settings/MediaManagementView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'catalogue',
|
||||
name: 'settings-catalogue',
|
||||
component: () => import('@/views/settings/CatalogueView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'settings-logs',
|
||||
component: () => import('@/views/settings/LogsView.vue'), // ← nouveau chemin
|
||||
},
|
||||
{
|
||||
path: 'advanced',
|
||||
name: 'settings-advanced',
|
||||
component: () => import('@/views/settings/AdvancedView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/series/:id',
|
||||
component: () => import('@/views/SerieView.vue')
|
||||
},
|
||||
{
|
||||
path: '/downloads',
|
||||
component: () => import('@/views/DownloadsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('@/views/SettingsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
component: () => import('@/views/LogsView.vue')
|
||||
}
|
||||
],
|
||||
scrollBehavior(to, _from, savedPosition) {
|
||||
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) return savedPosition
|
||||
return { top: 0 }
|
||||
}
|
||||
// Retour depuis le détail vers la liste — ne pas scroller en haut
|
||||
if (to.name === 'series' && from.name === 'serie-detail') return false
|
||||
return { el: '#main-scroll', top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Guard auth (inchangé) ────────────────────────────────────
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
@@ -49,7 +105,7 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
|
||||
if (to.path === '/auth' && auth.loggedIn) {
|
||||
return '/'
|
||||
return '/series'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
@@ -1,69 +1,49 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#080c10] flex items-center justify-center px-4">
|
||||
<div class="min-h-screen bg-shell flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex items-baseline gap-1 mb-8 justify-center">
|
||||
<span class="font-sans text-3xl font-black text-white tracking-tight">Fank</span>
|
||||
<span class="font-sans text-3xl font-black text-[#e8513a] tracking-tight">Arr</span>
|
||||
<div class="flex items-center justify-center gap-2.5 mb-8">
|
||||
<svg class="w-8 h-8" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
|
||||
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
|
||||
</svg>
|
||||
<span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-8">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">
|
||||
{{ isSetup ? 'PREMIÈRE CONNEXION' : 'CONNEXION' }}
|
||||
<div class="settings-card p-8 flex flex-col gap-5">
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-1">
|
||||
{{ isSetup ? 'Première connexion' : 'Connexion' }}
|
||||
</p>
|
||||
<h1 class="text-lg font-semibold text-primary">
|
||||
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
|
||||
</h1>
|
||||
</div>
|
||||
<h1 class="text-lg font-black text-white font-sans mb-6">
|
||||
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
|
||||
</h1>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">IDENTIFIANT</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<label class="settings-label mb-1.5">Identifiant</label>
|
||||
<input v-model="username" type="text" placeholder="admin" class="settings-input" @keyup.enter="submit" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">MOT DE PASSE</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<label class="settings-label mb-1.5">Mot de passe</label>
|
||||
<input v-model="password" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
|
||||
</div>
|
||||
|
||||
<div v-if="isSetup">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">CONFIRMER</label>
|
||||
<input
|
||||
v-model="confirm"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<label class="settings-label mb-1.5">Confirmer</label>
|
||||
<input v-model="confirm" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="text-[#e8513a] text-xs">{{ error }}</div>
|
||||
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
@click="submit"
|
||||
:disabled="submitting"
|
||||
class="w-full bg-[#e8513a] text-white py-2.5 text-xs tracking-widest font-bold hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer mt-2"
|
||||
>
|
||||
{{ submitting ? '...' : isSetup ? 'CRÉER LE COMPTE' : 'SE CONNECTER' }}
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full py-2.5 mt-1">
|
||||
{{ submitting ? '...' : isSetup ? 'Créer le compte' : 'Se connecter' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,42 +54,28 @@ import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const isSetup = computed(() => !auth.setup)
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
|
||||
if (!username.value || !password.value) {
|
||||
error.value = 'Tous les champs sont requis'
|
||||
return
|
||||
}
|
||||
|
||||
if (isSetup.value && password.value !== confirm.value) {
|
||||
error.value = 'Les mots de passe ne correspondent pas'
|
||||
return
|
||||
}
|
||||
if (!username.value || !password.value) { error.value = 'Tous les champs sont requis'; return }
|
||||
if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas'; return }
|
||||
|
||||
submitting.value = true
|
||||
|
||||
const err = isSetup.value
|
||||
? await auth.setupAccount(username.value, password.value)
|
||||
: await auth.login(username.value, password.value)
|
||||
|
||||
submitting.value = false
|
||||
|
||||
if (err) {
|
||||
error.value = err
|
||||
return
|
||||
}
|
||||
|
||||
if (err) { error.value = err; return }
|
||||
await router.push('/')
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+291
-181
@@ -1,171 +1,241 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="$router.push('/')"
|
||||
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
← CATALOGUE
|
||||
</button>
|
||||
<h1 class="text-xl font-black text-white font-sans">Téléchargements</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="importAll" :disabled="importingAll"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-green-500 hover:text-green-500 transition-colors cursor-pointer disabled:opacity-50">
|
||||
{{ importingAll ? '...' : 'IMPORTER TOUT' }}
|
||||
</button>
|
||||
<div class="flex items-center gap-2 text-[10px] text-[#5a7a94] tracking-widest">
|
||||
<div class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-[#1e2d3d]'"/>
|
||||
{{ polling ? 'EN DIRECT' : 'PAUSE' }}
|
||||
</div>
|
||||
<button @click="togglePolling"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
{{ polling ? 'PAUSE' : 'REPRENDRE' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="px-8 py-8 max-w-4xl">
|
||||
|
||||
<!-- Résumé -->
|
||||
<div class="grid grid-cols-4 gap-3 mb-6">
|
||||
<div v-for="stat in stats" :key="stat.label" class="bg-[#0d1219] border border-[#1e2d3d] p-4">
|
||||
<div class="text-[10px] text-[#5a7a94] tracking-widest mb-1">{{ stat.label }}</div>
|
||||
<div class="text-lg font-bold text-white">{{ stat.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglets -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex gap-0 border border-[#1e2d3d]">
|
||||
<button @click="activeTab = 'active'"
|
||||
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer"
|
||||
:class="activeTab === 'active' ? 'bg-[#e8513a] text-white' : 'text-[#5a7a94] hover:text-white'">
|
||||
EN COURS
|
||||
<span v-if="activeTorrents.length > 0" class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
|
||||
{{ activeTorrents.length }}
|
||||
</span>
|
||||
</button>
|
||||
<button @click="activeTab = 'done'"
|
||||
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer border-l border-[#1e2d3d]"
|
||||
:class="activeTab === 'done' ? 'bg-[#e8513a] text-white' : 'text-[#5a7a94] hover:text-white'">
|
||||
TERMINÉS
|
||||
<span v-if="doneTorrents.length > 0" class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
|
||||
{{ doneTorrents.length }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'done'" class="flex items-center gap-2">
|
||||
<button @click="hideImported = !hideImported"
|
||||
class="relative shrink-0 w-8 h-4 rounded-full transition-colors cursor-pointer"
|
||||
:class="hideImported ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
|
||||
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform duration-200"
|
||||
:class="hideImported ? 'translate-x-4' : 'translate-x-0'" />
|
||||
</button>
|
||||
<span class="text-[10px] text-[#5a7a94] tracking-widest">MASQUER IMPORTÉS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pas de clients -->
|
||||
<div v-if="noClients" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
||||
<p class="text-sm">Aucun client torrent configuré</p>
|
||||
<router-link to="/settings" class="text-[10px] tracking-widest text-[#e8513a] hover:opacity-75 transition-opacity">
|
||||
CONFIGURER →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
||||
<div class="w-5 h-5 border-2 border-white/10 border-t-[#e8513a] rounded-full animate-spin"/>
|
||||
<span class="text-sm">Chargement...</span>
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="!loading && visibleTorrents.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
||||
<p class="text-sm">
|
||||
{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}
|
||||
</p>
|
||||
<p class="text-[11px]">
|
||||
{{ activeTab === 'active'
|
||||
? 'Les torrents avec la catégorie « fankai » apparaîtront ici'
|
||||
: hideImported ? 'Tous les torrents terminés sont importés' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Liste torrents -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-for="t in visibleTorrents" :key="t.hash" class="bg-[#0d1219] border border-[#1e2d3d] p-4">
|
||||
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-white font-medium truncate">{{ t.name }}</p>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<span class="text-[10px] tracking-widest px-2 py-0.5 border" :class="stateBadge(t.state).class">
|
||||
{{ stateBadge(t.state).label }}
|
||||
</span>
|
||||
<span class="text-[10px] text-[#5a7a94]">{{ t.client_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-bold text-white">{{ t.progress }}%</p>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-lg font-semibold text-primary">Activité</h1>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full transition-colors"
|
||||
:class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'"
|
||||
/>
|
||||
<span class="text-xs text-muted">{{ polling ? 'En direct' : 'Pause' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="importAll" :disabled="importingAll" class="btn-primary">
|
||||
{{ importingAll ? '...' : 'Importer tout' }}
|
||||
</button>
|
||||
<button @click="togglePolling" class="btn-secondary">
|
||||
{{ polling ? 'Pause' : 'Reprendre' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-1 bg-[#1e2d3d] rounded-full overflow-hidden mb-2">
|
||||
<div class="h-full rounded-full transition-all duration-500"
|
||||
:class="t.state === 'seeding' ? 'bg-green-500' :
|
||||
t.state === 'error' ? 'bg-red-500' :
|
||||
t.state === 'paused' ? 'bg-[#5a7a94]' : 'bg-[#e8513a]'"
|
||||
:style="{ width: `${t.progress}%` }"/>
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-4 gap-3 mb-6">
|
||||
<div v-for="stat in stats" :key="stat.label" class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">{{ stat.label }}</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ stat.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar : recherche + tri -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Rechercher..."
|
||||
class="settings-input pl-9 w-full py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-[10px] text-[#5a7a94]">
|
||||
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
|
||||
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
|
||||
<span v-if="t.state === 'seeding'" class="text-green-500">✓ Complété</span>
|
||||
</div>
|
||||
<div class="relative shrink-0" ref="sortRef">
|
||||
<button
|
||||
@click="sortOpen = !sortOpen"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
|
||||
:class="activeSort !== 'none' ? 'border-accent text-accent' : ''"
|
||||
>
|
||||
<ArrowUpDown :size="14" />
|
||||
<span class="hidden md:inline text-xs">Trier</span>
|
||||
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
</button>
|
||||
|
||||
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
|
||||
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
|
||||
<span class="text-[9px] tracking-widest px-2 py-0.5 border border-red-500/40 text-red-400 cursor-default">
|
||||
{{ t.errorFiles.length }} ERREUR{{ t.errorFiles.length > 1 ? 'S' : '' }}
|
||||
</span>
|
||||
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
|
||||
<div class="bg-[#0d1219] border border-red-500/30 p-3 text-[10px] shadow-xl">
|
||||
<div class="text-red-400 tracking-widest mb-2">FICHIERS EN ERREUR</div>
|
||||
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
|
||||
<p class="text-white truncate">{{ e.file }}</p>
|
||||
<p class="text-[#5a7a94]">{{ e.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-[9px] tracking-widest px-2 py-0.5 border"
|
||||
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
|
||||
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
|
||||
'border-red-500/40 text-red-400'">
|
||||
{{ t.organizeState === 'done' ? `IMPORTÉ (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
t.organizeState === 'partial' ? `EN COURS (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
'NON IMPORTÉ' }}
|
||||
</span>
|
||||
|
||||
<button v-if="t.organizeState !== 'done'"
|
||||
@click="importTorrent(t)"
|
||||
:disabled="importing[t.hash]"
|
||||
class="text-[9px] tracking-widest px-3 py-1 border transition-colors cursor-pointer border-[#1e2d3d] text-[#5a7a94] hover:border-[#e8513a] hover:text-[#e8513a] disabled:opacity-50">
|
||||
{{ importing[t.hash] ? '...' : 'IMPORTER' }}
|
||||
<div
|
||||
v-if="sortOpen"
|
||||
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
@click="setSort(s.value)"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
|
||||
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
{{ s.label }}
|
||||
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
|
||||
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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">{{ t.progress }}%</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' ? 'bg-green-500' :
|
||||
t.state === 'error' ? 'bg-red-500' :
|
||||
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
|
||||
:style="{ width: `${t.progress}%` }"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div v-if="t.state === 'seeding'" 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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Search, ArrowUpDown } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
@@ -178,6 +248,30 @@ const importing = ref<Record<string, boolean>>({})
|
||||
const importingAll = ref(false)
|
||||
const activeTab = ref<'active' | 'done'>('active')
|
||||
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
|
||||
|
||||
@@ -190,16 +284,40 @@ const doneTorrents = computed(() =>
|
||||
)
|
||||
|
||||
const visibleTorrents = computed(() => {
|
||||
if (activeTab.value === 'active') return activeTorrents.value
|
||||
if (hideImported.value) return doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||
return doneTorrents.value
|
||||
let list = activeTab.value === 'active'
|
||||
? activeTorrents.value
|
||||
: 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(() => [
|
||||
{ label: 'EN COURS', value: torrents.value.filter(t => t.state === 'downloading').length },
|
||||
{ label: 'COMPLÉTÉS', value: torrents.value.filter(t => t.state === 'seeding').length },
|
||||
{ label: 'EN PAUSE', value: torrents.value.filter(t => t.state === 'paused').length },
|
||||
{ label: 'ERREURS', value: torrents.value.filter(t => t.state === 'error').length },
|
||||
{ label: 'En cours', value: torrents.value.filter(t => t.state === 'downloading').length },
|
||||
{ label: 'Complétés', value: torrents.value.filter(t => t.state === 'seeding').length },
|
||||
{ label: 'En pause', value: torrents.value.filter(t => t.state === 'paused').length },
|
||||
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
|
||||
])
|
||||
|
||||
async function fetchTorrents() {
|
||||
@@ -226,8 +344,7 @@ function stopPolling() {
|
||||
|
||||
function togglePolling() {
|
||||
polling.value = !polling.value
|
||||
if (polling.value) startPolling()
|
||||
else stopPolling()
|
||||
polling.value ? startPolling() : stopPolling()
|
||||
}
|
||||
|
||||
onMounted(startPolling)
|
||||
@@ -240,7 +357,7 @@ async function importTorrent(torrent: any) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name })
|
||||
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }),
|
||||
})
|
||||
if (res.ok) {
|
||||
toast(`${torrent.name} importé ✓`, 'success')
|
||||
@@ -258,14 +375,10 @@ async function importTorrent(torrent: any) {
|
||||
|
||||
async function importAll() {
|
||||
const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||
if (toImport.length === 0) {
|
||||
toast('Tous les torrents sont déjà importés', 'success')
|
||||
return
|
||||
}
|
||||
if (toImport.length === 0) { toast('Tous les torrents sont déjà importés', 'success'); return }
|
||||
|
||||
importingAll.value = true
|
||||
let done = 0
|
||||
let errors = 0
|
||||
let done = 0, errors = 0
|
||||
|
||||
for (const t of toImport) {
|
||||
try {
|
||||
@@ -273,30 +386,27 @@ async function importAll() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name })
|
||||
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name }),
|
||||
})
|
||||
if (res.ok) done++
|
||||
else errors++
|
||||
} catch {
|
||||
errors++
|
||||
}
|
||||
res.ok ? done++ : errors++
|
||||
} catch { errors++ }
|
||||
}
|
||||
|
||||
importingAll.value = false
|
||||
await fetchTorrents()
|
||||
|
||||
if (errors === 0) toast(`${done} torrent(s) importé(s) ✓`, 'success')
|
||||
else toast(`${done} OK, ${errors} erreur(s)`, 'error')
|
||||
errors === 0
|
||||
? toast(`${done} torrent(s) importé(s) ✓`, 'success')
|
||||
: toast(`${done} OK, ${errors} erreur(s)`, 'error')
|
||||
}
|
||||
|
||||
function stateBadge(state: string) {
|
||||
const map: Record<string, { label: string; class: string }> = {
|
||||
downloading: { label: 'TÉLÉCHARGEMENT', class: 'border-[#e8513a]/40 text-[#e8513a]' },
|
||||
seeding : { label: 'COMPLÉTÉ', class: 'border-green-500/40 text-green-500' },
|
||||
paused : { label: 'EN PAUSE', class: 'border-[#5a7a94]/40 text-[#5a7a94]' },
|
||||
checking : { label: 'VÉRIFICATION', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
error : { label: 'ERREUR', class: 'border-red-500/40 text-red-500' },
|
||||
unknown : { label: 'INCONNU', class: 'border-[#1e2d3d] text-[#5a7a94]' },
|
||||
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
|
||||
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
|
||||
paused : { label: 'En pause', class: 'border-border text-muted' },
|
||||
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
|
||||
unknown : { label: 'Inconnu', class: 'border-border text-muted' },
|
||||
}
|
||||
return map[state] ?? map.unknown
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+250
-255
@@ -1,66 +1,68 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
|
||||
<!-- Back -->
|
||||
<div class="max-w-7xl mx-auto px-6 pt-6">
|
||||
<router-link to="/" class="inline-flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-100 transition">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
Catalogue
|
||||
</router-link>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
|
||||
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
|
||||
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
|
||||
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
|
||||
<p class="text-sm">Chargement…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
|
||||
<p class="text-sm">{{ store.error }}</p>
|
||||
<button class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm" @click="load">Réessayer</button>
|
||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
|
||||
<p class="text-sm text-red-400">{{ store.error }}</p>
|
||||
<button class="btn-primary" @click="load">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- Hero -->
|
||||
|
||||
<!-- Hero avec fanart -->
|
||||
<div class="relative">
|
||||
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none">
|
||||
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20"
|
||||
@error="e => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'" />
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
|
||||
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-64 overflow-hidden pointer-events-none">
|
||||
<img
|
||||
:src="data.serie.fanart_image"
|
||||
class="w-full h-full object-cover opacity-15"
|
||||
@error="(e) => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-main" />
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-7xl mx-auto px-6 pt-8 pb-10 flex gap-8">
|
||||
<div class="relative px-4 md:px-8 pt-6 pb-10 flex gap-6 min-h-[200px]">
|
||||
<!-- Poster -->
|
||||
<div class="shrink-0 w-36 rounded-xl overflow-hidden border border-white/10 shadow-2xl hidden sm:block">
|
||||
<div class="shrink-0 w-32 rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
|
||||
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full aspect-[2/3] bg-zinc-900 flex items-center justify-center text-zinc-700">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/>
|
||||
</svg>
|
||||
<div v-else class="w-full aspect-[2/3] bg-card flex items-center justify-center text-muted">
|
||||
<Tv :size="28" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<!-- Infos -->
|
||||
<div class="flex flex-col gap-3 justify-end min-w-0">
|
||||
<h1 class="text-3xl font-bold tracking-tight">{{ data.serie.title }}</h1>
|
||||
<!-- Retour -->
|
||||
<RouterLink to="/series" class="inline-flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-fit">
|
||||
<ArrowLeft :size="13" />
|
||||
Séries
|
||||
</RouterLink>
|
||||
|
||||
<h1 class="text-2xl font-bold text-primary 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.year" class="text-sm text-muted">{{ data.serie.year }}</span>
|
||||
<span
|
||||
v-if="data.serie.status"
|
||||
class="text-xs px-2 py-0.5 rounded-full font-medium border"
|
||||
class="text-xs px-2 py-0.5 rounded-full border"
|
||||
:class="data.serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/15 text-green-400 border-green-500/20'
|
||||
: 'bg-white/5 text-zinc-500 border-white/10'"
|
||||
>{{ data.serie.status }}</span>
|
||||
: 'bg-hover text-muted border-border'"
|
||||
>
|
||||
{{ data.serie.status }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="data.serie.plot" class="text-sm text-zinc-400 leading-relaxed max-w-2xl line-clamp-3">
|
||||
|
||||
<p v-if="data.serie.plot" class="text-sm text-secondary leading-relaxed max-w-2xl line-clamp-3">
|
||||
{{ data.serie.plot }}
|
||||
</p>
|
||||
|
||||
<!-- Boutons hero : intégrale, pack_saison promu, pack_episodes -->
|
||||
<!-- Boutons intégrale -->
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
<button
|
||||
v-for="(t, i) in data.torrents_integrale"
|
||||
@@ -69,271 +71,233 @@
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
|
||||
:class="[
|
||||
t.label === 'Intégrale'
|
||||
? 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
: 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20',
|
||||
(downloading.has(`integrale-${i}`) || downloaded.has(`integrale-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'bg-accent-muted text-accent border border-accent/20 hover:bg-accent/20',
|
||||
(isDownloading(`integrale-${i}`) || isDownloaded(`integrale-${i}`) || isAlreadyQueued(t))
|
||||
? 'opacity-50 cursor-not-allowed' : ''
|
||||
]"
|
||||
:disabled="downloading.has(`integrale-${i}`) || downloaded.has(`integrale-${i}`) || isAlreadyQueued(t)"
|
||||
:disabled="isDownloading(`integrale-${i}`) || isDownloaded(`integrale-${i}`) || isAlreadyQueued(t)"
|
||||
@click="download(`integrale-${i}`, t.torrent_url, t.magnet)"
|
||||
>
|
||||
<DownloadIcon :spinning="downloading.has(`integrale-${i}`)" />
|
||||
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : downloaded.has(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
|
||||
<EpStateIcon :state="epBtnState(`integrale-${i}`, t)" />
|
||||
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : isDownloaded(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Barre progression intégrale -->
|
||||
|
||||
<!-- Progression intégrale -->
|
||||
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
|
||||
<template v-if="torrentProgress(extractHash(t))">
|
||||
<span v-if="torrentProgress(extractHash(t))!.progress === 100"
|
||||
class="mt-2 inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||
✓ Téléchargé
|
||||
</span>
|
||||
<div v-else class="mt-2 w-full max-w-sm">
|
||||
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
|
||||
<span>Téléchargement</span>
|
||||
<span>{{ torrentProgress(extractHash(t))!.progress }}%</span>
|
||||
</div>
|
||||
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${torrentProgress(extractHash(t))!.progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ProgressBar :progress="torrentProgress(extractHash(t))?.progress" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seasons -->
|
||||
<div class="max-w-7xl mx-auto px-6 pb-16 flex flex-col gap-6">
|
||||
<!-- Saisons -->
|
||||
<div class="px-4 md:px-8 pb-16 flex flex-col gap-4">
|
||||
<div
|
||||
v-for="season in data.seasons"
|
||||
:key="season.id"
|
||||
class="bg-zinc-900/50 border border-white/5 rounded-2xl overflow-hidden"
|
||||
class="bg-card border border-border rounded-xl overflow-hidden"
|
||||
>
|
||||
<!-- Season header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-white/5">
|
||||
<!-- Header saison -->
|
||||
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="text-zinc-500 hover:text-zinc-100 transition" @click="toggleSeason(season.id)">
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||
<button class="text-muted hover:text-primary transition-colors" @click="toggleSeason(season.id)">
|
||||
<ChevronUp
|
||||
:size="15"
|
||||
class="transition-transform duration-200"
|
||||
:class="collapsedSeasons.has(season.id) ? '' : 'rotate-180'"
|
||||
>
|
||||
<path d="M18 15l-6-6-6 6"/>
|
||||
</svg>
|
||||
:class="collapsedSeasons.has(season.id) ? 'rotate-180' : ''"
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="font-semibold text-sm">
|
||||
<h2 class="text-sm font-semibold text-primary">
|
||||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-zinc-500 font-normal ml-1">
|
||||
<span
|
||||
v-if="season.title && season.title !== `Saison ${season.season_number}`"
|
||||
class="text-muted font-normal ml-1"
|
||||
>
|
||||
— {{ season.title }}
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-xs text-zinc-600 mt-0.5">
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
|
||||
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
|
||||
<span v-if="season.organized_state !== 'none'"
|
||||
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
:class="season.organized_state === 'complete'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-yellow-500/10 text-yellow-500'">
|
||||
<span
|
||||
v-if="season.organized_state !== 'none'"
|
||||
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px]"
|
||||
:class="season.organized_state === 'complete'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-yellow-500/10 text-yellow-500'"
|
||||
>
|
||||
{{ season.organized_state === 'complete'
|
||||
? `✓ ${season.organized_count} organisé${season.organized_count > 1 ? 's' : ''}`
|
||||
: `${season.organized_count}/${seasonAvailableCount(season)} organisés` }}
|
||||
? `✓ ${season.organized_count} importé${season.organized_count > 1 ? 's' : ''}`
|
||||
: `${season.organized_count}/${seasonAvailableCount(season)} importés` }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton pack_saison sur le header -->
|
||||
<!-- Bouton pack saison -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
||||
:class="[
|
||||
season.torrent && !downloaded.has(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
|
||||
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
|
||||
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
|
||||
]"
|
||||
:disabled="!season.torrent || downloading.has(`season-${season.id}`) || downloaded.has(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
|
||||
:class="season.torrent && !isDownloaded(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
|
||||
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
|
||||
: 'text-muted border-border cursor-not-allowed opacity-50'"
|
||||
:disabled="!season.torrent || isDownloading(`season-${season.id}`) || isDownloaded(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
|
||||
@click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
|
||||
>
|
||||
<DownloadIcon :spinning="downloading.has(`season-${season.id}`)" :size="12" />
|
||||
{{ season.organized_state === 'complete' ? 'Organisé ✓' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : downloaded.has(`season-${season.id}`) ? 'Envoyé ✓' : 'Saison entière' }}
|
||||
<EpStateIcon :state="seasonBtnState(season)" :size="12" />
|
||||
{{ season.organized_state === 'complete' ? 'Importé' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : isDownloaded(`season-${season.id}`) ? 'Envoyé' : 'Saison entière' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Barre progression saison -->
|
||||
<template v-if="torrentProgress(extractHash(season.torrent))">
|
||||
<div v-if="torrentProgress(extractHash(season.torrent))!.progress === 100"
|
||||
class="px-5 py-2 border-b border-white/5">
|
||||
<span class="inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||
✓ Téléchargé
|
||||
</span>
|
||||
<!-- Progression saison — dans le header, sous les contrôles -->
|
||||
<div
|
||||
v-if="torrentProgress(extractHash(season.torrent)) && torrentProgress(extractHash(season.torrent))!.progress < 100"
|
||||
class="px-5 pb-3 -mt-1"
|
||||
>
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-accent rounded-full transition-all duration-500"
|
||||
:style="{ width: `${torrentProgress(extractHash(season.torrent))!.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="px-5 py-2 border-b border-white/5">
|
||||
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
|
||||
<span>Téléchargement en cours</span>
|
||||
<span>{{ torrentProgress(extractHash(season.torrent))!.progress }}%</span>
|
||||
</div>
|
||||
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${torrentProgress(extractHash(season.torrent))!.progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p class="text-[11px] text-muted mt-1">{{ torrentProgress(extractHash(season.torrent))!.progress }}%</p>
|
||||
</div>
|
||||
|
||||
<!-- Episodes -->
|
||||
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-white/[0.04]">
|
||||
<!-- Épisodes -->
|
||||
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-border/50">
|
||||
<div
|
||||
v-for="ep in season.episodes"
|
||||
:key="ep.id"
|
||||
class="px-5 py-3 hover:bg-white/[0.02] transition"
|
||||
class="px-5 py-3 hover:bg-hover/50 transition-colors"
|
||||
:class="{ 'opacity-40': !ep.available && !ep.organized }"
|
||||
>
|
||||
<!-- Ligne principale -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="shrink-0 w-24 aspect-video rounded-lg overflow-hidden bg-zinc-800 hidden sm:block">
|
||||
<div class="shrink-0 w-20 aspect-video rounded-md overflow-hidden bg-shell hidden sm:flex items-center justify-center text-muted text-xs font-mono">
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700 text-xs font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}
|
||||
</div>
|
||||
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<!-- Infos épisode -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-zinc-600 shrink-0 font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
|
||||
<span class="text-xs text-muted shrink-0 font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
|
||||
</span>
|
||||
<span class="text-sm text-primary truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||
<!-- 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 class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-0.5">
|
||||
<span v-if="ep.aired" class="text-xs text-zinc-600">{{ formatDate(ep.aired) }}</span>
|
||||
<span v-if="ep.duration" class="text-xs text-zinc-600">{{ ep.duration }} min</span>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span v-if="ep.aired" class="text-xs text-muted">{{ formatDate(ep.aired) }}</span>
|
||||
<span v-if="ep.duration" class="text-xs text-muted">{{ formatDuration(ep.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badges + bouton download -->
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<!-- Bouton état unique -->
|
||||
<button
|
||||
class="shrink-0 w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
|
||||
:class="epBtnClass(ep)"
|
||||
:disabled="!ep.torrent || ep.organized || isAlreadyQueued(ep.torrent)"
|
||||
@click="ep.torrent && !ep.organized && !isAlreadyQueued(ep.torrent) && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
|
||||
>
|
||||
<EpStateIcon :state="epState(ep)" :size="14" />
|
||||
</button>
|
||||
|
||||
<!-- Badge Hors Fankai (torrent manuel) -->
|
||||
<span
|
||||
v-if="ep.torrent?.manual"
|
||||
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
|
||||
class="text-[10px] px-2 py-0.5 rounded-full border bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||||
>
|
||||
Hors Fankai
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Badge organisé -->
|
||||
<span v-if="ep.organized"
|
||||
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||
✓ organisé
|
||||
</span>
|
||||
|
||||
<!-- Badge téléchargé à 100% -->
|
||||
<span v-if="torrentProgress(extractHash(ep.torrent))?.progress === 100"
|
||||
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||
✓ téléchargé
|
||||
</span>
|
||||
|
||||
<!-- Badge dispo/indispo -->
|
||||
<span
|
||||
class="inline-block text-[10px] px-2 py-0.5 rounded-full border"
|
||||
:class="ep.available
|
||||
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
||||
: 'bg-white/5 text-zinc-600 border-white/8'"
|
||||
>
|
||||
{{ ep.available ? 'dispo' : 'indispo' }}
|
||||
</span>
|
||||
|
||||
<!-- Bouton download -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
||||
:class="[
|
||||
ep.torrent && !downloaded.has(`ep-${ep.id}`) && !isAlreadyQueued(ep.torrent) && !ep.organized
|
||||
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
|
||||
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
|
||||
]"
|
||||
:disabled="!ep.torrent || downloading.has(`ep-${ep.id}`) || downloaded.has(`ep-${ep.id}`) || isAlreadyQueued(ep.torrent) || ep.organized"
|
||||
@click="ep.torrent && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
|
||||
>
|
||||
<DownloadIcon :spinning="downloading.has(`ep-${ep.id}`)" :size="12" />
|
||||
{{ ep.organized ? '✓' : isAlreadyQueued(ep.torrent) ? '✓' : downloaded.has(`ep-${ep.id}`) ? '✓' : 'Ep.' }}
|
||||
</button>
|
||||
<!-- Barre progression épisode -->
|
||||
<div
|
||||
v-if="torrentProgress(extractHash(ep.torrent)) && torrentProgress(extractHash(ep.torrent))!.progress < 100"
|
||||
class="mt-1.5 sm:pl-[92px]"
|
||||
>
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-accent rounded-full transition-all duration-500"
|
||||
:style="{ width: `${torrentProgress(extractHash(ep.torrent))!.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre progression épisode (seulement si < 100%) -->
|
||||
<div v-if="torrentProgress(extractHash(ep.torrent)) && torrentProgress(extractHash(ep.torrent))!.progress < 100"
|
||||
class="mt-2 sm:pl-28">
|
||||
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
|
||||
<span>Téléchargement</span>
|
||||
<span>{{ torrentProgress(extractHash(ep.torrent))!.progress }}%</span>
|
||||
</div>
|
||||
<div class="h-0.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${torrentProgress(extractHash(ep.torrent))!.progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Toast -->
|
||||
<Transition name="toast">
|
||||
<div
|
||||
v-if="toast"
|
||||
class="fixed bottom-6 right-6 flex items-center gap-3 px-4 py-3 rounded-xl shadow-2xl text-sm font-medium z-50"
|
||||
:class="toast.type === 'success'
|
||||
? 'bg-zinc-800 border border-green-500/30 text-green-400'
|
||||
: 'bg-zinc-800 border border-red-500/30 text-red-400'"
|
||||
>
|
||||
<svg v-if="toast.type === 'success'" width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M20 6 9 17l-5-5"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
|
||||
</svg>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { ArrowLeft, Tv, ChevronUp, Download, Loader, Check, X } from 'lucide-vue-next'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Season } from '@/stores/series'
|
||||
|
||||
// ── Composant icône download ──────────────────────────────────
|
||||
const DownloadIcon = defineComponent({
|
||||
props: { spinning: Boolean, size: { type: Number, default: 14 } },
|
||||
// ── Icône d'état du bouton ────────────────────────────────────
|
||||
// states: 'idle' | 'loading' | 'done' | 'unavailable'
|
||||
const EpStateIcon = defineComponent({
|
||||
props: {
|
||||
state: { type: String, default: 'idle' },
|
||||
size : { type: Number, default: 14 },
|
||||
},
|
||||
setup(props) {
|
||||
return () => props.spinning
|
||||
? h('div', { class: `w-${props.size === 12 ? '3' : '3.5'} h-${props.size === 12 ? '3' : '3.5'} border border-current/30 border-t-current rounded-full animate-spin` })
|
||||
: h('svg', { width: props.size, height: props.size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
|
||||
h('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3' })
|
||||
])
|
||||
}
|
||||
return () => {
|
||||
if (props.state === 'loading') return h(Loader, { size: props.size, class: 'animate-spin' })
|
||||
if (props.state === 'done') return h(Check, { size: props.size })
|
||||
if (props.state === 'unavailable') return h(X, { size: props.size })
|
||||
return h(Download, { size: props.size })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────
|
||||
const route = useRoute()
|
||||
const store = useSeriesStore()
|
||||
// ── Barre de progression ──────────────────────────────────────
|
||||
const ProgressBar = defineComponent({
|
||||
props: { progress: { type: Number, default: null } },
|
||||
setup(props) {
|
||||
return () => {
|
||||
if (props.progress == null || props.progress >= 100) return null
|
||||
return h('div', { class: 'h-0.5 bg-border rounded-full overflow-hidden' }, [
|
||||
h('div', {
|
||||
class: 'h-full bg-accent rounded-full transition-all duration-500',
|
||||
style: { width: `${props.progress}%` },
|
||||
}),
|
||||
])
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const store = useSeriesStore()
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const collapsedSeasons = ref<Set<number>>(new Set())
|
||||
const downloading = ref<Set<string>>(new Set())
|
||||
const downloaded = ref<Set<string>>(new Set())
|
||||
const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null)
|
||||
const downloading = ref<string[]>([])
|
||||
const downloaded = ref<string[]>([])
|
||||
|
||||
// ── Progression downloads ──────────────────────────────────────
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string; name: string }
|
||||
function isDownloading(key: string) { return downloading.value.includes(key) }
|
||||
function isDownloaded(key: string) { return downloaded.value.includes(key) }
|
||||
function addDownloading(key: string) { if (!downloading.value.includes(key)) downloading.value.push(key) }
|
||||
function removeDownloading(key: string) { downloading.value = downloading.value.filter(k => k !== key) }
|
||||
function addDownloaded(key: string) { if (!downloaded.value.includes(key)) downloaded.value.push(key) }
|
||||
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string }
|
||||
const activeTorrents = ref<ActiveTorrent[]>([])
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const data = computed(() => store.currentSerie)
|
||||
|
||||
// ── Polling downloads ─────────────────────────────────────────
|
||||
async function fetchActiveDownloads() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
@@ -343,7 +307,6 @@ async function fetchActiveDownloads() {
|
||||
hash : t.hash,
|
||||
progress: t.progress ?? 0,
|
||||
state : t.state,
|
||||
name : t.name,
|
||||
}))
|
||||
} catch {}
|
||||
}
|
||||
@@ -353,18 +316,58 @@ function torrentProgress(hash: string | null | undefined): ActiveTorrent | null
|
||||
return activeTorrents.value.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
|
||||
}
|
||||
|
||||
function extractHash(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): string | null {
|
||||
if (!torrent) return null
|
||||
const magnet = torrent.magnet ?? ''
|
||||
const m = magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
function extractHash(torrent: { magnet?: string | null } | null | undefined): string | null {
|
||||
if (!torrent?.magnet) return null
|
||||
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
return m ? m[1].toLowerCase() : null
|
||||
}
|
||||
|
||||
// ── Data ───────────────────────────────────────────────────────
|
||||
const data = computed(() => store.currentSerie)
|
||||
function isAlreadyQueued(torrent: { magnet?: string | null } | null | undefined): boolean {
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||||
}
|
||||
|
||||
// ── États boutons ─────────────────────────────────────────────
|
||||
type BtnState = 'idle' | 'loading' | 'done' | 'unavailable'
|
||||
|
||||
function epState(ep: any): BtnState {
|
||||
if (ep.organized) return 'done'
|
||||
if (isDownloaded(`ep-${ep.id}`)) return 'done'
|
||||
if (isAlreadyQueued(ep.torrent)) return 'done'
|
||||
const prog = torrentProgress(extractHash(ep.torrent))
|
||||
if (prog && prog.progress < 100) return 'loading'
|
||||
if (!ep.torrent || !ep.available) return 'unavailable'
|
||||
if (isDownloading(`ep-${ep.id}`)) return 'loading'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function epBtnState(key: string, torrent: any): BtnState {
|
||||
if (isDownloaded(key) || isAlreadyQueued(torrent)) return 'done'
|
||||
if (isDownloading(key)) return 'loading'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function seasonBtnState(season: any): BtnState {
|
||||
if (season.organized_state === 'complete') return 'done'
|
||||
if (isDownloaded(`season-${season.id}`)) return 'done'
|
||||
if (isAlreadyQueued(season.torrent)) return 'done'
|
||||
if (isDownloading(`season-${season.id}`)) return 'loading'
|
||||
if (!season.torrent) return 'unavailable'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function epBtnClass(ep: any): string {
|
||||
const state = epState(ep)
|
||||
if (state === 'done') return 'border-green-500/30 text-green-500 bg-green-500/10 cursor-default'
|
||||
if (state === 'loading') return 'border-accent/30 text-accent bg-accent-muted cursor-default'
|
||||
if (state === 'unavailable') return 'border-border text-muted opacity-40 cursor-not-allowed'
|
||||
return 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
function seasonAvailableCount(season: Season) {
|
||||
return season.episodes.filter(e => e.available).length
|
||||
return season.episodes.filter((e: any) => e.available).length
|
||||
}
|
||||
|
||||
function toggleSeason(id: number) {
|
||||
@@ -372,33 +375,17 @@ function toggleSeason(id: number) {
|
||||
else collapsedSeasons.value.add(id)
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function showToast(type: 'success' | 'error', message: string) {
|
||||
toast.value = { type, message }
|
||||
setTimeout(() => { toast.value = null }, 3500)
|
||||
}
|
||||
|
||||
function isAlreadyQueued(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): boolean {
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||||
}
|
||||
|
||||
async function download(key: string, torrent_url: string | null, magnet: string | null) {
|
||||
if (downloading.value.has(key) || downloaded.value.has(key)) return
|
||||
downloading.value = new Set([...downloading.value, key])
|
||||
if (isDownloading(key) || isDownloaded(key)) return
|
||||
addDownloading(key)
|
||||
const result = await store.download(torrent_url, magnet)
|
||||
downloading.value = new Set([...downloading.value].filter(k => k !== key))
|
||||
removeDownloading(key)
|
||||
if (result.success) {
|
||||
downloaded.value = new Set([...downloaded.value, key])
|
||||
showToast('success', 'Téléchargement lancé')
|
||||
addDownloaded(key)
|
||||
toast('Téléchargement lancé ✓', 'success')
|
||||
fetchActiveDownloads()
|
||||
} else {
|
||||
showToast('error', result.error ?? 'Erreur inconnue')
|
||||
toast(result.error ?? 'Erreur inconnue', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +393,19 @@ function load() {
|
||||
store.fetchSerieDetail(Number(route.params.id))
|
||||
}
|
||||
|
||||
function formatDate(d: string): string {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) return m > 0 ? `${h}h ${m}min` : `${h}h`
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
fetchActiveDownloads()
|
||||
@@ -415,9 +415,4 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active, .toast-leave-active { transition: all 0.25s ease; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(6px); }
|
||||
</style>
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,234 @@
|
||||
<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="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">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,
|
||||
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, 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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,195 @@
|
||||
<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="form.completePath ? 'text-primary' : 'text-muted'">
|
||||
{{ form.completePath || '/' }}
|
||||
</button>
|
||||
<p 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="form.mediaPath ? 'text-primary' : 'text-muted'">
|
||||
{{ form.mediaPath || '/' }}
|
||||
</button>
|
||||
<p class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</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, 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 { data: systemInfo } = await fetch('/api/system/info')
|
||||
|
||||
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
||||
open: false,
|
||||
field: 'completePath',
|
||||
currentPath: systemInfo.value?.defaultPath ?? '/',
|
||||
})
|
||||
|
||||
function openPicker(field: 'completePath' | 'mediaPath') {
|
||||
picker.value = { open: true, field, currentPath: form.value[field] || '/' }
|
||||
}
|
||||
|
||||
function onPickerSelect(path: string) {
|
||||
form.value[picker.value.field] = path
|
||||
picker.value.open = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [settingsRes, systemRes] = await Promise.all([
|
||||
fetch('/api/settings', { credentials: 'include' }),
|
||||
fetch('/api/system', { credentials: 'include' }),
|
||||
])
|
||||
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
|
||||
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
|
||||
})
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user