Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44ac228ad6 | ||
|
|
1a4adabef9 | ||
|
|
a6fc655cb7 | ||
|
|
c0fb601ef0 | ||
|
|
b9b940e874 | ||
|
|
5a006664c0 | ||
|
|
43c4422b4f | ||
|
|
6410fcee1f | ||
|
|
3b281b60ee | ||
|
|
0e6e13332f | ||
|
|
b8f0984ec6 | ||
|
|
e3bca6be87 | ||
|
|
b88e020fb5 | ||
|
|
7f3f8656b3 | ||
|
|
97da422037 |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 859 KiB |
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.0](https://github.com/Masutayunikon/FanKarr/compare/v1.3.0...v1.4.0) (2026-03-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
|
||||
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
|
||||
|
||||
## [1.3.0](https://github.com/Masutayunikon/FanKarr/compare/v1.2.0...v1.3.0) (2026-03-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
|
||||
|
||||
## [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,5 +1,168 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
<div align="center">
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
# Fankarr
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
|
||||
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Vue catalogue : toutes les séries Fankai avec leur état d'organisation. Les badges indiquent si les fichiers sont déjà présents dans votre médiathèque Jellyfin.
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Catalogue complet** des séries Fankai avec affiches, statut et badges d'état
|
||||
- **Téléchargement en un clic** vers qBittorrent — par épisode, par saison ou intégrale
|
||||
- **Organisation automatique** des fichiers téléchargés vers votre médiathèque (hardlink ou déplacement)
|
||||
- **Suivi par épisode** — badge ✓ organisé sur chaque épisode et compteur par saison
|
||||
- **Notifications** toast automatiques quand une organisation se termine
|
||||
- **Page téléchargements** avec statut d'organisation et badge erreurs avec détail au hover
|
||||
- **Logs centralisés** avec filtre par niveau/source, rotation automatique et clear depuis l'UI
|
||||
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
|
||||
- **Authentification** par mot de passe avec session JWT
|
||||
- **Compatible Runtipi / Docker**
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Vue série : détail des épisodes avec leur état de disponibilité torrent et d'organisation dans Jellyfin.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Vue téléchargements : suivi en temps réel de l'organisation avec détail des erreurs au survol.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Page logs : historique complet des événements avec filtres et gestion de la taille.
|
||||
|
||||
---
|
||||
|
||||
## Données torrent — Scraper
|
||||
|
||||
Les données du catalogue sont récupérées depuis le repo [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
|
||||
|
||||
Ce repo contient un pipeline de scripts Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, puis les croise avec l'API metadata de Fankai pour identifier chaque épisode. Les scripts tournent automatiquement **toutes les 6 heures** via GitHub Actions et publient le fichier `torrent_final.json` dans le repo.
|
||||
|
||||
Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Prérequis
|
||||
|
||||
- Docker + Docker Compose
|
||||
- qBittorrent accessible en réseau
|
||||
- Un dossier de téléchargements complétés (`/downloads/complete` ou équivalent)
|
||||
- Un dossier médiathèque pour Jellyfin (`/media/Kai` ou équivalent)
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
fankarr:
|
||||
image: masutayunikon/fankarr:latest
|
||||
container_name: fankarr
|
||||
environment:
|
||||
- PUID=1000 # UID de votre utilisateur (id -u)
|
||||
- PGID=1000 # GID de votre utilisateur (id -g)
|
||||
- TZ=Europe/Paris # Votre timezone
|
||||
volumes:
|
||||
- ./data:/app/data # Config, logs, base de données
|
||||
- /votre/chemin/media:/media/Kai # Destination Jellyfin
|
||||
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
|
||||
ports:
|
||||
- 3001:3001
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Fankarr sera accessible sur `http://localhost:3001`.
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Défaut | Description |
|
||||
|---|---|---|
|
||||
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers |
|
||||
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers |
|
||||
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
|
||||
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
|
||||
|
||||
### Premier démarrage
|
||||
|
||||
1. Ouvrir `http://localhost:3001`
|
||||
2. Créer un mot de passe à la première connexion
|
||||
3. Aller dans **Paramètres** → ajouter votre client qBittorrent
|
||||
4. Vérifier les chemins `completePath` et `mediaPath`
|
||||
5. Choisir le mode d'organisation : `hardlink` (recommandé) ou `move`
|
||||
6. Retourner au catalogue et télécharger
|
||||
|
||||
---
|
||||
|
||||
## Organisation des fichiers
|
||||
|
||||
Fankarr organise automatiquement les fichiers terminés toutes les **30 secondes** vers la structure attendue par Jellyfin :
|
||||
|
||||
```
|
||||
/media/Kai/
|
||||
└── Black Lagoon Henshū/
|
||||
└── Saison 001/
|
||||
├── Black Lagoon Henshū S01E01.mkv
|
||||
├── Black Lagoon Henshū S01E02.mkv
|
||||
└── ...
|
||||
```
|
||||
|
||||
Le mode **hardlink** est recommandé si vos dossiers `complete` et `media` sont sur le même filesystem — les fichiers ne sont pas déplacés, qBittorrent continue de seeder.
|
||||
|
||||
---
|
||||
|
||||
## Runtipi
|
||||
|
||||
Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
||||
|
||||
1. Dans Runtipi, aller dans **Paramètres → App Stores**
|
||||
2. Ajouter l'URL : `https://github.com/Masutayunikon/runtipi-appstore`
|
||||
3. Fankarr apparaît dans le catalogue → **Installer**
|
||||
|
||||
---
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Couche | Technologie |
|
||||
|---|---|
|
||||
| Frontend | Vue 3 + Vite + Tailwind CSS |
|
||||
| Backend | Express 5 + TypeScript |
|
||||
| Auth | JWT + bcrypt |
|
||||
| Packaging | Docker (`node:20-slim`) + pnpm |
|
||||
| Permissions | gosu (PUID/PGID) |
|
||||
|
||||
---
|
||||
|
||||
## Liens
|
||||
|
||||
- [fankai.fr](https://fankai.fr) — Le projet Fankai
|
||||
- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) — Reconnaissance des métadonnées dans Jellyfin
|
||||
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents
|
||||
+211
-16
@@ -10,7 +10,8 @@ import {
|
||||
sanitizeClient, dispatchDownload, dispatchList
|
||||
} from './torrent-clients/index.js'
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
import { organizeTorrent, autoOrganizeAll } from './organize.js'
|
||||
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -195,15 +253,55 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
}).join(' · ')
|
||||
}
|
||||
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => ({
|
||||
...season,
|
||||
torrent: seasonTorrentMap[season.id] ?? null,
|
||||
episodes: season.episodes.map((ep: any) => ({
|
||||
// Charger organized.json pour les badges d'état
|
||||
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 {}
|
||||
|
||||
// Construire un set des episode_id organisés
|
||||
// On croise resolved_episodes de chaque torrent avec organized.json
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
for (const t of torrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
const files = t.torrent_files ?? []
|
||||
const orgFiles = organized[hash] ?? {}
|
||||
if (Object.keys(orgFiles).length === 0) continue
|
||||
// Au moins un fichier organisé pour ce torrent → on marque les épisodes résolus
|
||||
for (const ep of t.resolved_episodes ?? []) {
|
||||
// Vérifier si le fichier de cet épisode est organisé
|
||||
const epFile = files.find((f: any) =>
|
||||
f.episode_ids?.includes(ep.episode_id) || f.episode_number === ep.episode_number
|
||||
)
|
||||
if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) {
|
||||
organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
|
||||
const eps = season.episodes.map((ep: any) => ({
|
||||
...ep,
|
||||
available: availableEpisodeIds.has(ep.id),
|
||||
torrent : episodeTorrentMap[ep.id] ?? null
|
||||
available : availableEpisodeIds.has(ep.id),
|
||||
torrent : episodeTorrentMap[ep.id] ?? null,
|
||||
organized : organizedEpisodeIds.has(ep.id),
|
||||
}))
|
||||
}))
|
||||
// State saison : none / partial / complete
|
||||
const total = eps.filter((e: any) => e.available).length
|
||||
const orgCount = eps.filter((e: any) => e.organized).length
|
||||
const seasonOrganizedState =
|
||||
orgCount === 0 ? 'none' :
|
||||
orgCount >= total && total > 0 ? 'complete' : 'partial'
|
||||
return {
|
||||
...season,
|
||||
torrent : seasonTorrentMap[season.id] ?? null,
|
||||
organized_state : seasonOrganizedState,
|
||||
organized_count : orgCount,
|
||||
episodes : eps,
|
||||
}
|
||||
})
|
||||
|
||||
const heroBundles: any[] = [
|
||||
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })),
|
||||
@@ -230,13 +328,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 +412,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 }
|
||||
@@ -295,11 +476,25 @@ app.listen(PORT, () => {
|
||||
} catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') }
|
||||
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
|
||||
|
||||
// Scan initial du mediaPath pour reconnaître les fichiers déjà présents
|
||||
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const { mediaPath } = readSettings()
|
||||
scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err =>
|
||||
logger.error('organize', `scanMediaPath échoué: ${err}`)
|
||||
)
|
||||
|
||||
// Auto-organise toutes les 30s
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
+103
-14
@@ -5,8 +5,10 @@
|
||||
* Le worker gère tout le filesystem (copy, hardlink, move).
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { logger } from './logger.js'
|
||||
|
||||
// Un seul worker à la fois
|
||||
let workerRunning = false
|
||||
@@ -18,16 +20,99 @@ export interface OrganizeResult {
|
||||
errors : { file: string; error: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan initial du mediaPath au démarrage.
|
||||
* Parcourt récursivement le dossier Jellyfin et marque dans organized.json
|
||||
* tous les fichiers déjà présents qui correspondent à un torrent connu.
|
||||
*/
|
||||
export async function scanMediaPath(
|
||||
mediaPath : string,
|
||||
torrentsPath: string,
|
||||
organizedPath: string
|
||||
): Promise<{ found: number; added: number }> {
|
||||
const result = { found: 0, added: 0 }
|
||||
|
||||
if (!fs.existsSync(mediaPath)) {
|
||||
logger.warn('organize', `scanMediaPath: ${mediaPath} inexistant, skip`)
|
||||
return result
|
||||
}
|
||||
|
||||
// Charger torrent_final.json
|
||||
let torrents: any[] = []
|
||||
try {
|
||||
if (!fs.existsSync(torrentsPath)) return result
|
||||
torrents = JSON.parse(fs.readFileSync(torrentsPath, 'utf-8'))
|
||||
} catch {
|
||||
logger.error('organize', 'scanMediaPath: impossible de lire torrent_final.json')
|
||||
return result
|
||||
}
|
||||
|
||||
// Construire un index filename → { hash, infohash }
|
||||
const filenameIndex = new Map<string, string>() // filename → infohash
|
||||
for (const t of torrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (!hash) continue
|
||||
for (const f of t.torrent_files ?? []) {
|
||||
if (f.filename) filenameIndex.set(f.filename, hash)
|
||||
}
|
||||
// Fichier unique (torrent_files vide) → on indexe par raw name
|
||||
if ((t.torrent_files ?? []).length === 0 && t.raw) {
|
||||
filenameIndex.set(t.raw, hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Charger organized.json existant
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
// Parcourir récursivement mediaPath
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
|
||||
catch { return }
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(full)
|
||||
} else if (entry.isFile() && entry.name.endsWith('.mkv')) {
|
||||
result.found++
|
||||
const hash = filenameIndex.get(entry.name)
|
||||
if (!hash) continue
|
||||
if (organized[hash]?.[entry.name]) continue // déjà connu
|
||||
// Marquer comme organisé
|
||||
if (!organized[hash]) organized[hash] = {}
|
||||
organized[hash][entry.name] = new Date().toISOString()
|
||||
result.added++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(mediaPath)
|
||||
|
||||
if (result.added > 0) {
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés à organized.json`)
|
||||
} else {
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
logger.debug('organize', 'Worker déjà en cours, skip')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,7 +120,7 @@ export async function autoOrganizeAll(
|
||||
try {
|
||||
torrents = await listFn()
|
||||
} catch (err) {
|
||||
console.error('[organize] Impossible de récupérer la liste des torrents:', err)
|
||||
logger.error('organize', "Impossible de récupérer la liste des torrents:", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -44,7 +129,7 @@ export async function autoOrganizeAll(
|
||||
const seedingCount = torrents.filter(t => t.state === 'seeding').length
|
||||
if (seedingCount === 0) return
|
||||
|
||||
console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`)
|
||||
logger.info('organize', `Lancement worker (${seedingCount} torrents en seeding)`)
|
||||
workerRunning = true
|
||||
|
||||
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
|
||||
@@ -53,25 +138,31 @@ export async function autoOrganizeAll(
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
if (msg.level === 'error') console.error(msg.msg)
|
||||
else if (msg.level === 'warn') console.warn(msg.msg)
|
||||
else console.log(msg.msg)
|
||||
logger[msg.level as 'debug'|'info'|'warn'|'error']('organize', msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
|
||||
logger.info('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é')
|
||||
logger.debug('organize', 'Worker terminé')
|
||||
workerRunning = false
|
||||
worker.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => {
|
||||
console.error('[organize] Worker erreur:', err)
|
||||
logger.error('organize', `Worker erreur: ${err instanceof Error ? err.message : err}`)
|
||||
workerRunning = false
|
||||
})
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (code !== 0) console.error(`[organize] Worker exit code ${code}`)
|
||||
if (code !== 0) logger.error('organize', `Worker exit code ${code}`)
|
||||
workerRunning = false
|
||||
})
|
||||
|
||||
@@ -98,9 +189,7 @@ export async function organizeTorrent(
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
if (msg.level === 'error') console.error(msg.msg)
|
||||
else if (msg.level === 'warn') console.warn(msg.msg)
|
||||
else console.log(msg.msg)
|
||||
logger[msg.level as 'debug'|'info'|'warn'|'error']('organize', msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
result.total = msg.total
|
||||
result.skipped = msg.skipped
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
+20
-5
@@ -27,14 +27,14 @@
|
||||
<!-- Hero -->
|
||||
<div class="relative">
|
||||
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none">
|
||||
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20" alt="fanart image"/>
|
||||
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20" />
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-7xl mx-auto px-6 pt-8 pb-10 flex gap-8">
|
||||
<!-- Poster -->
|
||||
<div class="shrink-0 w-36 rounded-xl overflow-hidden border border-white/10 shadow-2xl hidden sm:block">
|
||||
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" alt="poster image"/>
|
||||
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full aspect-[2/3] bg-zinc-900 flex items-center justify-center text-zinc-700">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/>
|
||||
@@ -108,6 +108,15 @@
|
||||
<p class="text-xs text-zinc-600 mt-0.5">
|
||||
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
|
||||
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
|
||||
<span v-if="season.organized_state !== 'none'"
|
||||
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
:class="season.organized_state === 'complete'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-yellow-500/10 text-yellow-500'">
|
||||
{{ season.organized_state === 'complete'
|
||||
? `✓ ${season.organized_count} organisé${season.organized_count > 1 ? 's' : ''}`
|
||||
: `${season.organized_count}/${seasonAvailableCount(season)} organisés` }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,7 +144,7 @@
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="shrink-0 w-24 aspect-video rounded-lg overflow-hidden bg-zinc-800 hidden sm:block">
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" alt="thumb image"/>
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700 text-xs font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}
|
||||
</div>
|
||||
@@ -156,10 +165,16 @@
|
||||
</div>
|
||||
|
||||
<!-- Bouton épisode (type episode uniquement) -->
|
||||
<div class="shrink-0">
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<!-- Badge organisé -->
|
||||
<span v-if="ep.organized"
|
||||
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||
✓ organisé
|
||||
</span>
|
||||
|
||||
<!-- Badge dispo/indispo basé sur available (episode résolu dans un torrent) -->
|
||||
<span
|
||||
class="inline-block text-[10px] px-2 py-0.5 rounded-full border mr-2"
|
||||
class="inline-block text-[10px] px-2 py-0.5 rounded-full border"
|
||||
:class="ep.available
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20'
|
||||
: 'bg-white/5 text-zinc-600 border-white/8'"
|
||||
|
||||
+30
-13
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user