fix(client): fix de plusieurs torrent et refonte du tableau de compatibilité
feat(rss): ajout du rss sur les series
This commit is contained in:
1 parent
f07b608288
commit
086ca3053f
7 files changed
+474
-27
No files matched your search
@@ -28,6 +28,8 @@ import importRouter from './routes/import.js'
|
||||
import systemRouter from './routes/system.js'
|
||||
import nfoUpdatesRouter from './routes/nfo-updates.js'
|
||||
import plexRouter from './routes/plex.js'
|
||||
import rssSyncRouter from './routes/rss-sync.js'
|
||||
import { runRssSync } from './lib/rss-sync.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
registerDriver(transmissionDriver)
|
||||
@@ -63,6 +65,7 @@ app.use('/api', importRouter)
|
||||
app.use('/api', systemRouter)
|
||||
app.use('/api', nfoUpdatesRouter)
|
||||
app.use('/api', plexRouter)
|
||||
app.use('/api', rssSyncRouter)
|
||||
|
||||
// ── Catch-all SPA ──────────────────────────────────────────────
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
@@ -161,4 +164,12 @@ server.listen(PORT, async () => {
|
||||
logger.error('api', `Sync noms auto échoué : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}, 60 * 60_000)
|
||||
|
||||
// RSS sync — auto-téléchargement des nouveaux épisodes toutes les 6 heures.
|
||||
setTimeout(() => {
|
||||
runRssSync().catch(err => logger.error('rss-sync', `Sync initial échoué : ${err instanceof Error ? err.message : err}`))
|
||||
setInterval(() => {
|
||||
runRssSync().catch(err => logger.error('rss-sync', `Sync périodique échoué : ${err instanceof Error ? err.message : err}`))
|
||||
}, 6 * 60 * 60_000)
|
||||
}, 60_000) // premier cycle 1 min après le démarrage
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* RSS Sync — auto-téléchargement des nouveaux épisodes pour les séries surveillées.
|
||||
*
|
||||
* Toutes les 6 heures, FanKarr vérifie si de nouveaux épisodes sont apparus
|
||||
* APRÈS le dernier épisode organisé de chaque saison.
|
||||
*
|
||||
* Règle de frontière : si le dernier épisode organisé d'une saison est le 9,
|
||||
* seuls les épisodes 10+ sont candidats au téléchargement automatique.
|
||||
* Si aucun épisode n'est encore organisé dans une saison, rien n'est déclenché
|
||||
* pour cette saison (l'utilisateur doit lancer le premier téléchargement manuellement).
|
||||
*/
|
||||
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { loadEnrichedSeriesData, readInfohashMap } from './github-cache.js'
|
||||
import { dispatchDownload, dispatchList } from '../torrent-clients/index.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface SyncedSerie {
|
||||
serieId : number
|
||||
serieName: string
|
||||
addedAt : string // ISO
|
||||
}
|
||||
|
||||
type SyncedMap = Record<number, SyncedSerie>
|
||||
|
||||
// ── Persistance ───────────────────────────────────────────────
|
||||
|
||||
const SYNC_FILE = path.join(DATA_DIR, 'rss-synced.json')
|
||||
|
||||
export function loadSynced(): SyncedMap {
|
||||
try {
|
||||
if (fs.existsSync(SYNC_FILE))
|
||||
return JSON.parse(fs.readFileSync(SYNC_FILE, 'utf-8')) as SyncedMap
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
|
||||
function saveSynced(map: SyncedMap): void {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
fs.writeFileSync(SYNC_FILE, JSON.stringify(map, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export function setSync(serieId: number, serieName: string, enabled: boolean): SyncedMap {
|
||||
const map = loadSynced()
|
||||
if (enabled) {
|
||||
map[serieId] = { serieId, serieName, addedAt: new Date().toISOString() }
|
||||
} else {
|
||||
delete map[serieId]
|
||||
}
|
||||
saveSynced(map)
|
||||
return map
|
||||
}
|
||||
|
||||
export function isSynced(serieId: number): boolean {
|
||||
return !!loadSynced()[serieId]
|
||||
}
|
||||
|
||||
// ── Helpers organisés ─────────────────────────────────────────
|
||||
|
||||
function readOrganized(): Record<string, Record<string, any>> {
|
||||
try {
|
||||
const p = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'ensemble des IDs d'épisodes organisés pour une série donnée,
|
||||
* en croisant organized.json avec les données de la série.
|
||||
*/
|
||||
function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string, any>>): Set<number> {
|
||||
const ids = new Set<number>()
|
||||
|
||||
// Import manuel
|
||||
const manual = organized['manual'] ?? {}
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (manual[String(ep.id)]) ids.add(ep.id)
|
||||
|
||||
// Intégrales
|
||||
for (const t of sd.torrents ?? []) {
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id)
|
||||
}
|
||||
|
||||
// Packs saison + épisodes individuels
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id)
|
||||
}
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id)
|
||||
// Fallback filename
|
||||
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
if (pathEntry) {
|
||||
const fname = pathEntry.path?.split('/').pop()
|
||||
if (fname && orgFiles[fname] !== undefined) ids.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Pour une saison donnée, retourne le numéro d'épisode maximal parmi
|
||||
* les épisodes organisés, ou 0 si aucun n'est organisé.
|
||||
*/
|
||||
function maxOrganizedEpNumber(season: any, organizedIds: Set<number>): number {
|
||||
let max = 0
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (organizedIds.has(ep.id) && ep.episode_number > max)
|
||||
max = ep.episode_number
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// ── Logique de sync ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lance un cycle de RSS sync.
|
||||
*
|
||||
* Par série surveillée :
|
||||
* - Pour chaque saison, calcule la frontière = dernier épisode organisé.
|
||||
* - Ne propose au téléchargement que les épisodes APRÈS cette frontière.
|
||||
* - Si aucun épisode n'est organisé dans une saison → saison ignorée
|
||||
* (l'utilisateur doit lancer le premier téléchargement manuellement).
|
||||
* - Ne renvoie pas un torrent déjà actif dans le client.
|
||||
*/
|
||||
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
|
||||
const synced = loadSynced()
|
||||
const ids = Object.keys(synced).map(Number)
|
||||
if (ids.length === 0) return { sent: 0, skipped: 0, errors: 0 }
|
||||
|
||||
logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`)
|
||||
|
||||
const organized = readOrganized()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
|
||||
// Torrents actifs dans le client
|
||||
const activeHashes = new Set<string>()
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const infohashMap = await readInfohashMap()
|
||||
const active = await dispatchList(category ?? 'fankai', infohashMap)
|
||||
for (const t of active) if (t.hash) activeHashes.add(t.hash.toLowerCase())
|
||||
} catch (err) {
|
||||
logger.warn('rss-sync', `Impossible de lire les torrents actifs : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
let errors = 0
|
||||
|
||||
for (const serieId of ids) {
|
||||
const sd = seriesData.find((s: any) => s.id === serieId)
|
||||
if (!sd) {
|
||||
logger.warn('rss-sync', `Série ${serieId} introuvable dans le cache`)
|
||||
continue
|
||||
}
|
||||
|
||||
const title = sd.title ?? sd.show_title ?? String(serieId)
|
||||
const organizedIds = getOrganizedEpisodeIds(sd, organized)
|
||||
|
||||
if (organizedIds.size === 0) {
|
||||
logger.info('rss-sync', `${title} — aucun épisode organisé, sync ignoré (lance le 1er téléchargement manuellement)`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
logger.info('rss-sync', `Vérification : ${title} (${organizedIds.size} épisodes organisés)`)
|
||||
|
||||
// Épisodes candidats = après la frontière, par saison
|
||||
// Un épisode est candidat si : episode_number > max organisé dans cette saison
|
||||
const candidateIds = new Set<number>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
const frontier = maxOrganizedEpNumber(season, organizedIds)
|
||||
if (frontier === 0) continue // aucun épisode organisé dans cette saison → skip
|
||||
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (ep.episode_number > frontier && !organizedIds.has(ep.id))
|
||||
candidateIds.add(ep.id)
|
||||
}
|
||||
|
||||
if (candidateIds.size > 0)
|
||||
logger.info('rss-sync', `${title} S${season.season_number} — frontière ep${frontier}, ${
|
||||
[...candidateIds].length} candidat(s)`)
|
||||
}
|
||||
|
||||
if (candidateIds.size === 0) {
|
||||
logger.info('rss-sync', `${title} — aucun nouvel épisode`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Construire la liste des téléchargements (intégrale → pack saison → épisode)
|
||||
const toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = []
|
||||
const coveredIds = new Set<number>()
|
||||
|
||||
// 1. Intégrale(s) — uniquement si elle couvre au moins un candidat
|
||||
for (const t of sd.torrents ?? []) {
|
||||
if (!t.fankai) continue
|
||||
const url = t.torrent_url ?? t.magnet
|
||||
if (!url) continue
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (hash && activeHashes.has(hash)) { skipped++; continue }
|
||||
|
||||
const integraleEps: any[] = []
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (ep.torrents?.some((et: any) => et.infohash?.toLowerCase() === hash))
|
||||
integraleEps.push(ep)
|
||||
|
||||
const coversSomeCandidate = integraleEps.some(ep => candidateIds.has(ep.id))
|
||||
if (!coversSomeCandidate) { skipped++; continue }
|
||||
|
||||
toDownload.push({ url, infohash: hash ?? null, label: `intégrale ${title}` })
|
||||
for (const ep of integraleEps) coveredIds.add(ep.id)
|
||||
}
|
||||
|
||||
// 2. Packs saison — uniquement si le pack couvre au moins un candidat non couvert
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
if (!t.fankai) continue
|
||||
const url = t.torrent_url ?? t.magnet
|
||||
if (!url) continue
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (hash && activeHashes.has(hash)) { skipped++; continue }
|
||||
|
||||
const coversSomeCandidate = (season.episodes ?? []).some((ep: any) =>
|
||||
candidateIds.has(ep.id) && !coveredIds.has(ep.id)
|
||||
)
|
||||
if (!coversSomeCandidate) { skipped++; continue }
|
||||
|
||||
toDownload.push({ url, infohash: hash ?? null, label: `S${season.season_number} ${title}` })
|
||||
for (const ep of season.episodes ?? []) coveredIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Épisodes individuels candidats non encore couverts
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (!candidateIds.has(ep.id) || coveredIds.has(ep.id)) continue
|
||||
|
||||
for (const t of ep.torrents ?? []) {
|
||||
if (!t.fankai) continue
|
||||
const url = t.torrent_url ?? t.magnet
|
||||
if (!url) continue
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (hash && activeHashes.has(hash)) { skipped++; continue }
|
||||
|
||||
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === hash)
|
||||
toDownload.push({
|
||||
url,
|
||||
infohash : hash ?? null,
|
||||
file_index: pathEntry?.file_index ?? null,
|
||||
file_path : pathEntry?.path ?? null,
|
||||
label : `S${season.season_number}E${ep.episode_number} ${title}`,
|
||||
})
|
||||
coveredIds.add(ep.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Envoyer
|
||||
for (const dl of toDownload) {
|
||||
try {
|
||||
const results = await dispatchDownload(dl.url, {
|
||||
file_index: dl.file_index,
|
||||
file_path : dl.file_path,
|
||||
infohash : dl.infohash,
|
||||
})
|
||||
if (results.some((r: any) => r.ok)) {
|
||||
logger.info('rss-sync', `Envoyé : ${dl.label}`)
|
||||
sent++
|
||||
} else {
|
||||
logger.warn('rss-sync', `Échec envoi : ${dl.label}`)
|
||||
errors++
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('rss-sync', `Erreur dispatch ${dl.label} : ${err instanceof Error ? err.message : err}`)
|
||||
errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('rss-sync', `Sync terminé — ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`)
|
||||
return { sent, skipped, errors }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { loadSynced, setSync, isSynced, runRssSync } from '../lib/rss-sync.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** GET /api/rss-sync → liste des séries surveillées */
|
||||
router.get('/rss-sync', requireAuth, (_req, res) => {
|
||||
res.json(Object.values(loadSynced()))
|
||||
})
|
||||
|
||||
/** POST /api/rss-sync/run → forcer un cycle de sync maintenant (avant /:id) */
|
||||
router.post('/rss-sync/run', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const result = await runRssSync()
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error('rss-sync', `Sync forcé échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
/** GET /api/rss-sync/:id → statut sync pour une série */
|
||||
router.get('/rss-sync/:id', requireAuth, (req, res) => {
|
||||
const serieId = Number(req.params.id)
|
||||
res.json({ synced: isSynced(serieId) })
|
||||
})
|
||||
|
||||
/** POST /api/rss-sync/:id → activer la surveillance */
|
||||
router.post('/rss-sync/:id', requireAuth, (req, res) => {
|
||||
const serieId = Number(req.params.id)
|
||||
const serieName = String(req.body.serieName ?? '')
|
||||
if (!serieId || !serieName) { res.status(400).json({ error: 'serieId et serieName requis' }); return }
|
||||
const map = setSync(serieId, serieName, true)
|
||||
logger.info('rss-sync', `Surveillance activée pour "${serieName}" (id ${serieId})`)
|
||||
res.json({ synced: true, total: Object.keys(map).length })
|
||||
})
|
||||
|
||||
/** DELETE /api/rss-sync/:id → désactiver la surveillance */
|
||||
router.delete('/rss-sync/:id', requireAuth, (req, res) => {
|
||||
const serieId = Number(req.params.id)
|
||||
const map = setSync(serieId, '', false)
|
||||
logger.info('rss-sync', `Surveillance désactivée pour la série ${serieId}`)
|
||||
res.json({ synced: false, total: Object.keys(map).length })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -244,7 +244,7 @@ const RT: TorrentClientDriver = {
|
||||
await Promise.all(rows.map(async (r: any) => {
|
||||
const hash = String(r[0]).toUpperCase()
|
||||
try {
|
||||
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path='])
|
||||
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path=', 'f.priority='])
|
||||
if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files)
|
||||
} catch {}
|
||||
}))
|
||||
@@ -279,7 +279,7 @@ const RT: TorrentClientDriver = {
|
||||
index : i,
|
||||
name : String(f[2] ?? ''),
|
||||
progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0,
|
||||
priority: 1,
|
||||
priority: Number(f[3] ?? 1) === 0 ? 0 : 1,
|
||||
}))
|
||||
: undefined,
|
||||
} satisfies TorrentInfo
|
||||
|
||||
@@ -166,7 +166,7 @@ const DS: TorrentClientDriver = {
|
||||
version: '1',
|
||||
method : 'delete',
|
||||
id : found.id,
|
||||
force_complete: deleteFiles ? 'false' : 'false',
|
||||
force_complete: deleteFiles ? 'true' : 'false',
|
||||
_sid : sid,
|
||||
})
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
|
||||
+44
-1
@@ -50,6 +50,24 @@
|
||||
|
||||
<!-- Droite : téléchargements -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Bouton RSS sync -->
|
||||
<button
|
||||
@click="toggleRssSync"
|
||||
:title="rssSync ? 'Surveillance activée — cliquer pour désactiver' : 'Surveiller les nouveaux épisodes'"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
||||
:class="rssSync
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20 hover:bg-green-500/20'
|
||||
: 'bg-shell text-muted border-border hover:text-primary hover:bg-hover'"
|
||||
>
|
||||
<!-- Icône antenne RSS -->
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M4 11a9 9 0 0 1 9 9"/>
|
||||
<path d="M4 4a16 16 0 0 1 16 16"/>
|
||||
<circle cx="5" cy="19" r="1" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
{{ rssSync ? 'Surveillé' : 'Surveiller' }}
|
||||
</button>
|
||||
|
||||
<!-- Bouton unique "Tout télécharger" — dropdown si plusieurs intégrales -->
|
||||
<div v-if="hasSomethingToDownload" class="relative" @click.stop>
|
||||
<button
|
||||
@@ -173,6 +191,7 @@ const deleteSerieFiles = ref(false)
|
||||
const downloadMenuOpen = ref(false)
|
||||
const downloadingAll = ref(false)
|
||||
const downloadingSeason = ref<Record<number, boolean>>({})
|
||||
const rssSync = ref(false)
|
||||
|
||||
export interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[]; save_path?: string; name?: string }
|
||||
const activeTorrents = ref<ActiveTorrent[]>([])
|
||||
@@ -268,6 +287,30 @@ async function fetchSettings() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchRssSync() {
|
||||
try {
|
||||
const res = await fetch(`/api/rss-sync/${route.params.id}`, { credentials: 'include' })
|
||||
if (res.ok) { const d = await res.json(); rssSync.value = !!d.synced }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function toggleRssSync() {
|
||||
const serieId = Number(route.params.id)
|
||||
const serieName = data.value?.serie?.title ?? String(serieId)
|
||||
try {
|
||||
if (rssSync.value) {
|
||||
const res = await fetch(`/api/rss-sync/${serieId}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) { rssSync.value = false; toast('Surveillance désactivée', 'success') }
|
||||
} else {
|
||||
const res = await fetch(`/api/rss-sync/${serieId}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ serieName }),
|
||||
})
|
||||
if (res.ok) { rssSync.value = true; toast('Surveillance activée — les nouveaux épisodes seront téléchargés automatiquement ✓', 'success') }
|
||||
}
|
||||
} catch { toast('Erreur lors de la mise à jour de la surveillance', 'error') }
|
||||
}
|
||||
|
||||
async function fetchActiveDownloads() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
@@ -514,7 +557,7 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
|
||||
const closeMenus = () => { downloadMenuOpen.value = false }
|
||||
|
||||
onMounted(() => {
|
||||
load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads()
|
||||
load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads(); fetchRssSync()
|
||||
pollTimer = setInterval(fetchActiveDownloads, 5000)
|
||||
document.addEventListener('click', closeMenus)
|
||||
})
|
||||
|
||||
@@ -48,40 +48,64 @@
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-shell">
|
||||
<th class="text-left px-4 py-2.5 text-muted font-medium">Client</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Sélection fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Catégories</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Dossier cible</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Remap. chemin</th>
|
||||
<th class="text-left px-4 py-2.5 text-muted font-medium whitespace-nowrap">Client</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Auth</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Sél. fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Prog. fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Upload .torrent</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Catégories</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Suppr. fichiers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/60">
|
||||
<tr v-for="row in clientMatrix" :key="row.id" class="hover:bg-hover/40 transition-colors">
|
||||
<!-- Client + version -->
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === true ? 'bg-green-500' :
|
||||
healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === false ? 'bg-red-500' : 'bg-transparent'" />
|
||||
<span class="text-primary font-medium">{{ row.label }}</span>
|
||||
:class="healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === true ? 'bg-green-500' :
|
||||
healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === false ? 'bg-red-500' : 'bg-transparent'" />
|
||||
<div>
|
||||
<p class="text-primary font-medium">{{ row.label }}</p>
|
||||
<p class="text-[10px] text-muted mt-0.5">{{ row.version }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Auth -->
|
||||
<td class="text-center px-3 py-2.5 text-secondary whitespace-nowrap">{{ row.auth }}</td>
|
||||
<!-- Sélection fichier -->
|
||||
<td class="text-center px-3 py-2.5">
|
||||
<span :class="row.fileSelect === '✗' ? 'text-muted' : row.fileSelect.startsWith('Async') ? 'text-yellow-500' : 'text-green-400'">
|
||||
{{ row.fileSelect }}
|
||||
<span :class="row.fileSelect ? 'text-yellow-400' : 'text-muted'">
|
||||
{{ row.fileSelect ? 'Async ✓' : '✗' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-green-400' : 'text-muted'">{{ row.categories ? '✓' : '✗' }}</td>
|
||||
<td class="text-center px-3 py-2.5 text-green-400">✓</td>
|
||||
<td class="text-center px-3 py-2.5 text-green-400">✓</td>
|
||||
<!-- Progression par fichier -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.fileProgress ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.fileProgress ? '✓' : '✗' }}
|
||||
</td>
|
||||
<!-- Upload .torrent (FanKarr proxy) -->
|
||||
<td class="text-center px-3 py-2.5">
|
||||
<span :class="row.torrentUpload === 'proxy' ? 'text-green-400' : row.torrentUpload === 'url' ? 'text-secondary' : 'text-muted'">
|
||||
{{ row.torrentUpload === 'proxy' ? '✓ Proxy' : row.torrentUpload === 'url' ? 'URL' : '✗' }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Catégories -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.categories ? '✓' : '✗' }}
|
||||
</td>
|
||||
<!-- Suppression fichiers -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.deleteFiles ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.deleteFiles ? '✓' : '✗' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted mt-2">
|
||||
<span class="text-green-400">✓ Natif</span> — sélection à l'ajout, aucun fichier superflu ·
|
||||
<span class="text-green-400">Async ✓</span> — sélection dès réception des métadonnées, avant tout téléchargement ·
|
||||
<span class="text-muted">✗</span> — non supporté, torrent complet téléchargé
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-[11px] text-muted">
|
||||
<span><span class="text-yellow-400">Async ✓</span> — sélection dès réception des métadonnées, aucun octet superflu téléchargé</span>
|
||||
<span><span class="text-green-400">✓ Proxy</span> — FanKarr télécharge le .torrent et l'envoie au client (liens protégés supportés)</span>
|
||||
<span><span class="text-secondary">URL</span> — le client télécharge le .torrent directement depuis l'URL</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal ajout -->
|
||||
@@ -207,11 +231,30 @@ const availableClients = ref<any[]>([])
|
||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
|
||||
const clientMatrix = [
|
||||
{ id: 'qbittorrent', label: 'qBittorrent', fileSelect: 'Async ✓', categories: true },
|
||||
{ id: 'transmission', label: 'Transmission', fileSelect: '✓ Natif', categories: true },
|
||||
{ id: 'rtorrent', label: 'rTorrent / ruTorrent', fileSelect: 'Async ✓', categories: true },
|
||||
{ id: 'utorrent', label: 'uTorrent', fileSelect: 'Async ✓', categories: true },
|
||||
{ id: 'synology-ds', label: 'Synology Download Station', fileSelect: '✗', categories: false },
|
||||
{
|
||||
id: 'qbittorrent', label: 'qBittorrent', version: '4.1+',
|
||||
auth: 'Login / MdP · Clé API (5.2+)', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'transmission', label: 'Transmission', version: '3.x / 4.x',
|
||||
auth: 'Login / MdP', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'rtorrent', label: 'rTorrent / ruTorrent', version: '0.9+',
|
||||
auth: 'URL xmlrpc', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'utorrent', label: 'uTorrent', version: '3.x',
|
||||
auth: 'Login / MdP', fileSelect: true, fileProgress: false, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'synology-ds', label: 'Synology Download Station', version: '6.x+',
|
||||
auth: 'Login / MdP', fileSelect: false, fileProgress: false, torrentUpload: 'url', categories: false, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
|
||||
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
||||
},
|
||||
]
|
||||
|
||||
const modal = ref({
|
||||
|
||||
Reference in new issue
Block a user