Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0452dbd0c | ||
|
|
c376061333 | ||
|
|
bb02ea6cf8 | ||
|
|
887beae614 | ||
|
|
1290d5c925 | ||
|
|
dffb340250 | ||
|
|
059298f537 | ||
|
|
0ba2144f2f | ||
|
|
83a2fb9860 | ||
|
|
5445fadf94 | ||
|
|
6d110b86dd |
No files matched your search
@@ -53,6 +53,11 @@ jobs:
|
|||||||
bun-target: bun-linux-x64
|
bun-target: bun-linux-x64
|
||||||
binary: binaries/fankarr
|
binary: binaries/fankarr
|
||||||
archive: fankarr-linux-x64.zip
|
archive: fankarr-linux-x64.zip
|
||||||
|
- target: linux-arm64
|
||||||
|
os: ubuntu-latest
|
||||||
|
bun-target: bun-linux-arm64-termux
|
||||||
|
binary: binaries/fankarr
|
||||||
|
archive: fankarr-linux-arm64-termux.zip
|
||||||
- target: macos
|
- target: macos
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
bun-target: bun-darwin-arm64
|
bun-target: bun-darwin-arm64
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.2.0](https://github.com/Masutayunikon/FanKarr/compare/v3.1.1...v3.2.0) (2026-04-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add build for arm64 ([96acc13](https://github.com/Masutayunikon/FanKarr/commit/96acc134698ca09842d57d906e4a28ab8e94ba56))
|
||||||
|
* add developer settings on settings and dashboard debug ([96acc13](https://github.com/Masutayunikon/FanKarr/commit/96acc134698ca09842d57d906e4a28ab8e94ba56))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* android build name ([0cfe6eb](https://github.com/Masutayunikon/FanKarr/commit/0cfe6eb81055b841a67c08e02129709c0214d87d))
|
||||||
|
|
||||||
|
## [3.1.1](https://github.com/Masutayunikon/FanKarr/compare/v3.1.0...v3.1.1) (2026-03-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* remove plex settings ([8744ade](https://github.com/Masutayunikon/FanKarr/commit/8744adeff5f2db1ab648412d50495690d9e68d0b))
|
||||||
|
* torrent for multiple seasons is now working ([2514008](https://github.com/Masutayunikon/FanKarr/commit/25140086c13ce5a497500659bdd6cea2aeff96ed))
|
||||||
|
|
||||||
## [3.1.0](https://github.com/Masutayunikon/FanKarr/compare/v3.0.0...v3.1.0) (2026-03-27)
|
## [3.1.0](https://github.com/Masutayunikon/FanKarr/compare/v3.0.0...v3.1.0) (2026-03-27)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+47
-3
@@ -10,7 +10,7 @@ import {
|
|||||||
sanitizeClient, dispatchDownload, dispatchList
|
sanitizeClient, dispatchDownload, dispatchList
|
||||||
} from './torrent-clients/index.js'
|
} from './torrent-clients/index.js'
|
||||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||||
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
|
import { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js'
|
||||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ app.get('/api/settings', requireAuth, (_req, res) => {
|
|||||||
res.json(readSettings())
|
res.json(readSettings())
|
||||||
})
|
})
|
||||||
app.post('/api/settings', requireAuth, (req, res) => {
|
app.post('/api/settings', requireAuth, (req, res) => {
|
||||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, usePlexTitles } = req.body
|
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode } = req.body
|
||||||
const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, usePlexTitles })
|
const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode })
|
||||||
logger.info('api', 'Paramètres mis à jour')
|
logger.info('api', 'Paramètres mis à jour')
|
||||||
res.json(updated)
|
res.json(updated)
|
||||||
})
|
})
|
||||||
@@ -456,6 +456,50 @@ app.post('/api/scan', requireAuth, async (_req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Debug (dev mode uniquement) ───────────────────────────────
|
||||||
|
app.get('/api/debug/stats', requireAuth, (req, res) => {
|
||||||
|
const { devMode } = readSettings()
|
||||||
|
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
|
||||||
|
|
||||||
|
const mem = process.memoryUsage()
|
||||||
|
const uptimeS = Math.floor(process.uptime())
|
||||||
|
const h = Math.floor(uptimeS / 3600)
|
||||||
|
const m = Math.floor((uptimeS % 3600) / 60)
|
||||||
|
const s = uptimeS % 60
|
||||||
|
|
||||||
|
let organizedCount = 0
|
||||||
|
try {
|
||||||
|
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||||
|
if (fs.existsSync(orgPath)) {
|
||||||
|
const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||||
|
organizedCount = Object.values(org).reduce((acc: number, files: any) => acc + Object.keys(files).length, 0)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
memory: {
|
||||||
|
heapUsed : Math.round(mem.heapUsed / 1024 / 1024),
|
||||||
|
heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
|
||||||
|
rss : Math.round(mem.rss / 1024 / 1024),
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
entries : _cache.size,
|
||||||
|
ttlHours : 6,
|
||||||
|
},
|
||||||
|
uptime: `${h}h ${m}m ${s}s`,
|
||||||
|
uptimeSeconds: uptimeS,
|
||||||
|
worker: {
|
||||||
|
running: workerRunning,
|
||||||
|
},
|
||||||
|
organized: {
|
||||||
|
trackedFiles: organizedCount,
|
||||||
|
},
|
||||||
|
requests: {
|
||||||
|
notifs: recentOrganized.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
if (fs.existsSync(PUBLIC_PATH)) {
|
if (fs.existsSync(PUBLIC_PATH)) {
|
||||||
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
|
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-17
@@ -20,12 +20,12 @@ function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'err
|
|||||||
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
|
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
|
||||||
|
|
||||||
// ─── Settings ─────────────────────────────────────────────────
|
// ─── Settings ─────────────────────────────────────────────────
|
||||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean; usePlexTitles: boolean } {
|
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
|
||||||
try {
|
try {
|
||||||
const p = path.join(DATA_DIR, 'settings.json')
|
const p = path.join(DATA_DIR, 'settings.json')
|
||||||
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, usePlexTitles: false }
|
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
|
||||||
return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||||
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, usePlexTitles: false } }
|
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Organized log ────────────────────────────────────────────
|
// ─── Organized log ────────────────────────────────────────────
|
||||||
@@ -49,26 +49,42 @@ function isOrganized(hash: string, filename: string): boolean {
|
|||||||
|
|
||||||
// ─── Lookup torrent dans les seriesData ───────────────────────
|
// ─── Lookup torrent dans les seriesData ───────────────────────
|
||||||
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
|
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
|
||||||
|
const h = hash.toLowerCase()
|
||||||
for (const sd of seriesData) {
|
for (const sd of seriesData) {
|
||||||
|
// Niveau série (pack intégrale)
|
||||||
for (const t of sd.torrents ?? []) {
|
for (const t of sd.torrents ?? []) {
|
||||||
if (t.infohash?.toLowerCase() === hash.toLowerCase())
|
if (t.infohash?.toLowerCase() === h)
|
||||||
return { torrent: t, serieData: sd }
|
return { torrent: t, serieData: sd }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Niveau saison — vérifier si le même hash apparaît dans plusieurs saisons
|
||||||
|
// Si oui, pas de _season pour que buildFileMap scanne toutes les saisons
|
||||||
|
const seasonsWithHash = (sd.seasons ?? []).filter((s: any) =>
|
||||||
|
s.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
||||||
|
)
|
||||||
|
if (seasonsWithHash.length === 1) {
|
||||||
|
// Torrent propre à une seule saison → on filtre sur cette saison
|
||||||
|
const season = seasonsWithHash[0]
|
||||||
|
const t = season.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||||
|
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||||
|
} else if (seasonsWithHash.length > 1) {
|
||||||
|
// Même torrent dans plusieurs saisons → pas de filtre saison
|
||||||
|
const t = seasonsWithHash[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||||
|
return { torrent: t, serieData: sd }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Niveau épisode
|
||||||
for (const season of sd.seasons ?? []) {
|
for (const season of sd.seasons ?? []) {
|
||||||
for (const t of season.torrents ?? []) {
|
|
||||||
if (t.infohash?.toLowerCase() === hash.toLowerCase())
|
|
||||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
|
||||||
}
|
|
||||||
const matchingEpisodes = season.episodes?.filter((ep: any) =>
|
const matchingEpisodes = season.episodes?.filter((ep: any) =>
|
||||||
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
|
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
||||||
) ?? []
|
) ?? []
|
||||||
|
|
||||||
if (matchingEpisodes.length === 1) {
|
if (matchingEpisodes.length === 1) {
|
||||||
const ep = matchingEpisodes[0]
|
const ep = matchingEpisodes[0]
|
||||||
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
|
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||||
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
|
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
|
||||||
} else if (matchingEpisodes.length > 1) {
|
} else if (matchingEpisodes.length > 1) {
|
||||||
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
|
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,13 +271,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { torrent, serieData } = found
|
const { torrent, serieData } = found
|
||||||
const { usePlexTitles } = readSettings()
|
|
||||||
const rawTitle = serieData.title ?? serieData.show_title ?? name
|
const rawTitle = serieData.title ?? serieData.show_title ?? name
|
||||||
const serieTitle = sanitizeDirName(
|
const serieTitle = sanitizeDirName(rawTitle)
|
||||||
usePlexTitles && serieData.title_for_plex
|
|
||||||
? serieData.title_for_plex
|
|
||||||
: rawTitle
|
|
||||||
)
|
|
||||||
|
|
||||||
debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
|
debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -13,7 +13,7 @@ import { readSettings } from './settings.js'
|
|||||||
|
|
||||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||||
|
|
||||||
let workerRunning = false
|
export let workerRunning = false
|
||||||
const _prevStates = new Map<string, string>()
|
const _prevStates = new Map<string, string>()
|
||||||
|
|
||||||
function resolveWorkerPath(): string {
|
function resolveWorkerPath(): string {
|
||||||
@@ -211,7 +211,8 @@ export async function autoOrganizeAll(
|
|||||||
workerRunning = false
|
workerRunning = false
|
||||||
})
|
})
|
||||||
worker.on('exit', (code) => {
|
worker.on('exit', (code) => {
|
||||||
if (code !== 0) logger.error('organize', `Worker exit avec code ${code}`)
|
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
|
||||||
|
else logger.debug('organize', `Worker terminé (code ${code})`)
|
||||||
workerRunning = false
|
workerRunning = false
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -261,7 +262,7 @@ export async function organizeTorrent(
|
|||||||
reject(err)
|
reject(err)
|
||||||
})
|
})
|
||||||
worker.on('exit', (code) => {
|
worker.on('exit', (code) => {
|
||||||
if (code !== 0) reject(new Error(`Worker exit ${code}`))
|
if (code > 1) reject(new Error(`Worker exit inattendu: code ${code}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
|
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
|
||||||
|
|||||||
+14
-14
@@ -5,23 +5,23 @@ import { DATA_DIR } from './config.js'
|
|||||||
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
|
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
mediaPath : string
|
mediaPath : string
|
||||||
completePath : string
|
completePath : string
|
||||||
organizeMode : 'hardlink' | 'move'
|
organizeMode : 'hardlink' | 'move'
|
||||||
category : string
|
category : string
|
||||||
nfoSupport : boolean
|
nfoSupport : boolean
|
||||||
autoImport : boolean
|
autoImport : boolean
|
||||||
usePlexTitles : boolean // utilise title_for_plex pour les noms de dossiers
|
devMode : boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaults: Settings = {
|
const defaults: Settings = {
|
||||||
mediaPath : '',
|
mediaPath : '',
|
||||||
completePath : '',
|
completePath: '',
|
||||||
organizeMode : 'hardlink',
|
organizeMode: 'hardlink',
|
||||||
category : 'fankai',
|
category : 'fankai',
|
||||||
nfoSupport : false,
|
nfoSupport : false,
|
||||||
autoImport : true,
|
autoImport : true,
|
||||||
usePlexTitles: false,
|
devMode : false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readSettings(): Settings {
|
export function readSettings(): Settings {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const navItems = computed<NavItem[]>(() => [
|
|||||||
{ label: 'Gestion des médias', to: '/settings/media-management' },
|
{ label: 'Gestion des médias', to: '/settings/media-management' },
|
||||||
{ label: 'Catalogue Fankai', to: '/settings/catalogue' },
|
{ label: 'Catalogue Fankai', to: '/settings/catalogue' },
|
||||||
{ label: 'Journaux', to: '/settings/logs' },
|
{ label: 'Journaux', to: '/settings/logs' },
|
||||||
|
{ label: 'Avancé', to: '/settings/advanced' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ const router = createRouter({
|
|||||||
name: 'settings-logs',
|
name: 'settings-logs',
|
||||||
component: () => import('@/views/settings/LogsView.vue'), // ← nouveau chemin
|
component: () => import('@/views/settings/LogsView.vue'), // ← nouveau chemin
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'advanced',
|
||||||
|
name: 'settings-advanced',
|
||||||
|
component: () => import('@/views/settings/AdvancedView.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+85
-70
@@ -11,9 +11,7 @@
|
|||||||
<div class="settings-card flex flex-col gap-1">
|
<div class="settings-card flex flex-col gap-1">
|
||||||
<span class="text-xs text-muted">Importées</span>
|
<span class="text-xs text-muted">Importées</span>
|
||||||
<span class="text-2xl font-semibold text-green-500">{{ stats.imported }}</span>
|
<span class="text-2xl font-semibold text-green-500">{{ stats.imported }}</span>
|
||||||
<span class="text-xs text-muted">
|
<span class="text-xs text-muted">{{ stats.catalogue > 0 ? Math.round(stats.imported / stats.catalogue * 100) : 0 }}% du catalogue</span>
|
||||||
{{ stats.catalogue > 0 ? Math.round(stats.imported / stats.catalogue * 100) : 0 }}% du catalogue
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-card flex flex-col gap-1">
|
<div class="settings-card flex flex-col gap-1">
|
||||||
<span class="text-xs text-muted">En cours</span>
|
<span class="text-xs text-muted">En cours</span>
|
||||||
@@ -22,28 +20,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="settings-card flex flex-col gap-1">
|
<div class="settings-card flex flex-col gap-1">
|
||||||
<span class="text-xs text-muted">Erreurs</span>
|
<span class="text-xs text-muted">Erreurs</span>
|
||||||
<span class="text-2xl font-semibold" :class="stats.errors > 0 ? 'text-red-400' : 'text-muted'">
|
<span class="text-2xl font-semibold" :class="stats.errors > 0 ? 'text-red-400' : 'text-muted'">{{ stats.errors }}</span>
|
||||||
{{ stats.errors }}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-muted">{{ stats.errors === 0 ? 'aucune erreur' : 'à corriger' }}</span>
|
<span class="text-xs text-muted">{{ stats.errors === 0 ? 'aucune erreur' : 'à corriger' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-4">
|
<div class="grid md:grid-cols-2 gap-4 mb-6">
|
||||||
|
|
||||||
<!-- Téléchargements actifs -->
|
<!-- Téléchargements actifs -->
|
||||||
<div class="settings-card flex flex-col gap-3">
|
<div class="settings-card flex flex-col gap-3">
|
||||||
<h2 class="text-sm font-medium text-secondary">Téléchargements actifs</h2>
|
<h2 class="text-sm font-medium text-secondary">Téléchargements actifs</h2>
|
||||||
|
|
||||||
<div v-if="loadingTorrents" class="flex items-center gap-2 text-muted text-xs py-4">
|
<div v-if="loadingTorrents" class="flex items-center gap-2 text-muted text-xs py-4">
|
||||||
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
|
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
|
||||||
Chargement...
|
Chargement...
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="activeTorrents.length === 0" class="text-xs text-muted py-4">Aucun téléchargement en cours</div>
|
||||||
<div v-else-if="activeTorrents.length === 0" class="text-xs text-muted py-4">
|
|
||||||
Aucun téléchargement en cours
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-3">
|
<div v-else class="flex flex-col gap-3">
|
||||||
<div v-for="t in activeTorrents" :key="t.hash" class="flex flex-col gap-1.5">
|
<div v-for="t in activeTorrents" :key="t.hash" class="flex flex-col gap-1.5">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
@@ -51,17 +42,11 @@
|
|||||||
<span class="text-xs text-muted shrink-0">{{ t.progress }}%</span>
|
<span class="text-xs text-muted shrink-0">{{ t.progress }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||||
<div
|
<div class="h-full rounded-full transition-all duration-500" :class="t.state === 'seeding' ? 'bg-green-500' : 'bg-accent'" :style="{ width: `${t.progress}%` }" />
|
||||||
class="h-full rounded-full transition-all duration-500"
|
|
||||||
:class="t.state === 'seeding' ? 'bg-green-500' : 'bg-accent'"
|
|
||||||
:style="{ width: `${t.progress}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-[11px] text-muted">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</span>
|
<span class="text-[11px] text-muted">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</span>
|
||||||
<span v-if="t.state === 'downloading' && t.eta > 0" class="text-[11px] text-muted">
|
<span v-if="t.state === 'downloading' && t.eta > 0" class="text-[11px] text-muted">{{ formatEta(t.eta) }}</span>
|
||||||
{{ formatEta(t.eta) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,28 +55,16 @@
|
|||||||
<!-- Derniers imports -->
|
<!-- Derniers imports -->
|
||||||
<div class="settings-card flex flex-col gap-3">
|
<div class="settings-card flex flex-col gap-3">
|
||||||
<h2 class="text-sm font-medium text-secondary">Derniers imports</h2>
|
<h2 class="text-sm font-medium text-secondary">Derniers imports</h2>
|
||||||
|
|
||||||
<div v-if="loadingNotifs" class="flex items-center gap-2 text-muted text-xs py-4">
|
<div v-if="loadingNotifs" class="flex items-center gap-2 text-muted text-xs py-4">
|
||||||
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
|
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
|
||||||
Chargement...
|
Chargement...
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="recentNotifs.length === 0" class="text-xs text-muted py-4">Aucun import récent</div>
|
||||||
<div v-else-if="recentNotifs.length === 0" class="text-xs text-muted py-4">
|
|
||||||
Aucun import récent
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex flex-col divide-y divide-border/50">
|
<div v-else class="flex flex-col divide-y divide-border/50">
|
||||||
<div v-for="n in recentNotifs" :key="n.hash + n.at" class="flex items-start gap-3 py-2.5 first:pt-0 last:pb-0">
|
<div v-for="n in recentNotifs" :key="n.hash + n.at" class="flex items-start gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||||
<div
|
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0 mt-0.5" :class="n.errors > 0 ? 'bg-red-500/10' : 'bg-green-500/10'">
|
||||||
class="w-5 h-5 rounded-md flex items-center justify-center shrink-0 mt-0.5"
|
<svg v-if="n.errors === 0" width="10" height="10" viewBox="0 0 12 12" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
|
||||||
:class="n.errors > 0 ? 'bg-red-500/10' : 'bg-green-500/10'"
|
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="#f87171" stroke-width="2.5" fill="none"><line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/></svg>
|
||||||
>
|
|
||||||
<svg v-if="n.errors === 0" width="10" height="10" viewBox="0 0 12 12" stroke="#22c55e" stroke-width="2.5" fill="none">
|
|
||||||
<polyline points="2 6 5 9 10 3"/>
|
|
||||||
</svg>
|
|
||||||
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="#f87171" stroke-width="2.5" fill="none">
|
|
||||||
<line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-xs text-primary truncate">{{ n.name }}</p>
|
<p class="text-xs text-primary truncate">{{ n.name }}</p>
|
||||||
@@ -106,6 +79,51 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Panneau debug -->
|
||||||
|
<div v-if="devMode" class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="text-sm font-medium text-secondary">Debug</h2>
|
||||||
|
<span class="text-[10px] px-1.5 py-0.5 rounded border border-accent/30 text-accent">DEV</span>
|
||||||
|
<button @click="fetchDebug" class="ml-auto text-xs text-muted hover:text-primary transition-colors">↺ Rafraîchir</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<div class="settings-card flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-muted">Heap utilisé</span>
|
||||||
|
<span class="text-xl font-semibold text-primary">{{ debug.memory.heapUsed }} MB</span>
|
||||||
|
<span class="text-xs text-muted">/ {{ debug.memory.heapTotal }} MB total</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-muted">RSS (process)</span>
|
||||||
|
<span class="text-xl font-semibold text-primary">{{ debug.memory.rss }} MB</span>
|
||||||
|
<span class="text-xs text-muted">mémoire système</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-muted">Uptime</span>
|
||||||
|
<span class="text-xl font-semibold text-primary">{{ debug.uptime }}</span>
|
||||||
|
<span class="text-xs text-muted">depuis le démarrage</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-muted">Cache GitHub</span>
|
||||||
|
<span class="text-xl font-semibold text-primary">{{ debug.cache.entries }}</span>
|
||||||
|
<span class="text-xs text-muted">entrées (TTL {{ debug.cache.ttlHours }}h)</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-muted">Fichiers trackés</span>
|
||||||
|
<span class="text-xl font-semibold text-primary">{{ debug.organized.trackedFiles }}</span>
|
||||||
|
<span class="text-xs text-muted">dans organized.json</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-muted">Worker organize</span>
|
||||||
|
<span class="text-xl font-semibold" :class="debug.worker.running ? 'text-accent' : 'text-muted'">
|
||||||
|
{{ debug.worker.running ? 'Actif' : 'Inactif' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted">{{ debug.requests.notifs }} notif(s) en mémoire</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="settings-card text-xs text-muted py-4 text-center">Chargement des stats...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -114,15 +132,26 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
|||||||
|
|
||||||
const loadingTorrents = ref(true)
|
const loadingTorrents = ref(true)
|
||||||
const loadingNotifs = ref(true)
|
const loadingNotifs = ref(true)
|
||||||
|
const devMode = ref(false)
|
||||||
const stats = ref({ catalogue: 0, imported: 0, downloading: 0, errors: 0 })
|
const debug = ref<any>(null)
|
||||||
|
const stats = ref({ catalogue: 0, imported: 0, downloading: 0, errors: 0 })
|
||||||
const activeTorrents = ref<any[]>([])
|
const activeTorrents = ref<any[]>([])
|
||||||
const recentNotifs = ref<any[]>([])
|
const recentNotifs = ref<any[]>([])
|
||||||
|
|
||||||
let interval: ReturnType<typeof setInterval> | null = null
|
let interval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
async function fetchAll() {
|
async function fetchSettings() {
|
||||||
await Promise.all([fetchStats(), fetchTorrents(), fetchNotifs()])
|
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const s = await res.json()
|
||||||
|
devMode.value = s.devMode ?? false
|
||||||
|
if (devMode.value) fetchDebug()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDebug() {
|
||||||
|
const res = await fetch('/api/debug/stats', { credentials: 'include' })
|
||||||
|
if (res.ok) debug.value = await res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStats() {
|
async function fetchStats() {
|
||||||
@@ -132,18 +161,12 @@ async function fetchStats() {
|
|||||||
fetch('/api/series', { credentials: 'include' }),
|
fetch('/api/series', { credentials: 'include' }),
|
||||||
fetch('/api/downloads', { credentials: 'include' }),
|
fetch('/api/downloads', { credentials: 'include' }),
|
||||||
])
|
])
|
||||||
|
if (statusRes.ok) { const s = await statusRes.json(); stats.value.catalogue = s.count ?? 0 }
|
||||||
if (statusRes.ok) {
|
|
||||||
const s = await statusRes.json()
|
|
||||||
stats.value.catalogue = s.count ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seriesRes.ok) {
|
if (seriesRes.ok) {
|
||||||
const s = await seriesRes.json()
|
const s = await seriesRes.json()
|
||||||
const list = Array.isArray(s) ? s : (s.series ?? [])
|
const list = Array.isArray(s) ? s : (s.series ?? [])
|
||||||
stats.value.imported = list.filter((s: any) => s.download_state === 'complete').length
|
stats.value.imported = list.filter((s: any) => s.download_state === 'complete').length
|
||||||
}
|
}
|
||||||
|
|
||||||
if (downloadsRes.ok) {
|
if (downloadsRes.ok) {
|
||||||
const torrents = await downloadsRes.json()
|
const torrents = await downloadsRes.json()
|
||||||
stats.value.downloading = torrents.filter((t: any) => t.state === 'downloading').length
|
stats.value.downloading = torrents.filter((t: any) => t.state === 'downloading').length
|
||||||
@@ -153,18 +176,14 @@ async function fetchStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTorrents() {
|
async function fetchTorrents() {
|
||||||
// Premier chargement seulement → affiche le spinner
|
|
||||||
const isFirst = loadingTorrents.value
|
const isFirst = loadingTorrents.value
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const list = await res.json()
|
const list = await res.json()
|
||||||
activeTorrents.value = list
|
activeTorrents.value = list.filter((t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state)).slice(0, 5)
|
||||||
.filter((t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
|
|
||||||
.slice(0, 5)
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {} finally {
|
||||||
} finally {
|
|
||||||
if (isFirst) loadingTorrents.value = false
|
if (isFirst) loadingTorrents.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,14 +193,13 @@ async function fetchNotifs() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/organize/recent', { credentials: 'include' })
|
const res = await fetch('/api/organize/recent', { credentials: 'include' })
|
||||||
if (res.ok) recentNotifs.value = (await res.json()).slice(0, 6)
|
if (res.ok) recentNotifs.value = (await res.json()).slice(0, 6)
|
||||||
} catch {
|
} catch {} finally {
|
||||||
} finally {
|
|
||||||
loadingNotifs.value = false
|
loadingNotifs.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (!bytes || bytes === 0) return '0 B'
|
if (!bytes) return '0 B'
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||||
@@ -189,9 +207,7 @@ function formatSize(bytes: number): string {
|
|||||||
|
|
||||||
function formatEta(seconds: number): string {
|
function formatEta(seconds: number): string {
|
||||||
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||||
const h = Math.floor(seconds / 3600)
|
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
|
||||||
const s = seconds % 60
|
|
||||||
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`
|
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`
|
||||||
if (m > 0) return `${m}m ${s.toString().padStart(2, '0')}s`
|
if (m > 0) return `${m}m ${s.toString().padStart(2, '0')}s`
|
||||||
return `${s}s`
|
return `${s}s`
|
||||||
@@ -199,21 +215,20 @@ function formatEta(seconds: number): string {
|
|||||||
|
|
||||||
function formatRelative(iso: string): string {
|
function formatRelative(iso: string): string {
|
||||||
const diff = Date.now() - new Date(iso).getTime()
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
const m = Math.floor(diff / 60000)
|
const m = Math.floor(diff / 60000), h = Math.floor(diff / 3600000), d = Math.floor(diff / 86400000)
|
||||||
const h = Math.floor(diff / 3600000)
|
if (m < 1) return "à l'instant"
|
||||||
const d = Math.floor(diff / 86400000)
|
|
||||||
if (m < 1) return "à l'instant"
|
|
||||||
if (m < 60) return `il y a ${m}min`
|
if (m < 60) return `il y a ${m}min`
|
||||||
if (h < 24) return `il y a ${h}h`
|
if (h < 24) return `il y a ${h}h`
|
||||||
return `il y a ${d}j`
|
return `il y a ${d}j`
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchAll()
|
Promise.all([fetchStats(), fetchTorrents(), fetchNotifs(), fetchSettings()])
|
||||||
interval = setInterval(fetchTorrents, 10000)
|
interval = setInterval(() => {
|
||||||
|
fetchTorrents()
|
||||||
|
if (devMode.value) fetchDebug()
|
||||||
|
}, 10000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => { if (interval) clearInterval(interval) })
|
||||||
if (interval) clearInterval(interval)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
@@ -32,6 +32,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar : recherche + tri -->
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<div class="relative flex-1 max-w-xs">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Rechercher..."
|
||||||
|
class="settings-input pl-9 w-full py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative shrink-0" ref="sortRef">
|
||||||
|
<button
|
||||||
|
@click="sortOpen = !sortOpen"
|
||||||
|
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
|
||||||
|
:class="activeSort !== 'none' ? 'border-accent text-accent' : ''"
|
||||||
|
>
|
||||||
|
<ArrowUpDown :size="14" />
|
||||||
|
<span class="hidden md:inline text-xs">Trier</span>
|
||||||
|
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="sortOpen"
|
||||||
|
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="s in sortOptions" :key="s.value"
|
||||||
|
@click="setSort(s.value)"
|
||||||
|
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
|
||||||
|
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||||
|
>
|
||||||
|
{{ s.label }}
|
||||||
|
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
|
||||||
|
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Onglets + filtre -->
|
<!-- Onglets + filtre -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
|
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
|
||||||
@@ -193,6 +234,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { Search, ArrowUpDown } from 'lucide-vue-next'
|
||||||
|
import { onClickOutside } from '@vueuse/core'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
const { add: toast } = useToast()
|
const { add: toast } = useToast()
|
||||||
@@ -205,6 +248,30 @@ const importing = ref<Record<string, boolean>>({})
|
|||||||
const importingAll = ref(false)
|
const importingAll = ref(false)
|
||||||
const activeTab = ref<'active' | 'done'>('active')
|
const activeTab = ref<'active' | 'done'>('active')
|
||||||
const hideImported = ref(false)
|
const hideImported = ref(false)
|
||||||
|
const search = ref('')
|
||||||
|
const sortOpen = ref(false)
|
||||||
|
const activeSort = ref<'none' | 'name' | 'size' | 'progress' | 'state'>('none')
|
||||||
|
const sortDir = ref<'asc' | 'desc'>('asc')
|
||||||
|
const sortRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
onClickOutside(sortRef, () => { sortOpen.value = false })
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ label: 'Nom', value: 'name' },
|
||||||
|
{ label: 'Taille', value: 'size' },
|
||||||
|
{ label: 'Progression', value: 'progress' },
|
||||||
|
{ label: 'État', value: 'state' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function setSort(val: typeof activeSort.value) {
|
||||||
|
if (activeSort.value === val) {
|
||||||
|
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||||
|
} else {
|
||||||
|
activeSort.value = val
|
||||||
|
sortDir.value = 'asc'
|
||||||
|
}
|
||||||
|
sortOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
@@ -217,9 +284,33 @@ const doneTorrents = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const visibleTorrents = computed(() => {
|
const visibleTorrents = computed(() => {
|
||||||
if (activeTab.value === 'active') return activeTorrents.value
|
let list = activeTab.value === 'active'
|
||||||
if (hideImported.value) return doneTorrents.value.filter(t => t.organizeState !== 'done')
|
? activeTorrents.value
|
||||||
return doneTorrents.value
|
: hideImported.value
|
||||||
|
? doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||||
|
: doneTorrents.value
|
||||||
|
|
||||||
|
// Recherche
|
||||||
|
if (search.value.trim()) {
|
||||||
|
const q = search.value.toLowerCase()
|
||||||
|
list = list.filter(t => t.name.toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tri
|
||||||
|
if (activeSort.value !== 'none') {
|
||||||
|
const dir = sortDir.value === 'asc' ? 1 : -1
|
||||||
|
list = [...list].sort((a, b) => {
|
||||||
|
switch (activeSort.value) {
|
||||||
|
case 'name' : return a.name.localeCompare(b.name, 'fr') * dir
|
||||||
|
case 'size' : return (a.size - b.size) * dir
|
||||||
|
case 'progress': return (a.progress - b.progress) * dir
|
||||||
|
case 'state' : return a.state.localeCompare(b.state) * dir
|
||||||
|
default : return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
})
|
})
|
||||||
|
|
||||||
const stats = computed(() => [
|
const stats = computed(() => [
|
||||||
|
|||||||
+13
-2
@@ -151,8 +151,19 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progression saison -->
|
<!-- Progression saison — dans le header, sous les contrôles -->
|
||||||
<ProgressBar :progress="torrentProgress(extractHash(season.torrent))?.progress" class="px-5 py-2 border-b border-border" />
|
<div
|
||||||
|
v-if="torrentProgress(extractHash(season.torrent)) && torrentProgress(extractHash(season.torrent))!.progress < 100"
|
||||||
|
class="px-5 pb-3 -mt-1"
|
||||||
|
>
|
||||||
|
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full bg-accent rounded-full transition-all duration-500"
|
||||||
|
:style="{ width: `${torrentProgress(extractHash(season.torrent))!.progress}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-muted mt-1">{{ torrentProgress(extractHash(season.torrent))!.progress }}%</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Épisodes -->
|
<!-- Épisodes -->
|
||||||
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-border/50">
|
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-border/50">
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ const filtersDisponibilite = [
|
|||||||
const filtersImport = [
|
const filtersImport = [
|
||||||
{ label: 'Importés', value: 'complete', color: '#22c55e' },
|
{ label: 'Importés', value: 'complete', color: '#22c55e' },
|
||||||
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
|
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
|
||||||
{ label: 'Partiel', value: 'partial', color: 'var(--accent)' },
|
{ label: 'Partiel', value: 'partial', color: '#9b59b6' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
@@ -233,7 +233,7 @@ const sortOptions = [
|
|||||||
const legend = [
|
const legend = [
|
||||||
{ label: 'Importé', color: '#22c55e' },
|
{ label: 'Importé', color: '#22c55e' },
|
||||||
{ label: 'En cours', color: '#3b82f6' },
|
{ label: 'En cours', color: '#3b82f6' },
|
||||||
{ label: 'Partiel', color: 'var(--accent)' },
|
{ label: 'Partiel', color: '#9b59b6' },
|
||||||
{ label: 'Non importé', color: 'var(--border)' },
|
{ label: 'Non importé', color: 'var(--border)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ function stateColor(state: string): string {
|
|||||||
return ({
|
return ({
|
||||||
complete : '#22c55e',
|
complete : '#22c55e',
|
||||||
downloading: '#3b82f6',
|
downloading: '#3b82f6',
|
||||||
partial : 'var(--accent)',
|
partial : '#9b59b6',
|
||||||
none : 'transparent',
|
none : 'transparent',
|
||||||
} as Record<string, string>)[state] ?? 'transparent'
|
} as Record<string, string>)[state] ?? 'transparent'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-semibold text-primary">Avancé</h2>
|
||||||
|
<p class="text-sm text-muted mt-1">Options pour le débogage et le développement.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card flex flex-col gap-5">
|
||||||
|
<SettingsToggle
|
||||||
|
v-model="devMode"
|
||||||
|
label="Mode développeur"
|
||||||
|
description="Affiche un panneau de debug sur le dashboard : mémoire, cache, uptime, worker."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="saved" class="text-xs text-green-400">Paramètre sauvegardé.</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||||
|
|
||||||
|
const devMode = ref(false)
|
||||||
|
const saved = ref(false)
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const s = await res.json()
|
||||||
|
devMode.value = s.devMode ?? false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(devMode, async (val) => {
|
||||||
|
await fetch('/api/settings', {
|
||||||
|
method : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body : JSON.stringify({ devMode: val }),
|
||||||
|
})
|
||||||
|
saved.value = true
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(() => { saved.value = false }, 2000)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -72,12 +72,6 @@
|
|||||||
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
|
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsToggle
|
|
||||||
v-model="form.usePlexTitles"
|
|
||||||
label="Titres optimisés Plex"
|
|
||||||
description="Utilise title_for_plex pour les noms de dossiers — évite les caractères spéciaux."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
v-model="form.nfoSupport"
|
v-model="form.nfoSupport"
|
||||||
label="NFO / Métadonnées"
|
label="NFO / Métadonnées"
|
||||||
@@ -133,7 +127,6 @@ const form = ref({
|
|||||||
organizeMode : 'hardlink' as 'hardlink' | 'move',
|
organizeMode : 'hardlink' as 'hardlink' | 'move',
|
||||||
nfoSupport : false,
|
nfoSupport : false,
|
||||||
autoImport : true,
|
autoImport : true,
|
||||||
usePlexTitles: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
||||||
|
|||||||
Reference in new issue
Block a user