Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b39a5a8c4 | ||
|
|
50144c70dc | ||
|
|
482743bf3a | ||
|
|
f3a36af629 | ||
|
|
bc960ba585 | ||
|
|
e648764715 | ||
|
|
15fac6c472 | ||
|
|
61591020be | ||
|
|
f92b3c5fb9 | ||
|
|
a39a98615b | ||
|
|
2a3defdb34 | ||
|
|
733f8790ca | ||
|
|
4a9bbd9402 | ||
|
|
1562ca32f9 | ||
|
|
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 |
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,87 @@
|
||||
# Changelog
|
||||
|
||||
## [3.4.0](https://github.com/Masutayunikon/FanKarr/compare/v3.3.3...v3.4.0) (2026-04-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ajout d'un retour visuel si on a un path par defaut sur / ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
|
||||
* Ajout du support pour l'édition/mise à jour des clients torrent ([dda8a03](https://github.com/Masutayunikon/FanKarr/commit/dda8a03403b46027a23b4a2812ca3797a2628673))
|
||||
* Ajout du support pour l'édition/mise à jour des clients torrent ([a3e1c34](https://github.com/Masutayunikon/FanKarr/commit/a3e1c34e7547bab75c45ddd3eace0d211409ccd5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* le driver de Transmission n'est plus basé sur android ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
|
||||
* les torrent inconnue en états seront mis dans la catégorie terminée ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
|
||||
* typo ([18ec548](https://github.com/Masutayunikon/FanKarr/commit/18ec548d76c929afac11cd16d88e734480ee2755))
|
||||
* utilisation de nfo_filename au lieu de original_filename ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
|
||||
|
||||
## [3.3.3](https://github.com/Masutayunikon/FanKarr/compare/v3.3.2...v3.3.3) (2026-04-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* MediaManagementView.vue api call is now in mounted ([8c5b650](https://github.com/Masutayunikon/FanKarr/commit/8c5b650ca6eeac75f33e8c5423f6d4fd24f9871b))
|
||||
* path ([58ae88a](https://github.com/Masutayunikon/FanKarr/commit/58ae88a01c063318407134cd7351572220acd892))
|
||||
|
||||
## [3.3.2](https://github.com/Masutayunikon/FanKarr/compare/v3.3.1...v3.3.2) (2026-04-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use fetch instead of useFetch ([af550ec](https://github.com/Masutayunikon/FanKarr/commit/af550eccfbeeb116491e8ea4973eac493ed962d3))
|
||||
|
||||
## [3.3.1](https://github.com/Masutayunikon/FanKarr/compare/v3.3.0...v3.3.1) (2026-04-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add driver register for transmission ([7d2096e](https://github.com/Masutayunikon/FanKarr/commit/7d2096ec1972079a2e0e5bdfe01c4cc702e6b185))
|
||||
|
||||
## [3.3.0](https://github.com/Masutayunikon/FanKarr/compare/v3.2.1...v3.3.0) (2026-04-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add transmission client ([a2b88de](https://github.com/Masutayunikon/FanKarr/commit/a2b88de3a123aeb9c140e83605901dd5cdad041a))
|
||||
* set defaultPath for any system ([a2b88de](https://github.com/Masutayunikon/FanKarr/commit/a2b88de3a123aeb9c140e83605901dd5cdad041a))
|
||||
|
||||
## [3.2.1](https://github.com/Masutayunikon/FanKarr/compare/v3.2.0...v3.2.1) (2026-04-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bun target android ([a167a87](https://github.com/Masutayunikon/FanKarr/commit/a167a870188bdcd6b26644e304c2059ad5f05e96))
|
||||
|
||||
## [3.2.0](https://github.com/Masutayunikon/FanKarr/compare/v3.1.1...v3.2.0) (2026-04-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add build for arm64 ([96acc13](https://github.com/Masutayunikon/FanKarr/commit/96acc134698ca09842d57d906e4a28ab8e94ba56))
|
||||
* add developer settings on settings and dashboard debug ([96acc13](https://github.com/Masutayunikon/FanKarr/commit/96acc134698ca09842d57d906e4a28ab8e94ba56))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* android build name ([0cfe6eb](https://github.com/Masutayunikon/FanKarr/commit/0cfe6eb81055b841a67c08e02129709c0214d87d))
|
||||
|
||||
## [3.1.1](https://github.com/Masutayunikon/FanKarr/compare/v3.1.0...v3.1.1) (2026-03-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove plex settings ([8744ade](https://github.com/Masutayunikon/FanKarr/commit/8744adeff5f2db1ab648412d50495690d9e68d0b))
|
||||
* torrent for multiple seasons is now working ([2514008](https://github.com/Masutayunikon/FanKarr/commit/25140086c13ce5a497500659bdd6cea2aeff96ed))
|
||||
|
||||
## [3.1.0](https://github.com/Masutayunikon/FanKarr/compare/v3.0.0...v3.1.0) (2026-03-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* use original_name and formated_name from scraper to bypass timeout ([1b693fc](https://github.com/Masutayunikon/FanKarr/commit/1b693fc53b9ca3a830c952c3f2c101bf1da7d27e))
|
||||
|
||||
## [3.0.0](https://github.com/Masutayunikon/FanKarr/compare/v2.4.1...v3.0.0) (2026-03-27)
|
||||
|
||||
|
||||
|
||||
+76
-32
@@ -7,14 +7,17 @@ import { readSettings, writeSettings } from './settings.js'
|
||||
import {
|
||||
registerDriver, getDriver, getAvailableClients,
|
||||
listClients, addClient, removeClient, getClient,
|
||||
sanitizeClient, dispatchDownload, dispatchList
|
||||
sanitizeClient, dispatchDownload, dispatchList, updateClient
|
||||
} 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 { systemInfo } from './system.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
registerDriver(transmissionDriver)
|
||||
|
||||
const app = express()
|
||||
const PORT = Number(process.env.PORT) || 9898
|
||||
@@ -36,13 +39,16 @@ 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, usePlexTitles } = req.body
|
||||
const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, usePlexTitles })
|
||||
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)
|
||||
})
|
||||
@@ -65,11 +71,31 @@ app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
res.json({ ok: true })
|
||||
})
|
||||
app.put('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
const { name, type, config } = req.body
|
||||
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
const updated = updateClient(String(req.params.uuid), name, type, config)
|
||||
if (!updated) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
res.json(sanitizeClient(updated))
|
||||
})
|
||||
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
|
||||
const { type, config } = req.body
|
||||
const { type, config, uuid } = req.body
|
||||
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
|
||||
const driver = getDriver(type)
|
||||
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
|
||||
if (uuid) {
|
||||
const oldClient = getClient(uuid)
|
||||
if (oldClient) {
|
||||
for (const field of driver.definition.fields) {
|
||||
if (field.type === 'password' && config[field.key] === '••••••••') {
|
||||
config[field.key] = oldClient.config[field.key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json(await driver.test(config))
|
||||
})
|
||||
app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
|
||||
@@ -187,36 +213,10 @@ function normalizeEpisode(ep: any): any {
|
||||
return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 400) }
|
||||
}
|
||||
|
||||
async function enrichSeriesDataWithOriginalFilenames(seriesData: any[]): Promise<any[]> {
|
||||
return Promise.all(seriesData.map(async (sd) => {
|
||||
if (!sd) return sd
|
||||
const [serieApi, enrichedSeasons] = await Promise.all([
|
||||
fankaiGet(`/series/${sd.id}`).catch(() => null),
|
||||
Promise.all((sd.seasons ?? []).map(async (season: any) => {
|
||||
let epsData: any[] = []
|
||||
try {
|
||||
let lastErr: any
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try { const res = await fankaiGet(`/seasons/${season.id}/episodes`); epsData = Array.isArray(res) ? res : (res.episodes ?? []); break }
|
||||
catch (e) { lastErr = e; if (attempt === 0) await new Promise(r => setTimeout(r, 500)) }
|
||||
}
|
||||
if (epsData.length === 0 && lastErr) throw lastErr
|
||||
} catch { return season }
|
||||
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 ?? '' })
|
||||
}
|
||||
return { ...season, episodes: (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 { ...sd, title_for_plex: serieApi?.title_for_plex ?? null, seasons: enrichedSeasons }
|
||||
}))
|
||||
}
|
||||
|
||||
async function loadEnrichedSeriesData(): Promise<any[]> {
|
||||
const ids = await readAvailable()
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
|
||||
return enrichSeriesDataWithOriginalFilenames(allSd.filter(Boolean))
|
||||
return allSd.filter(Boolean)
|
||||
}
|
||||
|
||||
function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, string>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
@@ -482,6 +482,50 @@ app.post('/api/scan', requireAuth, async (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── 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')) })
|
||||
}
|
||||
|
||||
+61
-34
@@ -13,19 +13,18 @@ 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 }) }
|
||||
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean; usePlexTitles: boolean } {
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
|
||||
try {
|
||||
const p = path.join(DATA_DIR, 'settings.json')
|
||||
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, usePlexTitles: false }
|
||||
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
|
||||
return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, usePlexTitles: false } }
|
||||
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
|
||||
}
|
||||
|
||||
// ─── Organized log ────────────────────────────────────────────
|
||||
@@ -49,26 +48,36 @@ function isOrganized(hash: string, filename: string): boolean {
|
||||
|
||||
// ─── 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) {
|
||||
for (const t of sd.torrents ?? []) {
|
||||
if (t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
if (t.infohash?.toLowerCase() === h)
|
||||
return { torrent: t, serieData: sd }
|
||||
}
|
||||
|
||||
const seasonsWithHash = (sd.seasons ?? []).filter((s: any) =>
|
||||
s.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
||||
)
|
||||
if (seasonsWithHash.length === 1) {
|
||||
const season = seasonsWithHash[0]
|
||||
const t = season.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||
} else if (seasonsWithHash.length > 1) {
|
||||
const t = seasonsWithHash[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: t, serieData: sd }
|
||||
}
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
if (t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -81,8 +90,9 @@ function buildFileMap(
|
||||
hash : string,
|
||||
nfoSupport : boolean,
|
||||
seasonFilter?: number,
|
||||
): Map<string, { season_number: number; original_filename: string; fullPath: string }> {
|
||||
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
|
||||
): Map<string, { season_number: number; nfo_filename: string; fullPath: string }> {
|
||||
// FIX : type de retour utilise nfo_filename au lieu de original_filename
|
||||
const map = new Map<string, { season_number: number; nfo_filename: string; fullPath: string }>()
|
||||
const h = hash.toLowerCase()
|
||||
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
@@ -108,17 +118,21 @@ function buildFileMap(
|
||||
: null
|
||||
|
||||
if (!nfoSupport && !fmtName) {
|
||||
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback original_filename`)
|
||||
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`)
|
||||
}
|
||||
|
||||
const destName = nfoSupport
|
||||
? (ep.original_filename ?? filename)
|
||||
: (fmtName ?? ep.original_filename ?? filename)
|
||||
// FIX : priorité à nfo_filename, fallback sur formatted_name puis filename brut
|
||||
// On remplace l'extension du nfo_filename par celle du fichier source (.nfo → .mkv)
|
||||
const srcExt = path.extname(filename)
|
||||
const resolvedName = nfoSupport
|
||||
? (ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
const destName = swapExtension(resolvedName, srcExt)
|
||||
|
||||
map.set(filename, {
|
||||
season_number : season.season_number,
|
||||
original_filename: destName,
|
||||
fullPath : matchedPath,
|
||||
season_number: season.season_number,
|
||||
nfo_filename : destName,
|
||||
fullPath : matchedPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -225,7 +239,18 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
|
||||
}
|
||||
|
||||
// ─── Filesystem ops ───────────────────────────────────────────
|
||||
function seasonFolder(n: number): string { return `Saison ${n}` }
|
||||
|
||||
// FIX : remplace l'extension d'un nom de fichier par celle du fichier source
|
||||
// ex: "[Shalouf] Dandadan 01.nfo" + ".mkv" → "[Shalouf] Dandadan 01.mkv"
|
||||
function swapExtension(filename: string, ext: string): string {
|
||||
const currentExt = path.extname(filename)
|
||||
if (!currentExt || currentExt === ext) return filename
|
||||
return filename.slice(0, -currentExt.length) + ext
|
||||
}
|
||||
|
||||
function seasonFolder(n: number): string {
|
||||
return n === 0 ? 'Specials' : `Saison ${n}`
|
||||
}
|
||||
|
||||
function tryHardlink(src: string, dest: string): boolean {
|
||||
try { fs.linkSync(src, dest); return true }
|
||||
@@ -255,13 +280,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
}
|
||||
|
||||
const { torrent, serieData } = found
|
||||
const { usePlexTitles } = readSettings()
|
||||
const rawTitle = serieData.title ?? serieData.show_title ?? name
|
||||
const serieTitle = sanitizeDirName(
|
||||
usePlexTitles && serieData.title_for_plex
|
||||
? serieData.title_for_plex
|
||||
: rawTitle
|
||||
)
|
||||
const serieTitle = sanitizeDirName(rawTitle)
|
||||
|
||||
debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
|
||||
|
||||
@@ -283,9 +303,15 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
const fmtName = ep.formatted_name?.trim()
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: null
|
||||
const destName = nfoSupport
|
||||
? (ep.original_filename ?? filename)
|
||||
: (fmtName ?? ep.original_filename ?? filename)
|
||||
|
||||
// FIX : priorité à nfo_filename pour épisode unique aussi
|
||||
// On swap l'extension (.nfo → extension du fichier source)
|
||||
const srcExt = path.extname(filename)
|
||||
const resolvedName = nfoSupport
|
||||
? (ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
const destName = swapExtension(resolvedName, srcExt)
|
||||
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||
const dest = path.join(destDir, destName)
|
||||
|
||||
@@ -334,7 +360,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
|
||||
result.total = fileMap.size
|
||||
|
||||
for (const [filename, { season_number, original_filename, fullPath }] of fileMap) {
|
||||
// FIX : destructure nfo_filename au lieu de original_filename
|
||||
for (const [filename, { season_number, nfo_filename, fullPath }] of fileMap) {
|
||||
if (isOrganized(hash, filename)) { result.skipped++; continue }
|
||||
|
||||
const candidates = [
|
||||
@@ -353,7 +380,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
}
|
||||
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number))
|
||||
const dest = path.join(destDir, original_filename)
|
||||
const dest = path.join(destDir, nfo_filename)
|
||||
|
||||
if (fs.existsSync(dest)) { markOrganized(hash, filename); result.skipped++; continue }
|
||||
|
||||
@@ -367,7 +394,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
}
|
||||
markOrganized(hash, filename)
|
||||
result.done++
|
||||
debug(`${original_filename} → Saison ${season_number}`)
|
||||
debug(`${nfo_filename} → Saison ${season_number}`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
error(`Échec import "${filename}" : ${msg}`)
|
||||
|
||||
+4
-3
@@ -13,7 +13,7 @@ import { readSettings } from './settings.js'
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
|
||||
let workerRunning = false
|
||||
export let workerRunning = false
|
||||
const _prevStates = new Map<string, string>()
|
||||
|
||||
function resolveWorkerPath(): string {
|
||||
@@ -211,7 +211,8 @@ export async function autoOrganizeAll(
|
||||
workerRunning = false
|
||||
})
|
||||
worker.on('exit', (code) => {
|
||||
if (code !== 0) logger.error('organize', `Worker exit avec code ${code}`)
|
||||
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
|
||||
else logger.debug('organize', `Worker terminé (code ${code})`)
|
||||
workerRunning = false
|
||||
})
|
||||
|
||||
@@ -261,7 +262,7 @@ export async function organizeTorrent(
|
||||
reject(err)
|
||||
})
|
||||
worker.on('exit', (code) => {
|
||||
if (code !== 0) reject(new Error(`Worker exit ${code}`))
|
||||
if (code > 1) reject(new Error(`Worker exit inattendu: code ${code}`))
|
||||
})
|
||||
|
||||
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
|
||||
|
||||
+14
-14
@@ -5,23 +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
|
||||
usePlexTitles : boolean // utilise title_for_plex pour les noms de dossiers
|
||||
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,
|
||||
usePlexTitles: false,
|
||||
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/' : '/',
|
||||
})
|
||||
}
|
||||
@@ -103,6 +103,30 @@ export function addClient(name: string, type: string, config: Record<string, str
|
||||
return client
|
||||
}
|
||||
|
||||
export function updateClient(uuid: string, name: string, type: string, config: Record<string, string | number>): SavedClient | null {
|
||||
const clients = loadClients()
|
||||
const index = clients.findIndex(c => c.uuid === uuid)
|
||||
if (index === -1) return null
|
||||
|
||||
const oldClient = clients[index]
|
||||
const definition = getDriver(type)?.definition
|
||||
|
||||
// Conserver les mots de passe si "••••••••" est envoyé
|
||||
if (definition) {
|
||||
for (const field of definition.fields) {
|
||||
if (field.type === 'password' && config[field.key] === '••••••••') {
|
||||
config[field.key] = oldClient.config[field.key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const client: SavedClient = { uuid, name, type, config }
|
||||
clients[index] = client
|
||||
saveClients(clients)
|
||||
logger.info('torrent-clients', `Client mis à jour : "${name}" (${type})`)
|
||||
return client
|
||||
}
|
||||
|
||||
export function removeClient(uuid: string): boolean {
|
||||
const clients = loadClients()
|
||||
const target = clients.find(c => c.uuid === uuid)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Transmission Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
function mapState(status: number): TorrentInfo['state'] {
|
||||
// Transmission status codes :
|
||||
// 0 = stopped, 1 = check queued, 2 = checking, 3 = download queued,
|
||||
// 4 = downloading, 5 = seed queued, 6 = seeding
|
||||
if (status === 4 || status === 3) return 'downloading'
|
||||
if (status === 6 || status === 5) return 'seeding'
|
||||
if (status === 0) return 'paused'
|
||||
if (status === 1 || status === 2) return 'checking'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
async function trRequest(
|
||||
config: Record<string, string | number>,
|
||||
method: string,
|
||||
args: Record<string, unknown> = {},
|
||||
sessionId = ''
|
||||
): Promise<{ result: string; arguments?: any; sessionId?: string }> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type' : 'application/json',
|
||||
'X-Transmission-Session-Id': sessionId,
|
||||
}
|
||||
|
||||
if (config.username && config.password) {
|
||||
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.url}/transmission/rpc`, {
|
||||
method : 'POST',
|
||||
headers,
|
||||
body : JSON.stringify({ method, arguments: args }),
|
||||
})
|
||||
|
||||
// Transmission renvoie 409 avec le session ID à la première requête
|
||||
if (res.status === 409) {
|
||||
const newSessionId = res.headers.get('X-Transmission-Session-Id') ?? ''
|
||||
if (!newSessionId) throw new Error('Session ID introuvable dans la réponse 409')
|
||||
return trRequest(config, method, args, newSessionId)
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`Transmission HTTP ${res.status}`)
|
||||
|
||||
const data = await res.json()
|
||||
if (data.result !== 'success') throw new Error(`Transmission erreur : ${data.result}`)
|
||||
|
||||
return { ...data, sessionId }
|
||||
}
|
||||
|
||||
const TR: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'transmission',
|
||||
label : 'Transmission',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'transmission', required: false },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
await trRequest(config, 'session-get')
|
||||
logger.info('transmission', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.warn('transmission', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const data = await trRequest(config, 'session-get')
|
||||
const version = data.arguments?.version ?? 'inconnue'
|
||||
logger.debug('transmission', `Healthcheck OK — version ${version}`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
logger.debug('transmission', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const fields = [
|
||||
'hashString', 'name', 'status', 'percentDone', 'totalSize',
|
||||
'downloadedEver', 'rateDownload', 'eta', 'downloadDir', 'labels',
|
||||
]
|
||||
const data = await trRequest(config, 'torrent-get', { fields })
|
||||
const torrents: any[] = data.arguments?.torrents ?? []
|
||||
|
||||
return torrents
|
||||
.filter(t => {
|
||||
if (!category) return true
|
||||
// Filtrer par label uniquement, comme qBittorrent filtre par catégorie
|
||||
return t.labels?.includes(category)
|
||||
})
|
||||
.map(t => ({
|
||||
hash : t.hashString,
|
||||
name : t.name,
|
||||
state : mapState(t.status),
|
||||
progress : Math.round(t.percentDone * 100),
|
||||
size : t.totalSize,
|
||||
downloaded: t.downloadedEver,
|
||||
speed : t.rateDownload,
|
||||
eta : t.eta ?? -1,
|
||||
save_path : t.downloadDir,
|
||||
category : t.labels?.[0] ?? '',
|
||||
} satisfies TorrentInfo))
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
const args: Record<string, unknown> = { filename: url }
|
||||
|
||||
if (config.savePath) {
|
||||
args['download-dir'] = String(config.savePath)
|
||||
}
|
||||
|
||||
if (config.category) {
|
||||
args['labels'] = [String(config.category)]
|
||||
}
|
||||
|
||||
await trRequest(config, 'torrent-add', args)
|
||||
logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`)
|
||||
},
|
||||
}
|
||||
|
||||
export default TR
|
||||
@@ -14,6 +14,12 @@
|
||||
|
||||
<!-- Chemin éditable -->
|
||||
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
|
||||
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" title="Retour à la racine">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
v-model="inputPath"
|
||||
@keydown.enter="navigateTo(inputPath)"
|
||||
@@ -62,7 +68,8 @@
|
||||
<!-- 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">
|
||||
<!-- FIX : regex sortie dans selectCurrent() pour éviter l'erreur de parsing Vue -->
|
||||
<button @click="selectCurrent()" class="btn-primary">
|
||||
Choisir
|
||||
</button>
|
||||
</div>
|
||||
@@ -85,8 +92,15 @@ const parent = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
|
||||
function selectCurrent() {
|
||||
emit('select', current.value.replace(/\/+$/, '') || '/')
|
||||
}
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
return base === '/' ? `/${name}` : `${base}/${name}`
|
||||
if (base === '/') return `/${name}`
|
||||
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base
|
||||
return `${cleanBase}/${name}`
|
||||
}
|
||||
|
||||
async function navigateTo(p: string) {
|
||||
|
||||
@@ -74,6 +74,7 @@ const navItems = computed<NavItem[]>(() => [
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -73,6 +73,11 @@ const router = createRouter({
|
||||
name: 'settings-logs',
|
||||
component: () => import('@/views/settings/LogsView.vue'), // ← nouveau chemin
|
||||
},
|
||||
{
|
||||
path: 'advanced',
|
||||
name: 'settings-advanced',
|
||||
component: () => import('@/views/settings/AdvancedView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+85
-70
@@ -11,9 +11,7 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -22,28 +20,21 @@
|
||||
</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-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">
|
||||
<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-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">
|
||||
@@ -51,17 +42,11 @@
|
||||
<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 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>
|
||||
<span v-if="t.state === 'downloading' && t.eta > 0" class="text-[11px] text-muted">{{ formatEta(t.eta) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,28 +55,16 @@
|
||||
<!-- 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-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 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>
|
||||
@@ -106,6 +79,51 @@
|
||||
</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>
|
||||
|
||||
@@ -114,15 +132,26 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const loadingTorrents = ref(true)
|
||||
const loadingNotifs = ref(true)
|
||||
|
||||
const stats = ref({ catalogue: 0, imported: 0, downloading: 0, errors: 0 })
|
||||
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 fetchAll() {
|
||||
await Promise.all([fetchStats(), fetchTorrents(), fetchNotifs()])
|
||||
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() {
|
||||
@@ -132,18 +161,12 @@ async function fetchStats() {
|
||||
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 (statusRes.ok) { const s = await statusRes.json(); stats.value.catalogue = s.count ?? 0 }
|
||||
if (seriesRes.ok) {
|
||||
const s = await seriesRes.json()
|
||||
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
|
||||
@@ -153,18 +176,14 @@ async function fetchStats() {
|
||||
}
|
||||
|
||||
async function fetchTorrents() {
|
||||
// Premier chargement seulement → affiche le spinner
|
||||
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)
|
||||
activeTorrents.value = list.filter((t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state)).slice(0, 5)
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
} catch {} finally {
|
||||
if (isFirst) loadingTorrents.value = false
|
||||
}
|
||||
}
|
||||
@@ -174,14 +193,13 @@ async function fetchNotifs() {
|
||||
try {
|
||||
const res = await fetch('/api/organize/recent', { credentials: 'include' })
|
||||
if (res.ok) recentNotifs.value = (await res.json()).slice(0, 6)
|
||||
} catch {
|
||||
} finally {
|
||||
} catch {} finally {
|
||||
loadingNotifs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return '0 B'
|
||||
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]}`
|
||||
@@ -189,9 +207,7 @@ function formatSize(bytes: number): string {
|
||||
|
||||
function formatEta(seconds: number): string {
|
||||
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
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`
|
||||
@@ -199,21 +215,20 @@ function formatEta(seconds: number): string {
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const m = Math.floor(diff / 60000)
|
||||
const h = Math.floor(diff / 3600000)
|
||||
const d = Math.floor(diff / 86400000)
|
||||
if (m < 1) return "à l'instant"
|
||||
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(() => {
|
||||
fetchAll()
|
||||
interval = setInterval(fetchTorrents, 10000)
|
||||
Promise.all([fetchStats(), fetchTorrents(), fetchNotifs(), fetchSettings()])
|
||||
interval = setInterval(() => {
|
||||
fetchTorrents()
|
||||
if (devMode.value) fetchDebug()
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) clearInterval(interval)
|
||||
})
|
||||
onUnmounted(() => { if (interval) clearInterval(interval) })
|
||||
</script>
|
||||
+109
-10
@@ -32,6 +32,47 @@
|
||||
</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="relative shrink-0" ref="sortRef">
|
||||
<button
|
||||
@click="sortOpen = !sortOpen"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
|
||||
:class="activeSort !== 'none' ? 'border-accent text-accent' : ''"
|
||||
>
|
||||
<ArrowUpDown :size="14" />
|
||||
<span class="hidden md:inline text-xs">Trier</span>
|
||||
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="sortOpen"
|
||||
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
@click="setSort(s.value)"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
|
||||
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
{{ s.label }}
|
||||
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
|
||||
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
@@ -118,7 +159,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-semibold text-primary">{{ t.progress }}%</p>
|
||||
<p class="text-sm font-semibold text-primary">{{ clampedProgress(t) }}%</p>
|
||||
<p class="text-xs text-muted mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,10 +168,10 @@
|
||||
<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' :
|
||||
:class="t.state === 'seeding' || t.state === 'unknown' ? 'bg-green-500' :
|
||||
t.state === 'error' ? 'bg-red-500' :
|
||||
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
|
||||
:style="{ width: `${t.progress}%` }"
|
||||
:style="{ width: `${clampedProgress(t)}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -140,9 +181,10 @@
|
||||
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
|
||||
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
|
||||
<span v-if="t.state === 'seeding'" class="text-green-500">Complété</span>
|
||||
<span v-if="t.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
|
||||
</div>
|
||||
|
||||
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
|
||||
<div v-if="t.state === 'seeding' || t.state === 'unknown'" class="flex items-center gap-2">
|
||||
|
||||
<!-- Erreurs fichiers -->
|
||||
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
|
||||
@@ -193,6 +235,8 @@
|
||||
<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()
|
||||
@@ -205,21 +249,70 @@ 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
|
||||
|
||||
// FIX : unknown retiré de activeTorrents, ajouté dans doneTorrents
|
||||
const activeTorrents = computed(() =>
|
||||
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error', 'unknown'].includes(t.state))
|
||||
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
|
||||
)
|
||||
|
||||
const doneTorrents = computed(() =>
|
||||
torrents.value.filter(t => t.state === 'seeding')
|
||||
torrents.value.filter(t => t.state === 'seeding' || t.state === 'unknown')
|
||||
)
|
||||
|
||||
const visibleTorrents = computed(() => {
|
||||
if (activeTab.value === 'active') return activeTorrents.value
|
||||
if (hideImported.value) return doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||
return doneTorrents.value
|
||||
let list = activeTab.value === 'active'
|
||||
? activeTorrents.value
|
||||
: 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(() => [
|
||||
@@ -229,6 +322,11 @@ const stats = computed(() => [
|
||||
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
|
||||
])
|
||||
|
||||
// FIX : progress cappé à 100 pour éviter "278.2 GB / 278.1 GB" → 100%
|
||||
function clampedProgress(t: any): number {
|
||||
return Math.min(100, Math.max(0, t.progress ?? 0))
|
||||
}
|
||||
|
||||
async function fetchTorrents() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
@@ -315,7 +413,8 @@ function stateBadge(state: string) {
|
||||
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' },
|
||||
// FIX : badge jaune pour signaler état dégradé sans alarmer
|
||||
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
}
|
||||
return map[state] ?? map.unknown
|
||||
}
|
||||
|
||||
+13
-2
@@ -151,8 +151,19 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progression saison -->
|
||||
<ProgressBar :progress="torrentProgress(extractHash(season.torrent))?.progress" class="px-5 py-2 border-b border-border" />
|
||||
<!-- 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>
|
||||
<p class="text-[11px] text-muted mt-1">{{ torrentProgress(extractHash(season.torrent))!.progress }}%</p>
|
||||
</div>
|
||||
|
||||
<!-- Épisodes -->
|
||||
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-border/50">
|
||||
|
||||
@@ -220,7 +220,7 @@ const filtersDisponibilite = [
|
||||
const filtersImport = [
|
||||
{ label: 'Importés', value: 'complete', color: '#22c55e' },
|
||||
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
|
||||
{ label: 'Partiel', value: 'partial', color: 'var(--accent)' },
|
||||
{ label: 'Partiel', value: 'partial', color: '#9b59b6' },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
@@ -233,7 +233,7 @@ const sortOptions = [
|
||||
const legend = [
|
||||
{ label: 'Importé', color: '#22c55e' },
|
||||
{ label: 'En cours', color: '#3b82f6' },
|
||||
{ label: 'Partiel', color: 'var(--accent)' },
|
||||
{ label: 'Partiel', color: '#9b59b6' },
|
||||
{ label: 'Non importé', color: 'var(--border)' },
|
||||
]
|
||||
|
||||
@@ -271,7 +271,7 @@ function stateColor(state: string): string {
|
||||
return ({
|
||||
complete : '#22c55e',
|
||||
downloading: '#3b82f6',
|
||||
partial : 'var(--accent)',
|
||||
partial : '#9b59b6',
|
||||
none : 'transparent',
|
||||
} as Record<string, string>)[state] ?? 'transparent'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -34,6 +34,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="testClient(client.uuid)" class="btn-ghost text-xs">Tester</button>
|
||||
<button @click="editClient(client)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
|
||||
<button @click="deleteClient(client.uuid)" class="btn-ghost text-xs text-red-400 hover:text-red-300">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,7 +50,7 @@
|
||||
<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>
|
||||
<h3 class="text-sm font-semibold text-primary">{{ modal.uuid ? 'Modifier un client' : 'Ajouter un client' }}</h3>
|
||||
<button @click="closeModal" class="text-muted hover:text-primary transition-colors">✕</button>
|
||||
</div>
|
||||
|
||||
@@ -116,6 +117,7 @@ const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
|
||||
const modal = ref({
|
||||
open : false,
|
||||
uuid : null as string | null,
|
||||
name : '',
|
||||
type : '',
|
||||
config : {} as Record<string, string>,
|
||||
@@ -171,7 +173,20 @@ async function deleteClient(uuid: string) {
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
||||
modal.value = { open: true, uuid: null, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
||||
}
|
||||
|
||||
function editClient(client: any) {
|
||||
modal.value = {
|
||||
open : true,
|
||||
uuid : client.uuid,
|
||||
name : client.name,
|
||||
type : client.type,
|
||||
config : JSON.parse(JSON.stringify(client.config)),
|
||||
testing: false,
|
||||
saving : false,
|
||||
tested : true,
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() { modal.value.open = false }
|
||||
@@ -193,7 +208,7 @@ async function testNewClient() {
|
||||
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 }),
|
||||
body: JSON.stringify({ type: modal.value.type, config: modal.value.config, uuid: modal.value.uuid }),
|
||||
})
|
||||
const { ok, message } = await res.json()
|
||||
modal.value.tested = ok
|
||||
@@ -209,8 +224,12 @@ 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',
|
||||
const isEdit = !!modal.value.uuid
|
||||
const method = isEdit ? 'PUT' : 'POST'
|
||||
const url = isEdit ? `/api/torrent-clients/${modal.value.uuid}` : '/api/torrent-clients'
|
||||
|
||||
const res = await fetch(url, {
|
||||
method, headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name : modal.value.name || clientLabel(modal.value.type),
|
||||
type : modal.value.type,
|
||||
@@ -219,10 +238,15 @@ async function saveClient() {
|
||||
})
|
||||
if (res.ok) {
|
||||
const client = await res.json()
|
||||
clients.value.push(client)
|
||||
if (isEdit) {
|
||||
const index = clients.value.findIndex(c => c.uuid === client.uuid)
|
||||
if (index !== -1) clients.value[index] = client
|
||||
} else {
|
||||
clients.value.push(client)
|
||||
}
|
||||
refreshHealth(client.uuid)
|
||||
closeModal()
|
||||
toast('Client enregistré ✓', 'success')
|
||||
toast(isEdit ? 'Client modifié ✓' : 'Client enregistré ✓', 'success')
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? "Erreur lors de l'enregistrement", 'error')
|
||||
|
||||
@@ -15,10 +15,13 @@
|
||||
<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'">
|
||||
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : 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>
|
||||
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
|
||||
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -27,14 +30,29 @@
|
||||
<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'">
|
||||
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : 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>
|
||||
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
|
||||
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Alerte globale si un des deux paths est sur / -->
|
||||
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
|
||||
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
|
||||
<p class="text-xs text-muted">
|
||||
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
|
||||
Configurez-les ci-dessus avant de sauvegarder.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode import -->
|
||||
<div class="settings-card flex flex-col gap-2">
|
||||
<label class="settings-label">Mode d'import</label>
|
||||
@@ -72,12 +90,6 @@
|
||||
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
v-model="form.usePlexTitles"
|
||||
label="Titres optimisés Plex"
|
||||
description="Utilise title_for_plex pour les noms de dossiers — évite les caractères spéciaux."
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
v-model="form.nfoSupport"
|
||||
label="NFO / Métadonnées"
|
||||
@@ -115,7 +127,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
@@ -133,15 +145,25 @@ const form = ref({
|
||||
organizeMode : 'hardlink' as 'hardlink' | 'move',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
usePlexTitles: false,
|
||||
})
|
||||
|
||||
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
||||
open: false, field: 'completePath', currentPath: '/',
|
||||
open: false,
|
||||
field: 'completePath',
|
||||
currentPath: '/',
|
||||
})
|
||||
|
||||
// FIX : détection path non configuré (vide ou racine /)
|
||||
function isRootPath(p: string): boolean {
|
||||
return !p || p === '/'
|
||||
}
|
||||
|
||||
const hasUnconfiguredPaths = computed(() =>
|
||||
isRootPath(form.value.completePath) || isRootPath(form.value.mediaPath)
|
||||
)
|
||||
|
||||
function openPicker(field: 'completePath' | 'mediaPath') {
|
||||
picker.value = { open: true, field, currentPath: form.value[field] || '/' }
|
||||
picker.value = { open: true, field, currentPath: form.value[field] || picker.value.currentPath || '/' }
|
||||
}
|
||||
|
||||
function onPickerSelect(path: string) {
|
||||
@@ -150,12 +172,14 @@ function onPickerSelect(path: string) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [settingsRes, systemRes] = await Promise.all([
|
||||
const [settingsRes, systemRes, infoRes] = await Promise.all([
|
||||
fetch('/api/settings', { credentials: 'include' }),
|
||||
fetch('/api/system', { credentials: 'include' }),
|
||||
fetch('/api/system/info', { credentials: 'include' }),
|
||||
])
|
||||
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
|
||||
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
|
||||
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
|
||||
})
|
||||
|
||||
async function save() {
|
||||
|
||||
Reference in new issue
Block a user