Compare commits

...
31 Commits
Author SHA1 Message Date
MasutaYunikon b8f0984ec6 Merge pull request #9 from Masutayunikon/release-please--branches--main
chore(main): release 1.2.0
2026-03-11 14:11:33 +01:00
MasutaYunikon e3bca6be87 chore(main): release 1.2.0 2026-03-11 14:10:26 +01:00
MasutaYunikon b88e020fb5 feat(ui): états catalogue + logs + badges erreurs downloads
- catalog: badge ORGANISÉ/PARTIEL/EN COURS sur les affiches (download_state)
- catalog: filtres Téléchargés et En cours
- catalog: icône logs dans le header
- catalog: polling notifications organisation (toast auto)
- api: /api/series enrichi avec download_state par série
- api: /api/logs + /api/logs/clear avec filtre level/source/limit
- api: /api/downloads enrichi avec errorFiles depuis recentOrganized
- logger: module centralisé logs.jsonl, rotation 2000 lignes, debug masqué en prod
- downloads: badge erreurs avec tooltip fichiers en erreur au hover
- downloads: badge organisation NON ORGANISÉ/EN COURS/ORGANISÉ avec compteur
- logs: page /logs avec table, filtres niveau/source, choix 100/500/tout, taille fichier, clear
- organize: callback onResult expose errorFiles
- organize-worker: logs candidats absents passés en debug
2026-03-11 14:10:04 +01:00
MasutaYunikon 7f3f8656b3 Merge remote-tracking branch 'origin/main' 2026-03-11 13:44:59 +01:00
MasutaYunikon 97da422037 feat(organize): worker thread + badges statut + notifications
- api: /api/organize/recent pour les notifications frontend
- api: /api/system pour détecter l'environnement Docker
- ui: badge organisation (NON ORGANISÉ / EN COURS / ORGANISÉ) dans DownloadsView
- ui: icône download avec compteur actifs dans le header CatalogView
- ui: toast automatique quand l'auto-organisation se termine
- ui: paths en lecture seule avec badge DOCKER dans SettingsView
2026-03-11 13:44:51 +01:00
MasutaYunikon 43dbfefc7a Merge pull request #8 from Masutayunikon/release-please--branches--main
chore(main): release 1.1.0
2026-03-11 00:50:58 +01:00
MasutaYunikon 0c576fb59f chore(main): release 1.1.0 2026-03-11 00:46:28 +01:00
MasutaYunikon c097cd4d1f fix: skip excluded folders 2026-03-11 00:46:12 +01:00
MasutaYunikon e67efe39f4 Merge remote-tracking branch 'origin/main' 2026-03-11 00:35:49 +01:00
MasutaYunikon 237530321e feat: make the organizer threaded to not consumme api main thread 2026-03-11 00:35:41 +01:00
MasutaYunikon 91c27cf2f3 Merge pull request #7 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.6
2026-03-10 23:58:26 +01:00
MasutaYunikon 19d09de88e chore(main): release 1.0.6 2026-03-10 23:58:14 +01:00
MasutaYunikon 5850265f6d Merge remote-tracking branch 'origin/main' 2026-03-10 23:57:54 +01:00
MasutaYunikon 27f55ab2be fix: remove user creation in entrypoint.sh 2026-03-10 23:57:46 +01:00
MasutaYunikon f871644330 Merge pull request #6 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.5
2026-03-10 23:52:29 +01:00
MasutaYunikon a5e97088c4 chore(main): release 1.0.5 2026-03-10 23:52:14 +01:00
MasutaYunikon ca4542d71b Merge remote-tracking branch 'origin/main' 2026-03-10 23:51:58 +01:00
MasutaYunikon 61418c6f77 fix: add user permissions when the scripts is launched 2026-03-10 23:51:50 +01:00
MasutaYunikon 1d89a8f61e Merge pull request #5 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.4
2026-03-10 23:37:40 +01:00
MasutaYunikon db5454393f chore(main): release 1.0.4 2026-03-10 23:37:16 +01:00
MasutaYunikon e18c6a1c54 fix: add log for debug 2026-03-10 23:37:06 +01:00
MasutaYunikon 49d60aeabd Merge remote-tracking branch 'origin/main' 2026-03-10 23:36:57 +01:00
MasutaYunikon c78537c9d8 fix: add log for debug 2026-03-10 23:36:46 +01:00
MasutaYunikon def689a1ca Merge pull request #4 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.3
2026-03-10 23:27:12 +01:00
MasutaYunikon 8bddc61d41 chore(main): release 1.0.3 2026-03-10 23:25:18 +01:00
MasutaYunikon b6587acaf4 Merge remote-tracking branch 'origin/main' 2026-03-10 23:25:01 +01:00
MasutaYunikon dd1ead4568 fix: organize file 2026-03-10 23:24:51 +01:00
MasutaYunikon d83c060ab9 Merge pull request #3 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.2
2026-03-10 23:22:54 +01:00
MasutaYunikon f214f36cb2 chore(main): release 1.0.2 2026-03-10 23:22:21 +01:00
MasutaYunikon 6e654844aa Merge remote-tracking branch 'origin/main' 2026-03-10 23:22:03 +01:00
MasutaYunikon 38282fd854 fix: organize use torrent hash and not name 2026-03-10 23:21:56 +01:00
12 changed files with 1087 additions and 276 deletions

No files matched your search

+56
View File
@@ -1,5 +1,61 @@
# 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)
### Features
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
### Bug Fixes
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
## [1.0.6](https://github.com/Masutayunikon/FanKarr/compare/v1.0.5...v1.0.6) (2026-03-10)
### Bug Fixes
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
## [1.0.5](https://github.com/Masutayunikon/FanKarr/compare/v1.0.4...v1.0.5) (2026-03-10)
### Bug Fixes
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
## [1.0.4](https://github.com/Masutayunikon/FanKarr/compare/v1.0.3...v1.0.4) (2026-03-10)
### Bug Fixes
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
## [1.0.3](https://github.com/Masutayunikon/FanKarr/compare/v1.0.2...v1.0.3) (2026-03-10)
### Bug Fixes
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
## [1.0.2](https://github.com/Masutayunikon/FanKarr/compare/v1.0.1...v1.0.2) (2026-03-10)
### Bug Fixes
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
## [1.0.1](https://github.com/Masutayunikon/FanKarr/compare/v1.0.0...v1.0.1) (2026-03-10)
+7 -3
View File
@@ -15,13 +15,17 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
# ── Production ─────────────────────────────────────────────────
FROM base
FROM node:20-slim
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist/server /app/dist/server
COPY --from=build /app/dist/client /app/public
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN mkdir -p /app/data
EXPOSE 3001
ENV NODE_ENV=production
CMD ["node", "dist/server/index.js"]
ENTRYPOINT ["/entrypoint.sh"]
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -e
PUID=${PUID:-1000}
PGID=${PGID:-1000}
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
# Corriger les permissions du dossier data
chown -R "$PUID:$PGID" /app/data
exec gosu "$PUID:$PGID" node dist/server/index.js
+157 -9
View File
@@ -11,6 +11,7 @@ import {
} from './torrent-clients/index.js'
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import { organizeTorrent, autoOrganizeAll } from './organize.js'
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
registerDriver(qbittorrentDriver)
@@ -19,7 +20,7 @@ const PORT = 3001
const FANKAI_API = 'https://metadata.fankai.fr'
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
?? 'https://raw.githubusercontent.com/CHANGE_ME/fankarr-scraper/main/data/torrent_final.json'
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json'
app.use(express.json())
app.use(cookieParser())
@@ -108,16 +109,73 @@ function indexBySerie(torrents: any[]): Map<number, any[]> {
}
// ── 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) => {
try {
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())])
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 ?? [])
res.json({ series: seriesList.map((serie: any) => ({
...serie,
torrent_count: byId.get(serie.id)?.length ?? 0,
has_torrents : byId.has(serie.id)
}))})
res.json({ series: seriesList.map((serie: any) => {
const serieTorrents = byId.get(serie.id) ?? []
return {
...serie,
torrent_count : serieTorrents.length,
has_torrents : serieTorrents.length > 0,
download_state: computeSerieDownloadState(serieTorrents, organized, activeTorrents),
}
})})
} catch (err) {
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 {
const { category } = readSettings()
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) {
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) => {
const { hash, name, save_path } = req.body
if (!hash || !name || !save_path) {
@@ -252,6 +372,27 @@ app.post('/api/organize', requireAuth, async (req, res) => {
})
// ── 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) => {
try {
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
@@ -299,7 +440,14 @@ app.listen(PORT, () => {
const autoOrganize = async () => {
try {
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) {
console.error('[organize] Erreur auto-organise:', err)
}
+105
View File
@@ -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 }
}
+263
View File
@@ -0,0 +1,263 @@
/**
* organize-worker.ts
* ==================
* Worker thread dédié à l'organisation des fichiers.
* Tourne dans un thread séparé pour ne pas bloquer le event loop Express.
*
* Messages reçus (parentPort.on) : { type: 'run', torrents: TorrentInfo[] }
* Messages envoyés (parentPort.postMessage) :
* { type: 'log', level: 'info'|'warn'|'error', msg: string }
* { type: 'result', hash: string, name: string, done, skipped, errors }
* { type: 'done' }
*/
import fs from 'fs'
import fsp from 'fs/promises'
import path from 'path'
import { parentPort } from 'worker_threads'
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
// ─── Utils log ────────────────────────────────────────────────
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 }) }
// ─── Settings (lu directement, pas d'import circulaire) ──────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string } {
try {
const p = path.join(process.cwd(), 'data', 'settings.json')
if (!fs.existsSync(p)) return { mediaPath: '/media/Kai', completePath: '/downloads/complete', organizeMode: 'hardlink' }
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '/media/Kai', completePath: '/downloads/complete', organizeMode: 'hardlink' } }
}
// ─── Organized log ────────────────────────────────────────────
function loadOrganized(): Record<string, Record<string, string>> {
try {
if (!fs.existsSync(ORGANIZED_PATH)) return {}
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch { return {} }
}
function markOrganized(hash: string, filename: string) {
const data = loadOrganized()
if (!data[hash]) data[hash] = {}
data[hash][filename] = new Date().toISOString()
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
function isOrganized(hash: string, filename: string): boolean {
return !!loadOrganized()[hash]?.[filename]
}
// ─── Torrent lookup ───────────────────────────────────────────
function findTorrentByHash(hash: string): any | null {
try {
if (!fs.existsSync(TORRENTS_PATH)) return null
const torrents = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
return torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) ?? null
} catch { return null }
}
// ─── Dossiers exclus (bonus, musique, images) ────────────────
const EXCLUDED_FOLDERS = new Set([
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
'images', 'image', 'scans', 'scan', 'soundtrack', 'music',
])
function isInExcludedFolder(filePath: string[]): boolean {
for (const folder of filePath.slice(0, -1)) {
const f = folder.toLowerCase().trim()
if (EXCLUDED_FOLDERS.has(f)) return true
for (const excl of EXCLUDED_FOLDERS) {
if (f.startsWith(excl)) return true
}
}
return false
}
// ─── Filesystem ops ───────────────────────────────────────────
function seasonFolder(n: number): string {
return `Saison ${String(n).padStart(3, '0')}`
}
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
catch (err) {
warn(`[organize] hardlink échoué (${err instanceof Error ? err.message : err})`)
return false
}
}
// ─── Organise un torrent ──────────────────────────────────────
async function organizeTorrent(hash: string, name: string, savePath: string) {
const { mediaPath, completePath, organizeMode } = readSettings()
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
log(`[organize] ── ${name} ──`)
log(`[organize] hash=${hash} save_path=${savePath} mode=${organizeMode}`)
const torrent = findTorrentByHash(hash)
if (!torrent) {
error(`[organize] ✗ Hash ${hash} introuvable dans torrent_final.json`)
throw new Error(`Torrent introuvable : ${hash}`)
}
log(`[organize] ✓ Trouvé : ${torrent.raw}`)
const serieTitle = torrent.serie_title || torrent.show_title || name
const torrentFiles: any[] = torrent.torrent_files ?? []
log(`[organize] serie=${serieTitle} fichiers=${torrentFiles.length}`)
// ── Cas fichier unique ────────────────────────────────────
if (torrentFiles.length === 0) {
log(`[organize] torrent_files vide → tentative fichier unique`)
const candidates = [path.join(savePath, name), path.join(completePath, name)]
let src: string | null = null
for (const c of candidates) {
log(`[organize] candidate: ${c} → ${fs.existsSync(c) ? '✓' : '✗'}`)
if (fs.existsSync(c)) { src = c; break }
}
if (!src) throw new Error(`Source introuvable pour "${name}"`)
const seasonNum = torrent.resolved_episodes?.[0]?.season_number ?? 1
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, name)
log(`[organize] dest: ${dest}`)
if (!fs.existsSync(dest)) {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
} else {
await fsp.copyFile(src, dest)
await fsp.unlink(src)
}
log(`[organize] ✓ ${organizeMode} OK`)
} else {
log(`[organize] → dest existe déjà, skip`)
}
markOrganized(hash, name)
return { ...result, total: 1, done: 1 }
}
// ── Map filename → season ─────────────────────────────────
const filenameSeasonMap = new Map<string, number>()
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.filename && ep.season_number !== undefined)
filenameSeasonMap.set(ep.filename, ep.season_number)
}
result.total = torrentFiles.length
for (const tf of torrentFiles) {
const filename = tf.filename
const filePath: string[] = tf.path
// Skip les fichiers dans des dossiers bonus/musique/images
if (isInExcludedFolder(filePath)) {
log(`[organize] skip bonus: ${filename}`)
result.skipped++
continue
}
if (isOrganized(hash, filename)) { result.skipped++; continue }
const candidates = [
path.join(savePath, name, ...filePath.slice(0, -1), filename),
path.join(savePath, ...filePath.slice(0, -1), filename),
path.join(savePath, name, filename),
path.join(savePath, filename),
path.join(completePath, name, ...filePath.slice(0, -1), filename),
path.join(completePath, name, filename),
path.join(completePath, filename),
]
let src: string | null = null
for (const c of candidates) {
if (fs.existsSync(c)) { src = c; break }
}
if (!src) {
const tried = candidates.map(c => ` • ${c}`).join('\n')
error(`[organize] ✗ ${filename} introuvable:\n${tried}`)
result.errors.push({ file: filename, error: 'Source introuvable' })
continue
}
const seasonNum = filenameSeasonMap.get(filename) ?? 1
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, filename)
if (fs.existsSync(dest)) {
markOrganized(hash, filename)
result.skipped++
continue
}
try {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
} else {
await fsp.copyFile(src, dest)
await fsp.unlink(src)
}
markOrganized(hash, filename)
result.done++
log(`[organize] ✓ ${filename} → Saison ${String(seasonNum).padStart(3,'0')}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
error(`[organize] ✗ ${filename}: ${msg}`)
result.errors.push({ file: filename, error: msg })
}
}
log(`[organize] ── Résultat ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
return result
}
// ─── Boucle principale du worker ─────────────────────────────
parentPort?.on('message', async (msg: any) => {
if (msg.type !== 'run') return
const torrents: any[] = msg.torrents
const organized = loadOrganized()
for (const t of torrents) {
if (t.state !== 'seeding') continue
const torrent = findTorrentByHash(t.hash)
if (!torrent) {
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé`)
continue
}
const files: any[] = torrent.torrent_files ?? []
// Pour fichier unique : vérifier via le name directement
const allDone = files.length > 0
? files.every((f: any) => organized[t.hash]?.[f.filename])
: !!organized[t.hash]?.[t.name]
if (allDone) continue
try {
const result = await organizeTorrent(t.hash, t.name, t.save_path)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
} catch (err) {
error(`[organize] Erreur ${t.name}: ${err instanceof Error ? err.message : err}`)
}
}
parentPort?.postMessage({ type: 'done' })
})
+110 -229
View File
@@ -1,83 +1,15 @@
/**
* Organizer
* =========
* Déplace ou hardlinke les fichiers téléchargés vers la médiathèque Jellyfin.
* Incrémental : skip les fichiers déjà organisés.
*
* Structure dest :
* mediaPath / serie_title / Saison 001 / filename.mkv
* organize.ts
* ===========
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
* Le worker gère tout le filesystem (copy, hardlink, move).
*/
import fs from 'fs'
import path from 'path'
import { readSettings } from './settings.js'
import path from 'path'
import { Worker } from 'worker_threads'
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
// ─── Organized log ────────────────────────────────────────────
function loadOrganized(): Record<string, Record<string, string>> {
try {
if (!fs.existsSync(ORGANIZED_PATH)) return {}
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch { return {} }
}
function saveOrganized(data: Record<string, Record<string, string>>) {
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
function markOrganized(hash: string, filename: string) {
const data = loadOrganized()
if (!data[hash]) data[hash] = {}
data[hash][filename] = new Date().toISOString()
saveOrganized(data)
}
function isOrganized(hash: string, filename: string): boolean {
const data = loadOrganized()
return !!data[hash]?.[filename]
}
// ─── Torrent lookup ───────────────────────────────────────────
function readTorrents(): any[] {
try {
if (!fs.existsSync(TORRENTS_PATH)) return []
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
} catch { return [] }
}
function findTorrentByName(name: string): any | null {
const torrents = readTorrents()
return torrents.find(t => t.raw === name) ?? null
}
// ─── Filesystem ops ───────────────────────────────────────────
function ensureDir(dir: string) {
fs.mkdirSync(dir, { recursive: true })
}
function seasonFolder(n: number): string {
return `Saison ${String(n).padStart(3, '0')}`
}
function tryHardlink(src: string, dest: string): boolean {
try {
fs.linkSync(src, dest)
return true
} catch {
return false
}
}
function copyFile(src: string, dest: string) {
fs.copyFileSync(src, dest)
}
// ─── Résultat d'organisation ──────────────────────────────────
// Un seul worker à la fois
let workerRunning = false
export interface OrganizeResult {
total : number
@@ -86,163 +18,112 @@ export interface OrganizeResult {
errors : { file: string; error: string }[]
}
// ─── Organise un torrent ──────────────────────────────────────
export async function organizeTorrent(
hash : string,
name : string, // nom du dossier torrent dans completePath
savePath: string // save_path retourné par qBittorrent
): Promise<OrganizeResult> {
const settings = readSettings()
const { mediaPath, completePath, organizeMode } = settings
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
// Trouver le torrent dans torrent_final.json
const torrent = findTorrentByName(name)
if (!torrent) {
throw new Error(`Torrent introuvable dans torrent_final.json : "${name}"`)
}
const serieTitle = torrent.serie_title || torrent.show_title || name
const torrentFiles: any[] = torrent.torrent_files ?? []
if (torrentFiles.length === 0) {
throw new Error(`Aucun fichier trouvé dans le torrent "${name}"`)
}
// Résoudre season_number par episode_id
const epSeasonMap = new Map<number, number>()
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.filename) {
epSeasonMap.set(ep.episode_number, ep.season_number)
}
}
// Map filename → season_number
const filenameSeasonMap = new Map<string, number>()
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.filename && ep.season_number !== undefined) {
filenameSeasonMap.set(ep.filename, ep.season_number)
}
}
result.total = torrentFiles.length
for (const tf of torrentFiles) {
const filename = tf.filename
const filePath: string[] = tf.path // ex: ["Saga 1 - ...", "file.mkv"]
// Skip si déjà organisé
if (isOrganized(hash, filename)) {
result.skipped++
continue
}
// Construire le chemin source
// save_path peut déjà contenir le nom du dossier ou pas selon le client
const torrentDir = path.join(savePath, name)
const torrentDirAlt = savePath // au cas où save_path inclut déjà le nom
// Chemin source = dossier torrent + sous-dossiers + fichier
const subPath = filePath.length > 1
? path.join(...filePath.slice(0, -1), filename)
: filename
let src = path.join(torrentDir, subPath)
if (!fs.existsSync(src)) {
// Fallback: save_path sans le nom du torrent
src = path.join(torrentDirAlt, subPath)
}
if (!fs.existsSync(src)) {
// Fallback: fichier directement dans save_path/name
src = path.join(torrentDir, filename)
}
if (!fs.existsSync(src)) {
result.errors.push({ file: filename, error: `Source introuvable : ${src}` })
continue
}
// Déterminer la saison
let seasonNum = filenameSeasonMap.get(filename)
if (seasonNum === undefined) {
// Fallback : season_number = 1
seasonNum = 1
}
// Construire le chemin destination
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, filename)
// Skip si dest existe déjà
if (fs.existsSync(dest)) {
markOrganized(hash, filename)
result.skipped++
continue
}
try {
ensureDir(destDir)
if (organizeMode === 'hardlink') {
const ok = tryHardlink(src, dest)
if (!ok) {
// Hardlink impossible (filesystems différents) → copy
console.warn(`[organize] hardlink échoué pour ${filename}, fallback copy`)
copyFile(src, dest)
}
} else {
// move = copy + delete source
copyFile(src, dest)
fs.unlinkSync(src)
}
markOrganized(hash, filename)
result.done++
console.log(`[organize] ${organizeMode} ${filename} → ${dest}`)
} catch (err) {
result.errors.push({
file : filename,
error: err instanceof Error ? err.message : 'Erreur inconnue'
})
}
}
return result
}
// ─── Auto-organise polling ────────────────────────────────────
// Appelé depuis index.ts au démarrage, toutes les 30s
// Détecte les torrents "seeding" non encore organisés
/**
* Lance l'auto-organisation dans un worker thread.
* listFn est appelée dans le thread principal (accès qBittorrent),
* puis les données sont passées au worker.
*/
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> {
if (workerRunning) {
console.log('[organize] Worker déjà en cours, skip')
return
}
let torrents: any[]
try {
torrents = await listFn()
} catch { return }
const organized = loadOrganized()
for (const t of torrents) {
if (t.state !== 'seeding') continue
// Vérifier si tous les fichiers sont déjà organisés
const torrent = findTorrentByName(t.name)
if (!torrent) continue
const files: any[] = torrent.torrent_files ?? []
if (files.length === 0) continue
const allDone = files.every(f => organized[t.hash]?.[f.filename])
if (allDone) continue
console.log(`[organize] Auto-organisation : ${t.name}`)
try {
const result = await organizeTorrent(t.hash, t.name, t.save_path)
console.log(`[organize] ${t.name} → ${result.done} fichiers, ${result.skipped} skippés, ${result.errors.length} erreurs`)
} catch (err) {
console.error(`[organize] Erreur pour ${t.name}:`, err)
}
} catch (err) {
console.error('[organize] Impossible de récupérer la liste des torrents:', err)
return
}
if (torrents.length === 0) return
const seedingCount = torrents.filter(t => t.state === 'seeding').length
if (seedingCount === 0) return
console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`)
workerRunning = true
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
const worker = new Worker(workerPath)
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
if (msg.level === 'error') console.error(msg.msg)
else if (msg.level === 'warn') console.warn(msg.msg)
else console.log(msg.msg)
} else if (msg.type === 'result') {
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') {
console.log('[organize] Worker terminé')
workerRunning = false
worker.terminate()
}
})
worker.on('error', (err) => {
console.error('[organize] Worker erreur:', err)
workerRunning = false
})
worker.on('exit', (code) => {
if (code !== 0) console.error(`[organize] Worker exit code ${code}`)
workerRunning = false
})
// Envoyer les torrents au worker
worker.postMessage({ type: 'run', torrents })
}
/**
* Organisation manuelle d'un torrent (appelée depuis l'API /organize).
* Lance aussi dans un worker thread.
*/
export async function organizeTorrent(
hash : string,
name : string,
savePath: string
): Promise<OrganizeResult> {
return new Promise((resolve, reject) => {
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
const worker = new Worker(workerPath)
// On envoie un seul torrent "fictif seeding"
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
if (msg.level === 'error') console.error(msg.msg)
else if (msg.level === 'warn') console.warn(msg.msg)
else console.log(msg.msg)
} else if (msg.type === 'result') {
result.total = msg.total
result.skipped = msg.skipped
result.done = msg.done
result.errors = msg.errors
} else if (msg.type === 'done') {
worker.terminate()
resolve(result)
}
})
worker.on('error', (err) => { worker.terminate(); reject(err) })
worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) })
worker.postMessage({ type: 'run', torrents: [fakeTorrent] })
})
}
+4
View File
@@ -25,6 +25,10 @@ const router = createRouter({
{
path: '/settings',
component: () => import('@/views/SettingsView.vue')
},
{
path: '/logs',
component: () => import('@/views/LogsView.vue')
}
],
scrollBehavior(to, _from, savedPosition) {
+96 -11
View File
@@ -8,14 +8,31 @@
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
</span>
<div class="flex items-center gap-2">
<router-link to="/downloads"
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition relative">
<!-- Icône logs -->
<router-link to="/logs"
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
>
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="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>
<!-- 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"
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
to="/settings"
@@ -120,6 +137,27 @@
{{ serie.torrent_count }}
</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 -->
<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>
@@ -148,30 +186,35 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useSeriesStore } from '@/stores/series'
import { useAuthStore } from '@/stores/auth'
defineOptions({ name: 'CatalogView' })
import { useToast } from '@/composables/useToast'
const store = useSeriesStore()
const auth = useAuthStore()
const router = useRouter()
const { add: toast } = useToast()
const search = ref('')
const activeFilter = ref('all')
const activeDownloads = ref(0)
const filters = [
{ label: 'Toutes', value: 'all' },
{ label: 'Toutes', value: 'all' },
{ label: 'Disponibles', value: 'available' },
{ label: 'Téléchargés', value: 'complete' },
{ label: 'En cours', value: 'downloading' },
{ label: 'Sans torrent', value: 'unavailable' },
]
const filtered = computed(() => {
let list = store.series
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
if (activeFilter.value === 'downloading') list = list.filter(s => s.download_state === 'downloading' || s.download_state === 'partial')
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(s => s.title.toLowerCase().includes(q))
@@ -184,7 +227,49 @@ async function handleLogout() {
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(() => {
if (store.series.length === 0) store.fetchSeries()
fetchActiveDownloads()
fetchOrganizeNotifs()
dlInterval = setInterval(() => {
fetchActiveDownloads()
fetchOrganizeNotifs()
}, 10000)
})
onUnmounted(() => {
if (dlInterval) clearInterval(dlInterval)
})
</script>
+38 -11
View File
@@ -103,16 +103,44 @@
</span>
</div>
<!-- Bouton organiser si complété -->
<button v-if="t.state === 'seeding'"
@click="organize(t)"
:disabled="organizing[t.hash]"
class="text-[10px] tracking-widest px-3 py-1 border transition-colors cursor-pointer"
:class="organized[t.hash]
? 'border-green-500/40 text-green-500'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#e8513a] hover:text-[#e8513a]'">
{{ organized[t.hash] ? 'ORGANISÉ ✓' : organizing[t.hash] ? '...' : 'ORGANISER' }}
</button>
<!-- Badge organisation -->
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
<!-- Badge erreurs avec tooltip -->
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
<span class="text-[9px] tracking-widest px-2 py-0.5 border border-red-500/40 text-red-400 cursor-default">
{{ t.errorFiles.length }} ERREUR{{ t.errorFiles.length > 1 ? 'S' : '' }}
</span>
<!-- Tooltip -->
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
<div class="bg-[#0d1219] border border-red-500/30 p-3 text-[10px] shadow-xl">
<div class="text-red-400 tracking-widest mb-2">FICHIERS EN ERREUR</div>
<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>
@@ -130,7 +158,6 @@ const loading = ref(true)
const polling = ref(true)
const noClients = ref(false)
const organizing = ref<Record<string, boolean>>({})
const organized = ref<Record<string, boolean>>({})
let pollInterval: ReturnType<typeof setInterval> | null = null
+209
View File
@@ -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
View File
@@ -87,20 +87,34 @@
<!-- Dossier complétion -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">DOSSIER COMPLÉTION</label>
<input v-model="form.completePath"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
<div class="flex items-center gap-2 mb-1.5">
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER COMPLÉTION</label>
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<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"/>
<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>
<!-- Dossier Jellyfin -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">DOSSIER JELLYFIN</label>
<input v-model="form.mediaPath"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
<div class="flex items-center gap-2 mb-1.5">
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER JELLYFIN</label>
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<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"/>
<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>
<!-- Mode organisation -->
@@ -215,8 +229,9 @@ const form = ref({
organizeMode: 'hardlink' as 'hardlink' | 'move',
})
const saving = ref(false)
const updating = ref(false)
const saving = ref(false)
const updating = ref(false)
const isDocker = ref(false)
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
const clients = ref<any[]>([])
@@ -244,18 +259,20 @@ function clientLabel(type: string): string {
// ── Init ───────────────────────────────────────────────────────
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/torrents/status', { credentials: 'include' }),
fetch('/api/torrent-clients', { credentials: 'include' }),
fetch('/api/torrent-clients/available', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
])
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (statusRes.ok) torrentsStatus.value = await statusRes.json()
if (clientsRes.ok) clients.value = await clientsRes.json()
if (availRes.ok) availableClients.value = await availRes.json()
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
for (const c of clients.value) refreshHealth(c.uuid)
for (const c of clients.value) await refreshHealth(c.uuid)
})
// ── Healthcheck ────────────────────────────────────────────────
@@ -345,7 +362,7 @@ async function saveClient() {
if (res.ok) {
const client = await res.json()
clients.value.push(client)
refreshHealth(client.uuid)
await refreshHealth(client.uuid)
closeModal()
toast('Client enregistré ✓', 'success')
} else {