Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8f0984ec6 | ||
|
|
e3bca6be87 | ||
|
|
b88e020fb5 | ||
|
|
7f3f8656b3 | ||
|
|
97da422037 |
No files matched your search
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.2.0](https://github.com/Masutayunikon/FanKarr/compare/v1.1.0...v1.2.0) (2026-03-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
|
||||||
|
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
|
||||||
|
|
||||||
## [1.1.0](https://github.com/Masutayunikon/FanKarr/compare/v1.0.6...v1.1.0) (2026-03-10)
|
## [1.1.0](https://github.com/Masutayunikon/FanKarr/compare/v1.0.6...v1.1.0) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+156
-8
@@ -11,6 +11,7 @@ import {
|
|||||||
} from './torrent-clients/index.js'
|
} from './torrent-clients/index.js'
|
||||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||||
import { organizeTorrent, autoOrganizeAll } from './organize.js'
|
import { organizeTorrent, autoOrganizeAll } from './organize.js'
|
||||||
|
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||||
|
|
||||||
registerDriver(qbittorrentDriver)
|
registerDriver(qbittorrentDriver)
|
||||||
|
|
||||||
@@ -108,16 +109,73 @@ function indexBySerie(torrents: any[]): Map<number, any[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Series ─────────────────────────────────────────────────────
|
// ── Series ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function computeSerieDownloadState(
|
||||||
|
serieTorrents : any[],
|
||||||
|
organized : Record<string, Record<string, string>>,
|
||||||
|
activeTorrents: Set<string> // hashes actuellement dans qB en downloading
|
||||||
|
): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||||
|
if (serieTorrents.length === 0) return 'none'
|
||||||
|
|
||||||
|
// Un torrent de la série est en cours de téléchargement ?
|
||||||
|
const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase()))
|
||||||
|
if (hasActive) return 'downloading'
|
||||||
|
|
||||||
|
// Compter les fichiers attendus vs organisés
|
||||||
|
let totalFiles = 0
|
||||||
|
let doneFiles = 0
|
||||||
|
for (const t of serieTorrents) {
|
||||||
|
const files: any[] = t.torrent_files ?? []
|
||||||
|
if (files.length === 0) {
|
||||||
|
// Fichier unique
|
||||||
|
totalFiles++
|
||||||
|
if (organized[t.infohash?.toLowerCase()]?.[t.raw]) doneFiles++
|
||||||
|
} else {
|
||||||
|
totalFiles += files.length
|
||||||
|
doneFiles += files.filter((f: any) => organized[t.infohash?.toLowerCase()]?.[f.filename]).length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalFiles === 0) return 'none'
|
||||||
|
if (doneFiles === 0) return 'none'
|
||||||
|
if (doneFiles >= totalFiles) return 'complete'
|
||||||
|
return 'partial'
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/api/series', requireAuth, async (_req, res) => {
|
app.get('/api/series', requireAuth, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())])
|
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())])
|
||||||
const byId = indexBySerie(torrents)
|
const byId = indexBySerie(torrents)
|
||||||
|
|
||||||
|
// Charger organized.json
|
||||||
|
let organized: Record<string, Record<string, string>> = {}
|
||||||
|
try {
|
||||||
|
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
|
||||||
|
if (fs.existsSync(orgPath))
|
||||||
|
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Récupérer les hashes actifs depuis qB (best effort)
|
||||||
|
const activeTorrents = new Set<string>()
|
||||||
|
try {
|
||||||
|
const { category } = readSettings()
|
||||||
|
const active = await dispatchList(category ?? 'fankai')
|
||||||
|
for (const t of active) {
|
||||||
|
if (t.state === 'downloading' && t.hash)
|
||||||
|
activeTorrents.add(t.hash.toLowerCase())
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
|
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
|
||||||
res.json({ series: seriesList.map((serie: any) => ({
|
res.json({ series: seriesList.map((serie: any) => {
|
||||||
...serie,
|
const serieTorrents = byId.get(serie.id) ?? []
|
||||||
torrent_count: byId.get(serie.id)?.length ?? 0,
|
return {
|
||||||
has_torrents : byId.has(serie.id)
|
...serie,
|
||||||
}))})
|
torrent_count : serieTorrents.length,
|
||||||
|
has_torrents : serieTorrents.length > 0,
|
||||||
|
download_state: computeSerieDownloadState(serieTorrents, organized, activeTorrents),
|
||||||
|
}
|
||||||
|
})})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||||
}
|
}
|
||||||
@@ -230,13 +288,75 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { category } = readSettings()
|
const { category } = readSettings()
|
||||||
const torrents = await dispatchList(category ?? 'fankai')
|
const torrents = await dispatchList(category ?? 'fankai')
|
||||||
res.json(torrents)
|
|
||||||
|
// Enrichir avec l'état d'organisation
|
||||||
|
let organized: Record<string, Record<string, string>> = {}
|
||||||
|
try {
|
||||||
|
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
|
||||||
|
if (fs.existsSync(orgPath))
|
||||||
|
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
let torrentFinal: any[] = []
|
||||||
|
try {
|
||||||
|
const tfPath = path.join(process.cwd(), 'data', 'torrent_final.json')
|
||||||
|
if (fs.existsSync(tfPath))
|
||||||
|
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const enriched = torrents.map((t: any) => {
|
||||||
|
const orgFiles = organized[t.hash] ?? {}
|
||||||
|
const meta = torrentFinal.find((m: any) =>
|
||||||
|
m.infohash?.toLowerCase() === t.hash?.toLowerCase()
|
||||||
|
)
|
||||||
|
const totalFiles = (meta?.torrent_files?.length ?? 0) || 1
|
||||||
|
const doneFiles = Object.keys(orgFiles).length
|
||||||
|
let organizeState: 'none' | 'partial' | 'done' = 'none'
|
||||||
|
if (doneFiles >= totalFiles) organizeState = 'done'
|
||||||
|
else if (doneFiles > 0) organizeState = 'partial'
|
||||||
|
|
||||||
|
// Récupérer les erreurs depuis recentOrganized
|
||||||
|
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||||
|
const errorFiles = notif?.errorFiles ?? []
|
||||||
|
|
||||||
|
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles }
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(enriched)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Organize ───────────────────────────────────────────────────
|
// ── Résultats d'organisation récents (pour notifications frontend) ──
|
||||||
|
interface OrganizeNotif {
|
||||||
|
hash : string
|
||||||
|
name : string
|
||||||
|
done : number
|
||||||
|
skipped : number
|
||||||
|
errors : number
|
||||||
|
errorFiles : { file: string; error: string }[]
|
||||||
|
at : string
|
||||||
|
}
|
||||||
|
const recentOrganized: OrganizeNotif[] = []
|
||||||
|
const MAX_NOTIFS = 20
|
||||||
|
|
||||||
|
function pushNotif(n: OrganizeNotif) {
|
||||||
|
recentOrganized.unshift(n)
|
||||||
|
if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/organize/recent', requireAuth, (_req, res) => {
|
||||||
|
res.json(recentOrganized)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Vider les notifs lues
|
||||||
|
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
|
||||||
|
recentOrganized.length = 0
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
app.post('/api/organize', requireAuth, async (req, res) => {
|
app.post('/api/organize', requireAuth, async (req, res) => {
|
||||||
const { hash, name, save_path } = req.body
|
const { hash, name, save_path } = req.body
|
||||||
if (!hash || !name || !save_path) {
|
if (!hash || !name || !save_path) {
|
||||||
@@ -252,6 +372,27 @@ app.post('/api/organize', requireAuth, async (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── Torrents status ────────────────────────────────────────────
|
// ── Torrents status ────────────────────────────────────────────
|
||||||
|
// ── 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() })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/logs/clear', requireAuth, (_req, res) => {
|
||||||
|
clearLogs()
|
||||||
|
logger.info('api', 'Logs effacés')
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── System info ────────────────────────────────────────────────
|
||||||
|
app.get('/api/system', requireAuth, (_req, res) => {
|
||||||
|
const isDocker = fs.existsSync('/.dockerenv')
|
||||||
|
res.json({ isDocker })
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/torrents/status', requireAuth, (_req, res) => {
|
app.get('/api/torrents/status', requireAuth, (_req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
|
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
|
||||||
@@ -299,7 +440,14 @@ app.listen(PORT, () => {
|
|||||||
const autoOrganize = async () => {
|
const autoOrganize = async () => {
|
||||||
try {
|
try {
|
||||||
const { category } = readSettings()
|
const { category } = readSettings()
|
||||||
await autoOrganizeAll(() => dispatchList(category ?? 'fankai'))
|
await autoOrganizeAll(
|
||||||
|
() => dispatchList(category ?? 'fankai'),
|
||||||
|
(result) => {
|
||||||
|
if (result.done > 0 || result.errors > 0) {
|
||||||
|
pushNotif({ ...result, at: new Date().toISOString() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[organize] Erreur auto-organise:', err)
|
console.error('[organize] Erreur auto-organise:', err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* logger.ts
|
||||||
|
* =========
|
||||||
|
* Logger centralisé — écrit dans data/logs.jsonl (une ligne JSON par event)
|
||||||
|
* Niveaux : debug | info | warn | error
|
||||||
|
* En prod (NODE_ENV=production) les lignes debug ne sont pas écrites sur disque.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
at : string // ISO timestamp
|
||||||
|
level : LogLevel
|
||||||
|
source : string // 'organize' | 'api' | 'scraper' | ...
|
||||||
|
msg : string
|
||||||
|
meta? : Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOGS_PATH = path.join(process.cwd(), 'data', 'logs.jsonl')
|
||||||
|
const MAX_LINES = 2000 // rotation auto au-delà
|
||||||
|
const IS_PROD = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
// ── Écriture ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function writeLine(entry: LogEntry) {
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(LOGS_PATH, JSON.stringify(entry) + '\n', 'utf-8')
|
||||||
|
// Rotation si trop grand
|
||||||
|
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
|
||||||
|
const lines = content.split('\n').filter(Boolean)
|
||||||
|
if (lines.length > MAX_LINES) {
|
||||||
|
fs.writeFileSync(LOGS_PATH, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8')
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API publique ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function log(level: LogLevel, source: string, msg: string, meta?: Record<string, any>) {
|
||||||
|
const entry: LogEntry = { at: new Date().toISOString(), level, source, msg, meta }
|
||||||
|
|
||||||
|
// Console
|
||||||
|
const prefix = `[${source}]`
|
||||||
|
if (level === 'error') console.error(prefix, msg, meta ?? '')
|
||||||
|
else if (level === 'warn') console.warn(prefix, msg, meta ?? '')
|
||||||
|
else if (level === 'debug' && !IS_PROD) console.log(prefix, msg, meta ?? '')
|
||||||
|
else if (level === 'info') console.log(prefix, msg, meta ?? '')
|
||||||
|
|
||||||
|
// Disque — pas les debug en prod
|
||||||
|
if (level === 'debug' && IS_PROD) return
|
||||||
|
writeLine(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
debug: (source: string, msg: string, meta?: Record<string, any>) => log('debug', source, msg, meta),
|
||||||
|
info : (source: string, msg: string, meta?: Record<string, any>) => log('info', source, msg, meta),
|
||||||
|
warn : (source: string, msg: string, meta?: Record<string, any>) => log('warn', source, msg, meta),
|
||||||
|
error: (source: string, msg: string, meta?: Record<string, any>) => log('error', source, msg, meta),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lecture pour l'API ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LogsReadOptions {
|
||||||
|
limit? : number // nb de lignes (défaut 100)
|
||||||
|
level? : LogLevel | 'all' // filtre niveau
|
||||||
|
source? : string // filtre source
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(LOGS_PATH)) return []
|
||||||
|
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
|
||||||
|
let lines = content.split('\n').filter(Boolean)
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
let entries: LogEntry[] = []
|
||||||
|
for (const line of lines) {
|
||||||
|
try { entries.push(JSON.parse(line)) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtres
|
||||||
|
if (opts.level && opts.level !== 'all')
|
||||||
|
entries = entries.filter(e => e.level === opts.level)
|
||||||
|
if (opts.source)
|
||||||
|
entries = entries.filter(e => e.source === opts.source)
|
||||||
|
|
||||||
|
// Plus récents en premier
|
||||||
|
entries.reverse()
|
||||||
|
|
||||||
|
// Limite
|
||||||
|
const limit = opts.limit ?? 100
|
||||||
|
return entries.slice(0, limit)
|
||||||
|
} catch { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLogs(): void {
|
||||||
|
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8') } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logsFileSize(): number {
|
||||||
|
try { return fs.statSync(LOGS_PATH).size } catch { return 0 }
|
||||||
|
}
|
||||||
+10
-1
@@ -24,7 +24,8 @@ export interface OrganizeResult {
|
|||||||
* puis les données sont passées au worker.
|
* puis les données sont passées au worker.
|
||||||
*/
|
*/
|
||||||
export async function autoOrganizeAll(
|
export async function autoOrganizeAll(
|
||||||
listFn: () => Promise<any[]>
|
listFn : () => Promise<any[]>,
|
||||||
|
onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (workerRunning) {
|
if (workerRunning) {
|
||||||
console.log('[organize] Worker déjà en cours, skip')
|
console.log('[organize] Worker déjà en cours, skip')
|
||||||
@@ -58,6 +59,14 @@ export async function autoOrganizeAll(
|
|||||||
else console.log(msg.msg)
|
else console.log(msg.msg)
|
||||||
} else if (msg.type === 'result') {
|
} else if (msg.type === 'result') {
|
||||||
console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
|
console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
|
||||||
|
onResult?.({
|
||||||
|
hash : msg.hash,
|
||||||
|
name : msg.name,
|
||||||
|
done : msg.done,
|
||||||
|
skipped : msg.skipped,
|
||||||
|
errors : msg.errors.length,
|
||||||
|
errorFiles : msg.errors,
|
||||||
|
})
|
||||||
} else if (msg.type === 'done') {
|
} else if (msg.type === 'done') {
|
||||||
console.log('[organize] Worker terminé')
|
console.log('[organize] Worker terminé')
|
||||||
workerRunning = false
|
workerRunning = false
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
component: () => import('@/views/SettingsView.vue')
|
component: () => import('@/views/SettingsView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/logs',
|
||||||
|
component: () => import('@/views/LogsView.vue')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
scrollBehavior(to, _from, savedPosition) {
|
scrollBehavior(to, _from, savedPosition) {
|
||||||
|
|||||||
+96
-11
@@ -8,14 +8,31 @@
|
|||||||
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
|
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<router-link to="/downloads"
|
<!-- Icône logs -->
|
||||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition relative">
|
<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">
|
<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-4M7 10l5 5 5-5M12 15V3"/>
|
<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>
|
</svg>
|
||||||
<!-- Badge si téléchargements actifs -->
|
</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>
|
||||||
|
<!-- Badge compteur -->
|
||||||
<span v-if="activeDownloads > 0"
|
<span v-if="activeDownloads > 0"
|
||||||
class="absolute top-1 right-1 w-2 h-2 bg-[#e8513a] rounded-full"/>
|
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>
|
||||||
<router-link
|
<router-link
|
||||||
to="/settings"
|
to="/settings"
|
||||||
@@ -120,6 +137,27 @@
|
|||||||
{{ serie.torrent_count }}
|
{{ serie.torrent_count }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Badge état téléchargement -->
|
||||||
|
<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' : ''">
|
||||||
|
<!-- Icône -->
|
||||||
|
<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' ? 'ORGANISÉ' : serie.download_state === 'downloading' ? 'EN COURS' : 'PARTIEL' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Overlay hover -->
|
<!-- Overlay hover -->
|
||||||
<div class="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
<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>
|
<span class="text-xs text-white border border-white/40 px-3 py-1.5 rounded-md">Voir</span>
|
||||||
@@ -148,30 +186,35 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useSeriesStore } from '@/stores/series'
|
import { useSeriesStore } from '@/stores/series'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
defineOptions({ name: 'CatalogView' })
|
|
||||||
|
|
||||||
const store = useSeriesStore()
|
const store = useSeriesStore()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { add: toast } = useToast()
|
||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const activeFilter = ref('all')
|
const activeFilter = ref('all')
|
||||||
|
const activeDownloads = ref(0)
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
{ label: 'Toutes', value: 'all' },
|
{ label: 'Toutes', value: 'all' },
|
||||||
{ label: 'Disponibles', value: 'available' },
|
{ label: 'Disponibles', value: 'available' },
|
||||||
|
{ label: 'Téléchargés', value: 'complete' },
|
||||||
|
{ label: 'En cours', value: 'downloading' },
|
||||||
{ label: 'Sans torrent', value: 'unavailable' },
|
{ label: 'Sans torrent', value: 'unavailable' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
let list = store.series
|
let list = store.series
|
||||||
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
|
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 === '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()) {
|
if (search.value.trim()) {
|
||||||
const q = search.value.toLowerCase()
|
const q = search.value.toLowerCase()
|
||||||
list = list.filter(s => s.title.toLowerCase().includes(q))
|
list = list.filter(s => s.title.toLowerCase().includes(q))
|
||||||
@@ -184,7 +227,49 @@ async function handleLogout() {
|
|||||||
router.push('/auth')
|
router.push('/auth')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Polling downloads actifs + notifications organisation ──────
|
||||||
|
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()
|
||||||
|
for (const n of notifs) {
|
||||||
|
const key = `${n.hash}-${n.at}`
|
||||||
|
if (seenNotifs.has(key)) continue
|
||||||
|
seenNotifs.add(key)
|
||||||
|
if (n.done > 0) {
|
||||||
|
const msg = n.errors > 0
|
||||||
|
? `${n.name} — ${n.done} fichier(s) organisé(s), ${n.errors} erreur(s)`
|
||||||
|
: `${n.name} — ${n.done} fichier(s) organisé(s) ✓`
|
||||||
|
toast(msg, n.errors > 0 ? 'error' : 'success')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (store.series.length === 0) store.fetchSeries()
|
if (store.series.length === 0) store.fetchSeries()
|
||||||
|
fetchActiveDownloads()
|
||||||
|
fetchOrganizeNotifs()
|
||||||
|
dlInterval = setInterval(() => {
|
||||||
|
fetchActiveDownloads()
|
||||||
|
fetchOrganizeNotifs()
|
||||||
|
}, 10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (dlInterval) clearInterval(dlInterval)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
+38
-11
@@ -103,16 +103,44 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bouton organiser si complété -->
|
<!-- Badge organisation -->
|
||||||
<button v-if="t.state === 'seeding'"
|
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
|
||||||
@click="organize(t)"
|
|
||||||
:disabled="organizing[t.hash]"
|
<!-- Badge erreurs avec tooltip -->
|
||||||
class="text-[10px] tracking-widest px-3 py-1 border transition-colors cursor-pointer"
|
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
|
||||||
:class="organized[t.hash]
|
<span class="text-[9px] tracking-widest px-2 py-0.5 border border-red-500/40 text-red-400 cursor-default">
|
||||||
? 'border-green-500/40 text-green-500'
|
{{ t.errorFiles.length }} ERREUR{{ t.errorFiles.length > 1 ? 'S' : '' }}
|
||||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#e8513a] hover:text-[#e8513a]'">
|
</span>
|
||||||
{{ organized[t.hash] ? 'ORGANISÉ ✓' : organizing[t.hash] ? '...' : 'ORGANISER' }}
|
<!-- Tooltip -->
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
|
<!-- Badge statut organisation -->
|
||||||
|
<span class="text-[9px] tracking-widest px-2 py-0.5 border"
|
||||||
|
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
|
||||||
|
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
|
||||||
|
'border-[#1e2d3d] text-[#5a7a94]'">
|
||||||
|
{{ t.organizeState === 'done' ? `ORGANISÉ (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||||
|
t.organizeState === 'partial' ? `EN COURS (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||||
|
'NON ORGANISÉ' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Bouton organiser si pas encore fait -->
|
||||||
|
<button v-if="t.organizeState !== 'done'"
|
||||||
|
@click="organize(t)"
|
||||||
|
:disabled="organizing[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">
|
||||||
|
{{ organizing[t.hash] ? '...' : 'ORGANISER' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +158,6 @@ const loading = ref(true)
|
|||||||
const polling = ref(true)
|
const polling = ref(true)
|
||||||
const noClients = ref(false)
|
const noClients = ref(false)
|
||||||
const organizing = ref<Record<string, boolean>>({})
|
const organizing = ref<Record<string, boolean>>({})
|
||||||
const organized = ref<Record<string, boolean>>({})
|
|
||||||
|
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<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>
|
||||||
+30
-13
@@ -87,20 +87,34 @@
|
|||||||
|
|
||||||
<!-- Dossier complétion -->
|
<!-- Dossier complétion -->
|
||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">DOSSIER COMPLÉTION</label>
|
<div class="flex items-center gap-2 mb-1.5">
|
||||||
<input v-model="form.completePath"
|
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER COMPLÉTION</label>
|
||||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="form.completePath" :disabled="isDocker"
|
||||||
|
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 outline-none transition-colors"
|
||||||
|
:class="isDocker ? 'text-[#5a7a94] cursor-not-allowed opacity-60' : 'text-white focus:border-[#e8513a]'"
|
||||||
placeholder="/downloads/complete"/>
|
placeholder="/downloads/complete"/>
|
||||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier de complétion de qBittorrent (monté dans Docker)</p>
|
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
||||||
|
<span v-if="isDocker">Défini par le volume Docker — non modifiable ici</span>
|
||||||
|
<span v-else>Dossier de complétion de qBittorrent</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dossier Jellyfin -->
|
<!-- Dossier Jellyfin -->
|
||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">DOSSIER JELLYFIN</label>
|
<div class="flex items-center gap-2 mb-1.5">
|
||||||
<input v-model="form.mediaPath"
|
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER JELLYFIN</label>
|
||||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="form.mediaPath" :disabled="isDocker"
|
||||||
|
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 outline-none transition-colors"
|
||||||
|
:class="isDocker ? 'text-[#5a7a94] cursor-not-allowed opacity-60' : 'text-white focus:border-[#e8513a]'"
|
||||||
placeholder="/media/Kai"/>
|
placeholder="/media/Kai"/>
|
||||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier racine de la bibliothèque Fankai dans Jellyfin</p>
|
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
||||||
|
<span v-if="isDocker">Défini par le volume Docker — non modifiable ici</span>
|
||||||
|
<span v-else>Dossier racine de la bibliothèque Fankai dans Jellyfin</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mode organisation -->
|
<!-- Mode organisation -->
|
||||||
@@ -215,8 +229,9 @@ const form = ref({
|
|||||||
organizeMode: 'hardlink' as 'hardlink' | 'move',
|
organizeMode: 'hardlink' as 'hardlink' | 'move',
|
||||||
})
|
})
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const updating = ref(false)
|
const updating = ref(false)
|
||||||
|
const isDocker = ref(false)
|
||||||
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
|
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
|
||||||
|
|
||||||
const clients = ref<any[]>([])
|
const clients = ref<any[]>([])
|
||||||
@@ -244,18 +259,20 @@ function clientLabel(type: string): string {
|
|||||||
|
|
||||||
// ── Init ───────────────────────────────────────────────────────
|
// ── Init ───────────────────────────────────────────────────────
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [settingsRes, statusRes, clientsRes, availRes] = await Promise.all([
|
const [settingsRes, statusRes, clientsRes, availRes, systemRes] = await Promise.all([
|
||||||
fetch('/api/settings', { credentials: 'include' }),
|
fetch('/api/settings', { credentials: 'include' }),
|
||||||
fetch('/api/torrents/status', { credentials: 'include' }),
|
fetch('/api/torrents/status', { credentials: 'include' }),
|
||||||
fetch('/api/torrent-clients', { credentials: 'include' }),
|
fetch('/api/torrent-clients', { credentials: 'include' }),
|
||||||
fetch('/api/torrent-clients/available', { 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 (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
|
||||||
if (statusRes.ok) torrentsStatus.value = await statusRes.json()
|
if (statusRes.ok) torrentsStatus.value = await statusRes.json()
|
||||||
if (clientsRes.ok) clients.value = await clientsRes.json()
|
if (clientsRes.ok) clients.value = await clientsRes.json()
|
||||||
if (availRes.ok) availableClients.value = await availRes.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)
|
for (const c of clients.value) await refreshHealth(c.uuid)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Healthcheck ────────────────────────────────────────────────
|
// ── Healthcheck ────────────────────────────────────────────────
|
||||||
@@ -345,7 +362,7 @@ async function saveClient() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const client = await res.json()
|
const client = await res.json()
|
||||||
clients.value.push(client)
|
clients.value.push(client)
|
||||||
refreshHealth(client.uuid)
|
await refreshHealth(client.uuid)
|
||||||
closeModal()
|
closeModal()
|
||||||
toast('Client enregistré ✓', 'success')
|
toast('Client enregistré ✓', 'success')
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in new issue
Block a user