Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8173a6d72 | ||
|
|
60c9ffa14c | ||
|
|
0751c2edcc | ||
|
|
7e050a699d | ||
|
|
9badada4a3 | ||
|
|
5e881272a1 | ||
|
|
a229a1eb3b | ||
|
|
b621adc6f6 | ||
|
|
7d3bc413ef | ||
|
|
6f9b7892b1 | ||
|
|
2332b544e6 | ||
|
|
3b091b7aa8 | ||
|
|
e1e430a968 |
No files matched your search
@@ -7,6 +7,7 @@ yarn-error.log*
|
|||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
|
config
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.3.2](https://github.com/Masutayunikon/FanKarr/compare/v2.3.1...v2.3.2) (2026-03-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* badge catalogue sur les series importé ([f070f17](https://github.com/Masutayunikon/FanKarr/commit/f070f1754ef616874cfb081eb2fefadad602c221))
|
||||||
|
|
||||||
|
## [2.3.1](https://github.com/Masutayunikon/FanKarr/compare/v2.3.0...v2.3.1) (2026-03-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add readsettings import ([ceb8e64](https://github.com/Masutayunikon/FanKarr/commit/ceb8e64a55937e20e27c1fb64865aba59516d518))
|
||||||
|
|
||||||
|
## [2.3.0](https://github.com/Masutayunikon/FanKarr/compare/v2.2.0...v2.3.0) (2026-03-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **catalogue:** badge IMPORTÉ et refresh automatique post-import ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||||
|
* **catalogue:** tri du catalogue avec 4 options ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||||
|
* **downloads:** bouton Organiser tout renommé Importer tout ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||||
|
* **organize:** nommage des fichiers selon original_filename ou formatted_name ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||||
|
* **settings:** ajout du toggle import automatique (activé par défaut) ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||||
|
* **settings:** textes génériques sans mention de logiciels spécifiques ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add config directory to .gitignore ([2d8749a](https://github.com/Masutayunikon/FanKarr/commit/2d8749ae7bc2986871fb402c2a4242bcb87d3a77))
|
||||||
|
|
||||||
## [2.2.0](https://github.com/Masutayunikon/FanKarr/compare/v2.1.3...v2.2.0) (2026-03-23)
|
## [2.2.0](https://github.com/Masutayunikon/FanKarr/compare/v2.1.3...v2.2.0) (2026-03-23)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-7
@@ -42,8 +42,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 } = req.body
|
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport } = req.body
|
||||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport }))
|
res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport }))
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Torrent clients ────────────────────────────────────────────
|
// ── Torrent clients ────────────────────────────────────────────
|
||||||
@@ -288,16 +288,20 @@ async function enrichSeriesDataWithOriginalFilenames(seriesData: any[]): Promise
|
|||||||
epsData = Array.isArray(res) ? res : (res.episodes ?? [])
|
epsData = Array.isArray(res) ? res : (res.episodes ?? [])
|
||||||
} catch { return season }
|
} catch { return season }
|
||||||
|
|
||||||
// Index episode_id → original_filename
|
// Index episode_id → original_filename + formatted_name
|
||||||
const origMap = new Map<number, string>()
|
const origMap = new Map<number, { original_filename: string; formatted_name: string }>()
|
||||||
for (const ep of epsData) {
|
for (const ep of epsData) {
|
||||||
if (ep.id && ep.original_filename) origMap.set(ep.id, ep.original_filename)
|
if (ep.id) origMap.set(ep.id, {
|
||||||
|
original_filename: ep.original_filename ?? '',
|
||||||
|
formatted_name : ep.formatted_name ?? '',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Injecter dans les épisodes du serieData
|
// Injecter dans les épisodes du serieData
|
||||||
const enrichedEpisodes = (season.episodes ?? []).map((ep: any) => ({
|
const enrichedEpisodes = (season.episodes ?? []).map((ep: any) => ({
|
||||||
...ep,
|
...ep,
|
||||||
original_filename: origMap.get(ep.id) ?? null,
|
original_filename: origMap.get(ep.id)?.original_filename ?? null,
|
||||||
|
formatted_name : origMap.get(ep.id)?.formatted_name ?? null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return { ...season, episodes: enrichedEpisodes }
|
return { ...season, episodes: enrichedEpisodes }
|
||||||
@@ -379,7 +383,7 @@ app.get('/api/series', requireAuth, async (_req, res) => {
|
|||||||
const { category } = readSettings()
|
const { category } = readSettings()
|
||||||
const active = await dispatchList(category ?? 'fankai')
|
const active = await dispatchList(category ?? 'fankai')
|
||||||
for (const t of active) {
|
for (const t of active) {
|
||||||
if (t.hash) activeTorrents.add(t.hash.toLowerCase())
|
if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase())
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
|||||||
@@ -89,9 +89,11 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
|
|||||||
|
|
||||||
// Construit la map filename → { season_number, original_filename, fullPath }
|
// Construit la map filename → { season_number, original_filename, fullPath }
|
||||||
// Supporte les deux formats : { infohash, path } et string (legacy)
|
// Supporte les deux formats : { infohash, path } et string (legacy)
|
||||||
|
// destFilename : original_filename si nfoSupport, formatted_name sinon
|
||||||
function buildFileMap(
|
function buildFileMap(
|
||||||
serieData : any,
|
serieData : any,
|
||||||
hash : string,
|
hash : string,
|
||||||
|
nfoSupport : boolean,
|
||||||
seasonFilter?: number
|
seasonFilter?: number
|
||||||
): Map<string, { season_number: number; original_filename: string; fullPath: string }> {
|
): Map<string, { season_number: number; original_filename: string; fullPath: string }> {
|
||||||
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
|
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
|
||||||
@@ -114,10 +116,18 @@ function buildFileMap(
|
|||||||
|
|
||||||
if (!matchedPath) continue
|
if (!matchedPath) continue
|
||||||
const filename = matchedPath.split('/').pop() ?? matchedPath
|
const filename = matchedPath.split('/').pop() ?? matchedPath
|
||||||
const original = ep.original_filename ?? filename
|
|
||||||
|
// nfoSupport → original_filename (correspond aux .nfo GitLab)
|
||||||
|
// sinon → formatted_name de l'API (nommage Jellyfin/Plex standardisé)
|
||||||
|
const destName = nfoSupport
|
||||||
|
? (ep.original_filename ?? filename)
|
||||||
|
: (ep.formatted_name
|
||||||
|
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||||
|
: (ep.original_filename ?? filename))
|
||||||
|
|
||||||
map.set(filename, {
|
map.set(filename, {
|
||||||
season_number : season.season_number,
|
season_number : season.season_number,
|
||||||
original_filename: original,
|
original_filename: destName,
|
||||||
fullPath : matchedPath,
|
fullPath : matchedPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -268,9 +278,13 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
|||||||
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break }
|
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break }
|
||||||
}
|
}
|
||||||
const filename = filePath?.split('/').pop() ?? name
|
const filename = filePath?.split('/').pop() ?? name
|
||||||
const original = ep.original_filename ?? filename
|
const destName = nfoSupport
|
||||||
|
? (ep.original_filename ?? filename)
|
||||||
|
: (ep.formatted_name
|
||||||
|
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||||
|
: (ep.original_filename ?? filename))
|
||||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||||
const dest = path.join(destDir, original)
|
const dest = path.join(destDir, destName)
|
||||||
|
|
||||||
if (isOrganized(hash, filename)) return { ...result, total: 1, skipped: 1 }
|
if (isOrganized(hash, filename)) return { ...result, total: 1, skipped: 1 }
|
||||||
|
|
||||||
@@ -302,13 +316,13 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
markOrganized(hash, filename)
|
markOrganized(hash, filename)
|
||||||
log(`[organize] ✓ ${original} → Saison ${season.season_number}`)
|
log(`[organize] ✓ ${destName} → Saison ${season.season_number}`)
|
||||||
return { ...result, total: 1, done: 1 }
|
return { ...result, total: 1, done: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pack saison / intégrale ───────────────────────────────
|
// ── Pack saison / intégrale ───────────────────────────────
|
||||||
const seasonFilter = torrent._season?.season_number
|
const seasonFilter = torrent._season?.season_number
|
||||||
const fileMap = buildFileMap(serieData, hash, seasonFilter)
|
const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter)
|
||||||
|
|
||||||
if (fileMap.size === 0) {
|
if (fileMap.size === 0) {
|
||||||
warn(`[organize] Aucun fichier mappé pour ${name}`)
|
warn(`[organize] Aucun fichier mappé pour ${name}`)
|
||||||
@@ -371,6 +385,7 @@ parentPort?.on('message', async (msg: any) => {
|
|||||||
const torrents: any[] = msg.torrents
|
const torrents: any[] = msg.torrents
|
||||||
const seriesData: any[] = msg.seriesData ?? []
|
const seriesData: any[] = msg.seriesData ?? []
|
||||||
const organized = loadOrganized()
|
const organized = loadOrganized()
|
||||||
|
const { nfoSupport } = readSettings()
|
||||||
|
|
||||||
for (const t of torrents) {
|
for (const t of torrents) {
|
||||||
if (t.state !== 'seeding') continue
|
if (t.state !== 'seeding') continue
|
||||||
@@ -384,7 +399,7 @@ parentPort?.on('message', async (msg: any) => {
|
|||||||
// Vérifier si déjà tout organisé
|
// Vérifier si déjà tout organisé
|
||||||
const { torrent, serieData } = found
|
const { torrent, serieData } = found
|
||||||
const seasonFilter = torrent._season?.season_number
|
const seasonFilter = torrent._season?.season_number
|
||||||
const fileMap = buildFileMap(serieData, t.hash, seasonFilter)
|
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
|
||||||
const allDone = fileMap.size > 0
|
const allDone = fileMap.size > 0
|
||||||
? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
|
? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
|
||||||
: !!organized[t.hash]?.[t.name]
|
: !!organized[t.hash]?.[t.name]
|
||||||
|
|||||||
+6
-1
@@ -9,6 +9,7 @@ import path from 'path'
|
|||||||
import { Worker } from 'worker_threads'
|
import { Worker } from 'worker_threads'
|
||||||
import { logger } from './logger.js'
|
import { logger } from './logger.js'
|
||||||
import { DATA_DIR } from './config.js'
|
import { DATA_DIR } from './config.js'
|
||||||
|
import { readSettings } from './settings.js'
|
||||||
|
|
||||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||||
|
|
||||||
@@ -122,13 +123,17 @@ export async function scanMediaPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-organisation — appelée depuis index.ts avec les seriesData du cache GitHub.
|
* Auto-import — appelée depuis index.ts avec les seriesData du cache GitHub.
|
||||||
*/
|
*/
|
||||||
export async function autoOrganizeAll(
|
export async function autoOrganizeAll(
|
||||||
listFn : () => Promise<any[]>,
|
listFn : () => Promise<any[]>,
|
||||||
seriesData : any[],
|
seriesData : any[],
|
||||||
onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Vérifier si l'import auto est activé
|
||||||
|
const { autoImport } = readSettings()
|
||||||
|
if (!autoImport) return
|
||||||
|
|
||||||
if (workerRunning) {
|
if (workerRunning) {
|
||||||
console.log('[organize] Worker déjà en cours, skip')
|
console.log('[organize] Worker déjà en cours, skip')
|
||||||
return
|
return
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,8 @@ export interface Settings {
|
|||||||
completePath : string
|
completePath : string
|
||||||
organizeMode : 'hardlink' | 'move'
|
organizeMode : 'hardlink' | 'move'
|
||||||
category : string
|
category : string
|
||||||
nfoSupport : boolean // téléchargement NFO/images depuis GitLab Fankai Pack
|
nfoSupport : boolean
|
||||||
|
autoImport : boolean // import automatique quand un torrent passe en seeding
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaults: Settings = {
|
const defaults: Settings = {
|
||||||
@@ -18,6 +19,7 @@ const defaults: Settings = {
|
|||||||
organizeMode : 'hardlink',
|
organizeMode : 'hardlink',
|
||||||
category : 'fankai',
|
category : 'fankai',
|
||||||
nfoSupport : false,
|
nfoSupport : false,
|
||||||
|
autoImport : true,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readSettings(): Settings {
|
export function readSettings(): Settings {
|
||||||
|
|||||||
+92
-92
@@ -8,10 +8,8 @@
|
|||||||
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
|
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Icône logs -->
|
|
||||||
<router-link to="/logs"
|
<router-link to="/logs"
|
||||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition">
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
<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"/>
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
@@ -21,32 +19,25 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link to="/downloads"
|
<router-link to="/downloads"
|
||||||
class="relative p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
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">
|
<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"/>
|
<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"/>
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
<!-- Badge compteur -->
|
|
||||||
<span v-if="activeDownloads > 0"
|
<span v-if="activeDownloads > 0"
|
||||||
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">
|
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 }}
|
{{ activeDownloads }}
|
||||||
</span>
|
</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link to="/settings"
|
||||||
to="/settings"
|
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition">
|
||||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</router-link>
|
</router-link>
|
||||||
<button
|
<button class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition" @click="handleLogout">
|
||||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
|
||||||
@click="handleLogout"
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -54,59 +45,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search + Filters -->
|
<!-- Search + Filters + Sort -->
|
||||||
<div class="max-w-7xl mx-auto px-6 pb-3 flex flex-wrap items-center gap-3">
|
<div class="max-w-7xl mx-auto px-6 pb-3 flex flex-wrap items-center gap-3">
|
||||||
<div class="relative flex-1 min-w-48 max-w-sm">
|
<div class="relative flex-1 min-w-48 max-w-sm">
|
||||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600 pointer-events-none" width="15" height="15" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600 pointer-events-none" width="15" height="15" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input v-model="search" type="text" placeholder="Rechercher…"
|
||||||
v-model="search"
|
class="w-full bg-white/5 border border-white/[0.08] rounded-lg pl-9 pr-4 py-2 text-sm placeholder-zinc-600 outline-none focus:border-orange-500/50 transition"/>
|
||||||
type="text"
|
|
||||||
placeholder="Rechercher…"
|
|
||||||
class="w-full bg-white/5 border border-white/[0.08] rounded-lg pl-9 pr-4 py-2 text-sm placeholder-zinc-600 outline-none focus:border-orange-500/50 transition"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<!-- Groupe 1 : état de la série -->
|
|
||||||
<button
|
|
||||||
v-for="f in filtersGroupe1"
|
|
||||||
:key="f.value"
|
|
||||||
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
|
||||||
:class="activeFilter === f.value
|
|
||||||
? 'bg-orange-500 border-orange-500 text-white'
|
|
||||||
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
|
|
||||||
@click="activeFilter = f.value"
|
|
||||||
>{{ f.label }}</button>
|
|
||||||
|
|
||||||
<!-- Séparateur -->
|
<div class="flex items-center gap-1">
|
||||||
|
<button v-for="f in filtersGroupe1" :key="f.value"
|
||||||
|
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
||||||
|
:class="activeFilter === f.value
|
||||||
|
? 'bg-orange-500 border-orange-500 text-white'
|
||||||
|
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
|
||||||
|
@click="activeFilter = f.value">{{ f.label }}</button>
|
||||||
|
|
||||||
<span class="w-px h-5 bg-white/10 mx-1" />
|
<span class="w-px h-5 bg-white/10 mx-1" />
|
||||||
|
|
||||||
<!-- Groupe 2 : état du téléchargement -->
|
<button v-for="f in filtersGroupe2" :key="f.value"
|
||||||
<button
|
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
||||||
v-for="f in filtersGroupe2"
|
:class="activeFilter === f.value
|
||||||
:key="f.value"
|
? 'bg-blue-500 border-blue-500 text-white'
|
||||||
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
|
||||||
:class="activeFilter === f.value
|
@click="activeFilter = f.value">{{ f.label }}</button>
|
||||||
? 'bg-blue-500 border-blue-500 text-white'
|
|
||||||
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
|
|
||||||
@click="activeFilter = f.value"
|
|
||||||
>{{ f.label }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-zinc-600 ml-auto">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
|
|
||||||
|
<!-- Tri -->
|
||||||
|
<div class="flex items-center gap-1 ml-auto">
|
||||||
|
<button v-for="s in sortOptions" :key="s.value"
|
||||||
|
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
||||||
|
:class="activeSort === s.value
|
||||||
|
? 'bg-white/10 border-white/20 text-zinc-100'
|
||||||
|
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
|
||||||
|
@click="activeSort = s.value">{{ s.label }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-xs text-zinc-600">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Main -->
|
<!-- Main -->
|
||||||
<main class="max-w-7xl mx-auto px-6 py-8">
|
<main class="max-w-7xl mx-auto px-6 py-8">
|
||||||
|
|
||||||
<!-- Loading -->
|
|
||||||
<div v-if="store.loadingSeries" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
|
<div v-if="store.loadingSeries" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
|
||||||
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
|
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
|
||||||
<p class="text-sm">Chargement du catalogue…</p>
|
<p class="text-sm">Chargement du catalogue…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
|
||||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
|
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
|
||||||
<svg width="36" height="36" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg width="36" height="36" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
|
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
|
||||||
@@ -117,12 +105,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty -->
|
|
||||||
<div v-else-if="filtered.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-96 text-zinc-600">
|
<div v-else-if="filtered.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-96 text-zinc-600">
|
||||||
<p class="text-sm">Aucune série trouvée</p>
|
<p class="text-sm">Aucune série trouvée</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Grid -->
|
|
||||||
<div v-else class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-5">
|
<div v-else class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-5">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="serie in filtered"
|
v-for="serie in filtered"
|
||||||
@@ -131,15 +117,9 @@
|
|||||||
class="group flex flex-col gap-2 transition-transform duration-200 hover:-translate-y-1"
|
class="group flex flex-col gap-2 transition-transform duration-200 hover:-translate-y-1"
|
||||||
:class="{ 'opacity-40': !serie.has_torrents }"
|
:class="{ 'opacity-40': !serie.has_torrents }"
|
||||||
>
|
>
|
||||||
<!-- Poster -->
|
|
||||||
<div class="relative aspect-[2/3] rounded-xl overflow-hidden bg-zinc-900 border border-white/5">
|
<div class="relative aspect-[2/3] rounded-xl overflow-hidden bg-zinc-900 border border-white/5">
|
||||||
<img
|
<img v-if="serie.poster_image" :src="serie.poster_image" :alt="serie.title"
|
||||||
v-if="serie.poster_image"
|
loading="lazy" class="w-full h-full object-cover"/>
|
||||||
:src="serie.poster_image"
|
|
||||||
:alt="serie.title"
|
|
||||||
loading="lazy"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700">
|
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700">
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<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"/>
|
<rect x="2" y="2" width="20" height="20" rx="2"/>
|
||||||
@@ -147,16 +127,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Badge état -->
|
||||||
|
|
||||||
|
|
||||||
<!-- Badge état téléchargement -->
|
|
||||||
<div v-if="serie.download_state !== 'none'"
|
<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="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' :
|
:class="serie.download_state === 'complete' ? 'bg-green-500/90 text-white' :
|
||||||
serie.download_state === 'partial' ? 'bg-orange-500/90 text-white' :
|
serie.download_state === 'partial' ? 'bg-orange-500/90 text-white' :
|
||||||
serie.download_state === 'downloading' ? 'bg-blue-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">
|
<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"/>
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -168,32 +144,26 @@
|
|||||||
<polyline points="7 10 12 15 17 10"/>
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ serie.download_state === 'complete' ? 'ORGANISÉ' : serie.download_state === 'downloading' ? 'EN COURS' : 'PARTIEL' }}
|
{{ serie.download_state === 'complete' ? 'IMPORTÉ' : serie.download_state === 'downloading' ? 'EN COURS' : 'PARTIEL' }}
|
||||||
</div>
|
</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">
|
<div class="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
<span class="text-xs text-white border border-white/40 px-3 py-1.5 rounded-md">Voir</span>
|
<span class="text-xs text-white border border-white/40 px-3 py-1.5 rounded-md">Voir</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Info -->
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-medium truncate leading-snug">{{ serie.title }}</p>
|
<p class="text-xs font-medium truncate leading-snug">{{ serie.title }}</p>
|
||||||
<div class="flex items-center gap-1.5 mt-0.5">
|
<div class="flex items-center gap-1.5 mt-0.5">
|
||||||
<span v-if="serie.year" class="text-[11px] text-zinc-600">{{ serie.year }}</span>
|
<span v-if="serie.year" class="text-[11px] text-zinc-600">{{ serie.year }}</span>
|
||||||
<span
|
<span v-if="serie.status" class="text-[10px] px-1.5 py-0.5 rounded"
|
||||||
v-if="serie.status"
|
:class="serie.status.toLowerCase() === 'continuing'
|
||||||
class="text-[10px] px-1.5 py-0.5 rounded"
|
? 'bg-green-500/10 text-green-400'
|
||||||
:class="serie.status.toLowerCase() === 'continuing'
|
: 'bg-white/5 text-zinc-600'">{{ serie.status }}</span>
|
||||||
? 'bg-green-500/10 text-green-400'
|
|
||||||
: 'bg-white/5 text-zinc-600'"
|
|
||||||
>{{ serie.status }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -205,36 +175,66 @@ import { useSeriesStore } from '@/stores/series'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
const store = useSeriesStore()
|
const store = useSeriesStore()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { add: toast } = useToast()
|
const { add: toast } = useToast()
|
||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const activeFilter = ref('all')
|
const activeFilter = ref('all')
|
||||||
|
const activeSort = ref('alpha')
|
||||||
const activeDownloads = ref(0)
|
const activeDownloads = ref(0)
|
||||||
|
|
||||||
const filtersGroupe1 = [
|
const filtersGroupe1 = [
|
||||||
{ label: 'Toutes', value: 'all' },
|
{ label: 'Toutes', value: 'all' },
|
||||||
{ label: 'Disponibles', value: 'available' },
|
{ label: 'Disponibles', value: 'available' },
|
||||||
{ label: 'Sans torrent', value: 'unavailable' },
|
{ label: 'Sans torrent', value: 'unavailable' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const filtersGroupe2 = [
|
const filtersGroupe2 = [
|
||||||
{ label: 'DL complet', value: 'complete' },
|
{ label: 'Importés', value: 'complete' },
|
||||||
{ label: 'DL partiel', value: 'downloading' },
|
{ label: 'En cours', value: 'downloading' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ label: 'A→Z', value: 'alpha' },
|
||||||
|
{ label: 'Z→A', value: 'alpha-desc' },
|
||||||
|
{ label: 'Récents', value: 'recent' },
|
||||||
|
{ label: 'Importés', value: 'imported' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
let list = store.series
|
let list = [...store.series]
|
||||||
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
|
|
||||||
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
|
// Filtres
|
||||||
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
|
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
|
||||||
if (activeFilter.value === 'downloading') list = list.filter(s => s.download_state === 'downloading' || s.download_state === 'partial')
|
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
|
||||||
|
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
|
||||||
|
if (activeFilter.value === 'downloading') list = list.filter(s => s.download_state === 'downloading' || s.download_state === 'partial')
|
||||||
|
|
||||||
if (search.value.trim()) {
|
if (search.value.trim()) {
|
||||||
const q = search.value.toLowerCase()
|
const q = search.value.toLowerCase()
|
||||||
list = list.filter(s => s.title.toLowerCase().includes(q))
|
list = list.filter(s => s.title.toLowerCase().includes(q))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tri
|
||||||
|
switch (activeSort.value) {
|
||||||
|
case 'alpha':
|
||||||
|
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
|
||||||
|
break
|
||||||
|
case 'alpha-desc':
|
||||||
|
list.sort((a, b) => b.title.localeCompare(a.title, 'fr'))
|
||||||
|
break
|
||||||
|
case 'recent':
|
||||||
|
list.sort((a, b) => (b.year ?? 0) - (a.year ?? 0))
|
||||||
|
break
|
||||||
|
case 'imported':
|
||||||
|
// Importés en premier, puis partiel, puis reste
|
||||||
|
const order: Record<string, number> = { complete: 0, partial: 1, downloading: 2, none: 3 }
|
||||||
|
list.sort((a, b) => (order[a.download_state] ?? 3) - (order[b.download_state] ?? 3))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
return list
|
return list
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ async function handleLogout() {
|
|||||||
router.push('/auth')
|
router.push('/auth')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Polling downloads actifs + notifications organisation ──────
|
// ── Polling downloads + notifs import ─────────────────────────
|
||||||
let dlInterval: ReturnType<typeof setInterval> | null = null
|
let dlInterval: ReturnType<typeof setInterval> | null = null
|
||||||
const seenNotifs = new Set<string>()
|
const seenNotifs = new Set<string>()
|
||||||
|
|
||||||
@@ -261,23 +261,24 @@ async function fetchOrganizeNotifs() {
|
|||||||
const res = await fetch('/api/organize/recent', { credentials: 'include' })
|
const res = await fetch('/api/organize/recent', { credentials: 'include' })
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const notifs: any[] = await res.json()
|
const notifs: any[] = await res.json()
|
||||||
|
let hasNew = false
|
||||||
for (const n of notifs) {
|
for (const n of notifs) {
|
||||||
const key = `${n.hash}-${n.at}`
|
const key = `${n.hash}-${n.at}`
|
||||||
if (seenNotifs.has(key)) continue
|
if (seenNotifs.has(key)) continue
|
||||||
seenNotifs.add(key)
|
seenNotifs.add(key)
|
||||||
|
hasNew = true
|
||||||
if (n.done > 0) {
|
if (n.done > 0) {
|
||||||
const msg = n.errors > 0
|
const msg = n.errors > 0
|
||||||
? `${n.name} — ${n.done} fichier(s) organisé(s), ${n.errors} erreur(s)`
|
? `${n.name} — ${n.done} fichier(s) importé(s), ${n.errors} erreur(s)`
|
||||||
: `${n.name} — ${n.done} fichier(s) organisé(s) ✓`
|
: `${n.name} — ${n.done} fichier(s) importé(s) ✓`
|
||||||
toast(msg, n.errors > 0 ? 'error' : 'success')
|
toast(msg, n.errors > 0 ? 'error' : 'success')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Rafraîchir le catalogue si de nouveaux imports ont eu lieu
|
||||||
|
if (hasNew) await store.fetchSeries()
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cache images ───────────────────────────────────────────────
|
|
||||||
// Géré directement dans le store series.ts
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (store.series.length === 0) await store.fetchSeries()
|
if (store.series.length === 0) await store.fetchSeries()
|
||||||
fetchActiveDownloads()
|
fetchActiveDownloads()
|
||||||
@@ -288,7 +289,6 @@ onMounted(async () => {
|
|||||||
}, 10000)
|
}, 10000)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Refresh au retour sur le catalogue (après update ou scan)
|
|
||||||
onActivated(async () => {
|
onActivated(async () => {
|
||||||
await store.fetchSeries()
|
await store.fetchSeries()
|
||||||
fetchActiveDownloads()
|
fetchActiveDownloads()
|
||||||
|
|||||||
+53
-83
@@ -10,11 +10,9 @@
|
|||||||
<h1 class="text-xl font-black text-white font-sans">Téléchargements</h1>
|
<h1 class="text-xl font-black text-white font-sans">Téléchargements</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button @click="importAll" :disabled="importingAll"
|
||||||
@click="organizeAll"
|
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-green-500 hover:text-green-500 transition-colors cursor-pointer disabled:opacity-50">
|
||||||
:disabled="organizingAll"
|
{{ importingAll ? '...' : 'IMPORTER TOUT' }}
|
||||||
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-green-500 hover:text-green-500 transition-colors cursor-pointer disabled:opacity-50">
|
|
||||||
{{ organizingAll ? '...' : 'ORGANISER TOUT' }}
|
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center gap-2 text-[10px] text-[#5a7a94] tracking-widest">
|
<div class="flex items-center gap-2 text-[10px] text-[#5a7a94] tracking-widest">
|
||||||
<div class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-[#1e2d3d]'"/>
|
<div class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-[#1e2d3d]'"/>
|
||||||
@@ -38,49 +36,39 @@
|
|||||||
<!-- Onglets -->
|
<!-- Onglets -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex gap-0 border border-[#1e2d3d]">
|
<div class="flex gap-0 border border-[#1e2d3d]">
|
||||||
<button
|
<button @click="activeTab = 'active'"
|
||||||
@click="activeTab = 'active'"
|
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer"
|
||||||
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer"
|
:class="activeTab === 'active' ? 'bg-[#e8513a] text-white' : 'text-[#5a7a94] hover:text-white'">
|
||||||
:class="activeTab === 'active'
|
|
||||||
? 'bg-[#e8513a] text-white border-[#e8513a]'
|
|
||||||
: 'text-[#5a7a94] hover:text-white'">
|
|
||||||
EN COURS
|
EN COURS
|
||||||
<span v-if="activeTorrents.length > 0"
|
<span v-if="activeTorrents.length > 0" class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
|
||||||
class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
|
|
||||||
{{ activeTorrents.length }}
|
{{ activeTorrents.length }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button @click="activeTab = 'done'"
|
||||||
@click="activeTab = 'done'"
|
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer border-l border-[#1e2d3d]"
|
||||||
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer border-l border-[#1e2d3d]"
|
:class="activeTab === 'done' ? 'bg-[#e8513a] text-white' : 'text-[#5a7a94] hover:text-white'">
|
||||||
:class="activeTab === 'done'
|
|
||||||
? 'bg-[#e8513a] text-white'
|
|
||||||
: 'text-[#5a7a94] hover:text-white'">
|
|
||||||
TERMINÉS
|
TERMINÉS
|
||||||
<span v-if="doneTorrents.length > 0"
|
<span v-if="doneTorrents.length > 0" class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
|
||||||
class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
|
|
||||||
{{ doneTorrents.length }}
|
{{ doneTorrents.length }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toggle masquer organisés (onglet TERMINÉS uniquement) -->
|
|
||||||
<div v-if="activeTab === 'done'" class="flex items-center gap-2">
|
<div v-if="activeTab === 'done'" class="flex items-center gap-2">
|
||||||
<button @click="hideOrganized = !hideOrganized"
|
<button @click="hideImported = !hideImported"
|
||||||
class="relative shrink-0 w-8 h-4 rounded-full transition-colors cursor-pointer"
|
class="relative shrink-0 w-8 h-4 rounded-full transition-colors cursor-pointer"
|
||||||
:class="hideOrganized ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
|
:class="hideImported ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
|
||||||
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform duration-200"
|
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform duration-200"
|
||||||
:class="hideOrganized ? 'translate-x-4' : 'translate-x-0'" />
|
:class="hideImported ? 'translate-x-4' : 'translate-x-0'" />
|
||||||
</button>
|
</button>
|
||||||
<span class="text-[10px] text-[#5a7a94] tracking-widest">MASQUER ORGANISÉS</span>
|
<span class="text-[10px] text-[#5a7a94] tracking-widest">MASQUER IMPORTÉS</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pas de clients -->
|
<!-- Pas de clients -->
|
||||||
<div v-if="noClients" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
<div v-if="noClients" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
||||||
<p class="text-sm">Aucun client torrent configuré</p>
|
<p class="text-sm">Aucun client torrent configuré</p>
|
||||||
<router-link to="/settings"
|
<router-link to="/settings" class="text-[10px] tracking-widest text-[#e8513a] hover:opacity-75 transition-opacity">
|
||||||
class="text-[10px] tracking-widest text-[#e8513a] hover:opacity-75 transition-opacity">
|
|
||||||
CONFIGURER →
|
CONFIGURER →
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,37 +87,30 @@
|
|||||||
<p class="text-[11px]">
|
<p class="text-[11px]">
|
||||||
{{ activeTab === 'active'
|
{{ activeTab === 'active'
|
||||||
? 'Les torrents avec la catégorie « fankai » apparaîtront ici'
|
? 'Les torrents avec la catégorie « fankai » apparaîtront ici'
|
||||||
: hideOrganized ? 'Tous les torrents terminés sont organisés' : '' }}
|
: hideImported ? 'Tous les torrents terminés sont importés' : '' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Liste torrents -->
|
<!-- Liste torrents -->
|
||||||
<div v-else class="flex flex-col gap-2">
|
<div v-else class="flex flex-col gap-2">
|
||||||
<div v-for="t in visibleTorrents" :key="t.hash"
|
<div v-for="t in visibleTorrents" :key="t.hash" class="bg-[#0d1219] border border-[#1e2d3d] p-4">
|
||||||
class="bg-[#0d1219] border border-[#1e2d3d] p-4">
|
|
||||||
|
|
||||||
<!-- Ligne principale -->
|
|
||||||
<div class="flex items-start justify-between gap-4 mb-3">
|
<div class="flex items-start justify-between gap-4 mb-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm text-white font-medium truncate">{{ t.name }}</p>
|
<p class="text-sm text-white font-medium truncate">{{ t.name }}</p>
|
||||||
<div class="flex items-center gap-3 mt-1">
|
<div class="flex items-center gap-3 mt-1">
|
||||||
<span class="text-[10px] tracking-widest px-2 py-0.5 border"
|
<span class="text-[10px] tracking-widest px-2 py-0.5 border" :class="stateBadge(t.state).class">
|
||||||
:class="stateBadge(t.state).class">
|
|
||||||
{{ stateBadge(t.state).label }}
|
{{ stateBadge(t.state).label }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[10px] text-[#5a7a94]">{{ t.client_name }}</span>
|
<span class="text-[10px] text-[#5a7a94]">{{ t.client_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-right shrink-0">
|
<div class="text-right shrink-0">
|
||||||
<p class="text-sm font-bold text-white">{{ t.progress }}%</p>
|
<p class="text-sm font-bold text-white">{{ t.progress }}%</p>
|
||||||
<p class="text-[10px] text-[#5a7a94] mt-0.5">
|
<p class="text-[10px] text-[#5a7a94] mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
|
||||||
{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Barre de progression -->
|
|
||||||
<div class="h-1 bg-[#1e2d3d] rounded-full overflow-hidden mb-2">
|
<div class="h-1 bg-[#1e2d3d] rounded-full overflow-hidden mb-2">
|
||||||
<div class="h-full rounded-full transition-all duration-500"
|
<div class="h-full rounded-full transition-all duration-500"
|
||||||
:class="t.state === 'seeding' ? 'bg-green-500' :
|
:class="t.state === 'seeding' ? 'bg-green-500' :
|
||||||
@@ -138,7 +119,6 @@
|
|||||||
:style="{ width: `${t.progress}%` }"/>
|
:style="{ width: `${t.progress}%` }"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Infos bas -->
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4 text-[10px] text-[#5a7a94]">
|
<div class="flex items-center gap-4 text-[10px] text-[#5a7a94]">
|
||||||
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
|
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
|
||||||
@@ -146,7 +126,6 @@
|
|||||||
<span v-if="t.state === 'seeding'" class="text-green-500">✓ Complété</span>
|
<span v-if="t.state === 'seeding'" class="text-green-500">✓ Complété</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Badge organisation (seulement pour les terminés) -->
|
|
||||||
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
|
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
|
||||||
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
|
<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">
|
<span class="text-[9px] tracking-widest px-2 py-0.5 border border-red-500/40 text-red-400 cursor-default">
|
||||||
@@ -164,19 +143,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="text-[9px] tracking-widest px-2 py-0.5 border"
|
<span class="text-[9px] tracking-widest px-2 py-0.5 border"
|
||||||
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
|
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
|
||||||
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
|
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
|
||||||
'border-[#1e2d3d] text-[#5a7a94]'">
|
'border-red-500/40 text-red-400'">
|
||||||
{{ t.organizeState === 'done' ? `ORGANISÉ (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
{{ t.organizeState === 'done' ? `IMPORTÉ (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||||
t.organizeState === 'partial' ? `EN COURS (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
t.organizeState === 'partial' ? `EN COURS (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||||
'NON ORGANISÉ' }}
|
'NON IMPORTÉ' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button v-if="t.organizeState !== 'done'"
|
<button v-if="t.organizeState !== 'done'"
|
||||||
@click="organize(t)"
|
@click="importTorrent(t)"
|
||||||
:disabled="organizing[t.hash]"
|
:disabled="importing[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">
|
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' }}
|
{{ importing[t.hash] ? '...' : 'IMPORTER' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,18 +170,17 @@ import { useToast } from '@/composables/useToast'
|
|||||||
|
|
||||||
const { add: toast } = useToast()
|
const { add: toast } = useToast()
|
||||||
|
|
||||||
const torrents = ref<any[]>([])
|
const torrents = ref<any[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const polling = ref(true)
|
const polling = ref(true)
|
||||||
const noClients = ref(false)
|
const noClients = ref(false)
|
||||||
const organizing = ref<Record<string, boolean>>({})
|
const importing = ref<Record<string, boolean>>({})
|
||||||
const organizingAll = ref(false)
|
const importingAll = ref(false)
|
||||||
const activeTab = ref<'active' | 'done'>('active')
|
const activeTab = ref<'active' | 'done'>('active')
|
||||||
const hideOrganized = ref(false)
|
const hideImported = ref(false)
|
||||||
|
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
// ── Filtres onglets ────────────────────────────────────────────
|
|
||||||
const activeTorrents = computed(() =>
|
const activeTorrents = computed(() =>
|
||||||
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error', 'unknown'].includes(t.state))
|
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error', 'unknown'].includes(t.state))
|
||||||
)
|
)
|
||||||
@@ -213,11 +191,10 @@ const doneTorrents = computed(() =>
|
|||||||
|
|
||||||
const visibleTorrents = computed(() => {
|
const visibleTorrents = computed(() => {
|
||||||
if (activeTab.value === 'active') return activeTorrents.value
|
if (activeTab.value === 'active') return activeTorrents.value
|
||||||
if (hideOrganized.value) return doneTorrents.value.filter(t => t.organizeState !== 'done')
|
if (hideImported.value) return doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||||
return doneTorrents.value
|
return doneTorrents.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Stats ──────────────────────────────────────────────────────
|
|
||||||
const stats = computed(() => [
|
const stats = computed(() => [
|
||||||
{ label: 'EN COURS', value: torrents.value.filter(t => t.state === 'downloading').length },
|
{ label: 'EN COURS', value: torrents.value.filter(t => t.state === 'downloading').length },
|
||||||
{ label: 'COMPLÉTÉS', value: torrents.value.filter(t => t.state === 'seeding').length },
|
{ label: 'COMPLÉTÉS', value: torrents.value.filter(t => t.state === 'seeding').length },
|
||||||
@@ -225,7 +202,6 @@ const stats = computed(() => [
|
|||||||
{ label: 'ERREURS', value: torrents.value.filter(t => t.state === 'error').length },
|
{ label: 'ERREURS', value: torrents.value.filter(t => t.state === 'error').length },
|
||||||
])
|
])
|
||||||
|
|
||||||
// ── Fetch ──────────────────────────────────────────────────────
|
|
||||||
async function fetchTorrents() {
|
async function fetchTorrents() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||||
@@ -234,7 +210,6 @@ async function fetchTorrents() {
|
|||||||
torrents.value = await res.json()
|
torrents.value = await res.json()
|
||||||
noClients.value = false
|
noClients.value = false
|
||||||
} catch {
|
} catch {
|
||||||
// silencieux en polling
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -258,46 +233,44 @@ function togglePolling() {
|
|||||||
onMounted(startPolling)
|
onMounted(startPolling)
|
||||||
onUnmounted(stopPolling)
|
onUnmounted(stopPolling)
|
||||||
|
|
||||||
// ── Organiser un torrent ───────────────────────────────────────
|
async function importTorrent(torrent: any) {
|
||||||
async function organize(torrent: any) {
|
importing.value[torrent.hash] = true
|
||||||
organizing.value[torrent.hash] = true
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/organize', {
|
const res = await fetch('/api/organize', {
|
||||||
method : 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name })
|
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name })
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast(`${torrent.name} organisé ✓`, 'success')
|
toast(`${torrent.name} importé ✓`, 'success')
|
||||||
await fetchTorrents()
|
await fetchTorrents()
|
||||||
} else {
|
} else {
|
||||||
const { error } = await res.json()
|
const { error } = await res.json()
|
||||||
toast(error ?? 'Erreur lors de l\'organisation', 'error')
|
toast(error ?? "Erreur lors de l'import", 'error')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast('Impossible de contacter le serveur', 'error')
|
toast('Impossible de contacter le serveur', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
organizing.value[torrent.hash] = false
|
importing.value[torrent.hash] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Organiser tout ─────────────────────────────────────────────
|
async function importAll() {
|
||||||
async function organizeAll() {
|
const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||||
const toOrganize = doneTorrents.value.filter(t => t.organizeState !== 'done')
|
if (toImport.length === 0) {
|
||||||
if (toOrganize.length === 0) {
|
toast('Tous les torrents sont déjà importés', 'success')
|
||||||
toast('Tous les torrents sont déjà organisés', 'success')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
organizingAll.value = true
|
importingAll.value = true
|
||||||
let done = 0
|
let done = 0
|
||||||
let errors = 0
|
let errors = 0
|
||||||
|
|
||||||
for (const t of toOrganize) {
|
for (const t of toImport) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/organize', {
|
const res = await fetch('/api/organize', {
|
||||||
method : 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name })
|
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name })
|
||||||
@@ -309,14 +282,13 @@ async function organizeAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
organizingAll.value = false
|
importingAll.value = false
|
||||||
await fetchTorrents()
|
await fetchTorrents()
|
||||||
|
|
||||||
if (errors === 0) toast(`${done} torrent(s) organisé(s) ✓`, 'success')
|
if (errors === 0) toast(`${done} torrent(s) importé(s) ✓`, 'success')
|
||||||
else toast(`${done} OK, ${errors} erreur(s)`, 'error')
|
else toast(`${done} OK, ${errors} erreur(s)`, 'error')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers affichage ──────────────────────────────────────────
|
|
||||||
function stateBadge(state: string) {
|
function stateBadge(state: string) {
|
||||||
const map: Record<string, { label: string; class: string }> = {
|
const map: Record<string, { label: string; class: string }> = {
|
||||||
downloading: { label: 'TÉLÉCHARGEMENT', class: 'border-[#e8513a]/40 text-[#e8513a]' },
|
downloading: { label: 'TÉLÉCHARGEMENT', class: 'border-[#e8513a]/40 text-[#e8513a]' },
|
||||||
@@ -336,9 +308,7 @@ function formatSize(bytes: number): string {
|
|||||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSpeed(bps: number): string {
|
function formatSpeed(bps: number): string { return `${formatSize(bps)}/s` }
|
||||||
return `${formatSize(bps)}/s`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatEta(seconds: number): string {
|
function formatEta(seconds: number): string {
|
||||||
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||||
|
|||||||
+56
-56
@@ -15,7 +15,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES MANQUANTES</div>
|
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES MANQUANTES</div>
|
||||||
<p class="text-sm text-[#a0b4c4]">
|
<p class="text-sm text-[#a0b4c4]">
|
||||||
{{ torrentsStatus.exists ? 'Le fichier de torrents est vide.' : 'Aucun fichier de torrents trouvé.' }}
|
{{ torrentsStatus.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
|
||||||
Téléchargez les données pour utiliser Fankarr.
|
Téléchargez les données pour utiliser Fankarr.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
<!-- Données torrents -->
|
<!-- Données torrents -->
|
||||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
|
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
|
||||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES</div>
|
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES</div>
|
||||||
<h2 class="text-sm font-bold text-white mb-1">Torrents Fankai</h2>
|
<h2 class="text-sm font-bold text-white mb-1">Catalogue Fankai</h2>
|
||||||
<p class="text-[11px] text-[#5a7a94] mb-5">
|
<p class="text-[11px] text-[#5a7a94] mb-5">
|
||||||
{{ torrentsStatus.empty ? 'Aucune donnée chargée' : `${torrentsStatus.count} torrents chargés` }}
|
{{ torrentsStatus.empty ? 'Aucune donnée chargée' : `${torrentsStatus.count} séries disponibles` }}
|
||||||
</p>
|
</p>
|
||||||
<button @click="update" :disabled="updating"
|
<button @click="update" :disabled="updating"
|
||||||
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
|
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
|
||||||
@@ -78,17 +78,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Médias & Organisation -->
|
<!-- Médias & Import -->
|
||||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
|
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
|
||||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">MÉDIAS</div>
|
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">MÉDIAS</div>
|
||||||
<h2 class="text-sm font-bold text-white mb-6">Organisation</h2>
|
<h2 class="text-sm font-bold text-white mb-6">Import</h2>
|
||||||
|
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
|
|
||||||
<!-- Dossier complétion -->
|
<!-- Dossier complétion -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-1.5">
|
<div class="flex items-center gap-2 mb-1.5">
|
||||||
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER COMPLÉTION</label>
|
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER TÉLÉCHARGEMENTS</label>
|
||||||
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
|
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
|
||||||
</div>
|
</div>
|
||||||
<button @click="openPicker('completePath')"
|
<button @click="openPicker('completePath')"
|
||||||
@@ -96,13 +96,13 @@
|
|||||||
:class="form.completePath ? 'text-white' : 'text-[#5a7a94]'">
|
:class="form.completePath ? 'text-white' : 'text-[#5a7a94]'">
|
||||||
{{ form.completePath || '/' }}
|
{{ form.completePath || '/' }}
|
||||||
</button>
|
</button>
|
||||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier de complétion de qBittorrent</p>
|
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dossier Jellyfin -->
|
<!-- Dossier médiathèque -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-1.5">
|
<div class="flex items-center gap-2 mb-1.5">
|
||||||
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER JELLYFIN</label>
|
<label class="text-[10px] text-[#5a7a94] tracking-widest">MÉDIATHÈQUE FANKAI</label>
|
||||||
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
|
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
|
||||||
</div>
|
</div>
|
||||||
<button @click="openPicker('mediaPath')"
|
<button @click="openPicker('mediaPath')"
|
||||||
@@ -110,12 +110,12 @@
|
|||||||
:class="form.mediaPath ? 'text-white' : 'text-[#5a7a94]'">
|
:class="form.mediaPath ? 'text-white' : 'text-[#5a7a94]'">
|
||||||
{{ form.mediaPath || '/' }}
|
{{ form.mediaPath || '/' }}
|
||||||
</button>
|
</button>
|
||||||
<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">Dossier racine de votre médiathèque Fankai (Jellyfin, Kodi, Plex…)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mode organisation -->
|
<!-- Mode import -->
|
||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">MODE</label>
|
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">MODE D'IMPORT</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button @click="form.organizeMode = 'hardlink'"
|
<button @click="form.organizeMode = 'hardlink'"
|
||||||
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
|
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
|
||||||
@@ -129,11 +129,28 @@
|
|||||||
:class="form.organizeMode === 'move'
|
:class="form.organizeMode === 'move'
|
||||||
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
|
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
|
||||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
|
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
|
||||||
MOVE
|
DÉPLACER
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
||||||
<span class="text-white">Hardlink recommandé</span> — le fichier reste dans qBittorrent pour le ratio
|
<span class="text-white">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import automatique -->
|
||||||
|
<div>
|
||||||
|
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">IMPORT AUTOMATIQUE</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button @click="form.autoImport = !form.autoImport"
|
||||||
|
class="relative shrink-0 w-11 h-6 rounded-full transition-colors cursor-pointer"
|
||||||
|
:class="form.autoImport ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
|
||||||
|
<span class="absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200"
|
||||||
|
:class="form.autoImport ? 'translate-x-5' : 'translate-x-0'" />
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-[#c8d8e8]">Importer automatiquement dès qu'un téléchargement est terminé</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
||||||
|
Vérifie toutes les 5 minutes si de nouveaux fichiers sont prêts à être importés
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,7 +168,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
||||||
Pour les lecteurs sans agent Fankai (Kodi, Infuse, etc.) — télécharge les métadonnées depuis
|
Pour les lecteurs sans agent Fankai (Kodi, Infuse, etc.) — télécharge les métadonnées depuis
|
||||||
<span class="text-white">fankai_pack</span> au moment de l'organisation
|
<span class="text-white">fankai_pack</span> lors de l'import
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,7 +179,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button type="button" @click="scan" :disabled="scanning"
|
<button type="button" @click="scan" :disabled="scanning"
|
||||||
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors disabled:opacity-50 cursor-pointer">
|
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors disabled:opacity-50 cursor-pointer">
|
||||||
{{ scanning ? 'SCAN...' : 'ANALYSER BIBLIOTHÈQUE' }}
|
{{ scanning ? 'SCAN...' : 'ANALYSER MÉDIATHÈQUE' }}
|
||||||
</button>
|
</button>
|
||||||
<span v-if="scanResult" class="text-[10px]"
|
<span v-if="scanResult" class="text-[10px]"
|
||||||
:class="scanResult.added > 0 ? 'text-green-400' : 'text-[#5a7a94]'">
|
:class="scanResult.added > 0 ? 'text-green-400' : 'text-[#5a7a94]'">
|
||||||
@@ -200,7 +217,7 @@
|
|||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">NOM</label>
|
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">NOM</label>
|
||||||
<input v-model="modal.name" placeholder="Mon qBittorrent"
|
<input v-model="modal.name" placeholder="Mon client torrent"
|
||||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"/>
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -259,11 +276,12 @@ const form = ref({
|
|||||||
completePath: '',
|
completePath: '',
|
||||||
organizeMode: 'hardlink' as 'hardlink' | 'move',
|
organizeMode: 'hardlink' as 'hardlink' | 'move',
|
||||||
nfoSupport : false,
|
nfoSupport : false,
|
||||||
|
autoImport : true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const updating = ref(false)
|
const updating = ref(false)
|
||||||
const scanning = ref(false)
|
const scanning = ref(false)
|
||||||
const scanResult = ref<{ found: number; added: number } | null>(null)
|
const scanResult = ref<{ found: number; added: number } | null>(null)
|
||||||
|
|
||||||
// ── Folder picker ──────────────────────────────────────────────
|
// ── Folder picker ──────────────────────────────────────────────
|
||||||
@@ -272,23 +290,19 @@ const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; current
|
|||||||
})
|
})
|
||||||
|
|
||||||
function openPicker(field: 'completePath' | 'mediaPath') {
|
function openPicker(field: 'completePath' | 'mediaPath') {
|
||||||
picker.value = {
|
picker.value = { open: true, field, currentPath: (form.value as any)[field] || '/' }
|
||||||
open : true,
|
|
||||||
field,
|
|
||||||
currentPath: (form.value as any)[field] || '/',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPickerSelect(path: string) {
|
function onPickerSelect(path: string) {
|
||||||
(form.value as any)[picker.value.field] = path
|
(form.value as any)[picker.value.field] = path
|
||||||
picker.value.open = false
|
picker.value.open = false
|
||||||
}
|
}
|
||||||
const isDocker = ref(false)
|
|
||||||
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
|
|
||||||
|
|
||||||
const clients = ref<any[]>([])
|
const isDocker = ref(false)
|
||||||
|
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
|
||||||
|
const clients = ref<any[]>([])
|
||||||
const availableClients = ref<any[]>([])
|
const availableClients = ref<any[]>([])
|
||||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||||
|
|
||||||
const modal = ref({
|
const modal = ref({
|
||||||
open : false,
|
open : false,
|
||||||
@@ -333,9 +347,7 @@ async function refreshHealth(uuid: string) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
|
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
|
||||||
healthStatus.value[uuid] = res.ok ? (await res.json()).online : false
|
healthStatus.value[uuid] = res.ok ? (await res.json()).online : false
|
||||||
} catch {
|
} catch { healthStatus.value[uuid] = false }
|
||||||
healthStatus.value[uuid] = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Test / delete clients ──────────────────────────────────────
|
// ── Test / delete clients ──────────────────────────────────────
|
||||||
@@ -362,9 +374,7 @@ function openAddModal() {
|
|||||||
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() { modal.value.open = false }
|
||||||
modal.value.open = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTypeChange() {
|
function onTypeChange() {
|
||||||
modal.value.config = {}
|
modal.value.config = {}
|
||||||
@@ -382,9 +392,7 @@ async function testNewClient() {
|
|||||||
modal.value.tested = false
|
modal.value.tested = false
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/torrent-clients/test-config', {
|
const res = await fetch('/api/torrent-clients/test-config', {
|
||||||
method : 'POST',
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ type: modal.value.type, config: modal.value.config })
|
body: JSON.stringify({ type: modal.value.type, config: modal.value.config })
|
||||||
})
|
})
|
||||||
const { ok, message } = await res.json()
|
const { ok, message } = await res.json()
|
||||||
@@ -402,14 +410,8 @@ async function saveClient() {
|
|||||||
modal.value.saving = true
|
modal.value.saving = true
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/torrent-clients', {
|
const res = await fetch('/api/torrent-clients', {
|
||||||
method : 'POST',
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify({ name: modal.value.name || clientLabel(modal.value.type), type: modal.value.type, config: modal.value.config })
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({
|
|
||||||
name : modal.value.name || clientLabel(modal.value.type),
|
|
||||||
type : modal.value.type,
|
|
||||||
config: modal.value.config
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const client = await res.json()
|
const client = await res.json()
|
||||||
@@ -426,7 +428,7 @@ async function saveClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Données torrents ───────────────────────────────────────────
|
// ── Données ────────────────────────────────────────────────────
|
||||||
async function update() {
|
async function update() {
|
||||||
updating.value = true
|
updating.value = true
|
||||||
try {
|
try {
|
||||||
@@ -434,7 +436,7 @@ async function update() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const { count } = await res.json()
|
const { count } = await res.json()
|
||||||
torrentsStatus.value = { exists: true, count, empty: count === 0 }
|
torrentsStatus.value = { exists: true, count, empty: count === 0 }
|
||||||
toast(`${count} torrents téléchargés ✓`, 'success')
|
toast(`${count} séries chargées ✓`, 'success')
|
||||||
} else {
|
} else {
|
||||||
const { error } = await res.json()
|
const { error } = await res.json()
|
||||||
toast(error ?? 'Erreur lors de la mise à jour', 'error')
|
toast(error ?? 'Erreur lors de la mise à jour', 'error')
|
||||||
@@ -446,7 +448,7 @@ async function update() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scan bibliothèque ──────────────────────────────────────────
|
// ── Scan ───────────────────────────────────────────────────────
|
||||||
async function scan() {
|
async function scan() {
|
||||||
scanning.value = true
|
scanning.value = true
|
||||||
scanResult.value = null
|
scanResult.value = null
|
||||||
@@ -457,12 +459,12 @@ async function scan() {
|
|||||||
scanResult.value = { found: data.found, added: data.added }
|
scanResult.value = { found: data.found, added: data.added }
|
||||||
toast(
|
toast(
|
||||||
data.added > 0
|
data.added > 0
|
||||||
? `${data.found} fichiers scannés — ${data.added} ajoutés`
|
? `${data.found} fichiers analysés — ${data.added} ajoutés`
|
||||||
: `${data.found} fichiers scannés — rien de nouveau`,
|
: `${data.found} fichiers analysés — rien de nouveau`,
|
||||||
'success'
|
'success'
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
toast('Erreur lors du scan', 'error')
|
toast('Erreur lors de l\'analyse', 'error')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast('Impossible de contacter le serveur', 'error')
|
toast('Impossible de contacter le serveur', 'error')
|
||||||
@@ -471,14 +473,12 @@ async function scan() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sauvegarder settings ───────────────────────────────────────
|
// ── Sauvegarder ────────────────────────────────────────────────
|
||||||
async function save() {
|
async function save() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings', {
|
const res = await fetch('/api/settings', {
|
||||||
method : 'POST',
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(form.value)
|
body: JSON.stringify(form.value)
|
||||||
})
|
})
|
||||||
if (res.ok) toast('Configuration sauvegardée', 'success')
|
if (res.ok) toast('Configuration sauvegardée', 'success')
|
||||||
|
|||||||
Reference in new issue
Block a user