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:
MasutaYunikon committed 2026-05-10 22:53:58 +02:00
1 parent f07b608288
commit 086ca3053f
7 files changed
+474 -27

No files matched your search

+11
View File
@@ -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
})
+302
View File
@@ -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 }
}
+48
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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)
})
+66 -23
View File
@@ -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({