fix: remove log for mapping
fix: unlink before move fix: l'organiseur prend les fichiers renommer maintenant feat: ajout d'information dans le fichier d'organisation pour l'organisation manuel plus tard fix: ajout d'une verif lors de l'écriture de la config sur les items dans body fix: ne sauvegarde plus le mots de passe "fake" quand on edit un client feat: ajout du driver rtorrent fix: empeche un login failed de se repeter et de se faire bloquer par les clients
This commit is contained in:
1 parent
630374def7
commit
8ec323dc95
6 files changed
+519
-82
No files matched your search
+66
-13
@@ -13,7 +13,8 @@ import {
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
import transmissionDriver from './torrent-clients/transmission.js'
|
||||
import synologyDsDriver from './torrent-clients/synology-ds.js'
|
||||
import utorrentDriver from './torrent-clients/utorrent.js'
|
||||
import rtorrentDriver from './torrent-clients/rtorrent.js'
|
||||
import utorrent from "./torrent-clients/utorrent";
|
||||
|
||||
import { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js'
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||
@@ -23,7 +24,8 @@ import { systemInfo } from './system.js'
|
||||
registerDriver(qbittorrentDriver)
|
||||
registerDriver(transmissionDriver)
|
||||
registerDriver(synologyDsDriver)
|
||||
registerDriver(utorrentDriver)
|
||||
registerDriver(rtorrentDriver)
|
||||
registerDriver(utorrent)
|
||||
|
||||
const app = express()
|
||||
const PORT = Number(process.env.PORT) || 9898
|
||||
@@ -54,7 +56,15 @@ app.get('/api/settings', requireAuth, (_req, res) => {
|
||||
})
|
||||
app.post('/api/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode } = req.body
|
||||
const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode })
|
||||
const patch: Record<string, any> = {}
|
||||
if (mediaPath !== undefined) patch.mediaPath = mediaPath
|
||||
if (completePath !== undefined) patch.completePath = completePath
|
||||
if (organizeMode !== undefined) patch.organizeMode = organizeMode
|
||||
if (category !== undefined) patch.category = category
|
||||
if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport
|
||||
if (autoImport !== undefined) patch.autoImport = autoImport
|
||||
if (devMode !== undefined) patch.devMode = devMode
|
||||
const updated = writeSettings(patch)
|
||||
logger.info('api', 'Paramètres mis à jour')
|
||||
res.json(updated)
|
||||
})
|
||||
@@ -81,6 +91,18 @@ app.put('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
const { name, type, config } = req.body
|
||||
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
// Restaurer les mots de passe masqués avec les valeurs actuelles
|
||||
const oldClient = getClient(String(req.params.uuid))
|
||||
if (oldClient) {
|
||||
const driver = getDriver(type)
|
||||
if (driver) {
|
||||
for (const field of driver.definition.fields) {
|
||||
if (field.type === 'password' && config[field.key] === '••••••••') {
|
||||
config[field.key] = oldClient.config[field.key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const updated = updateClient(String(req.params.uuid), name, type, config)
|
||||
if (!updated) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
res.json(sanitizeClient(updated))
|
||||
@@ -225,7 +247,7 @@ async function loadEnrichedSeriesData(): Promise<any[]> {
|
||||
return allSd.filter(Boolean)
|
||||
}
|
||||
|
||||
function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, string>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, any>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
if (!serieData) return 'none'
|
||||
const allTorrents = extractTorrentsFromSerieData(serieData)
|
||||
if (allTorrents.length === 0) return 'none'
|
||||
@@ -243,7 +265,9 @@ function computeSerieDownloadState(serieData: any | null, organized: Record<stri
|
||||
const orgFiles = organized[hash] ?? {}
|
||||
if (Object.keys(orgFiles).length === 0) continue
|
||||
for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) {
|
||||
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
|
||||
// Nouveau format : clé = episode_id ; ancien format : clé = filename
|
||||
const isOrganized = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
||||
if (isOrganized) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none'
|
||||
@@ -285,7 +309,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
|
||||
return { ...normalizeSeason(season), episodes }
|
||||
}))
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
const episodeTorrentMap : Record<number, any> = {}
|
||||
@@ -299,7 +323,10 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of resolved) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) }
|
||||
for (const ep of resolved) {
|
||||
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
||||
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const t of (season.torrents ?? [])) {
|
||||
@@ -307,16 +334,25 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of resolved) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) }
|
||||
for (const ep of resolved) {
|
||||
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
||||
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of (ep.torrents ?? [])) {
|
||||
episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true }
|
||||
availableEpisodeIds.add(ep.id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
const filename = match ? match.path.split('/').pop() : null
|
||||
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
|
||||
const isOrg = orgFiles[String(ep.id)] !== undefined
|
||||
if (!isOrg) {
|
||||
// fallback ancien format
|
||||
const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
const filename = match ? match.path.split('/').pop() : null
|
||||
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
|
||||
} else {
|
||||
organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,7 +401,7 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const torrents = await dispatchList(category ?? 'fankai')
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const enriched = await Promise.all(torrents.map(async (t: any) => {
|
||||
const orgFiles = organized[t.hash] ?? {}
|
||||
@@ -504,7 +540,7 @@ app.get('/api/debug/stats', requireAuth, (req, res) => {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath)) {
|
||||
const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
organizedCount = Object.values(org).reduce((acc: number, files: any) => acc + Object.keys(files).length, 0)
|
||||
organizedCount = Object.values(org).reduce((acc: number, episodes: any) => acc + Object.keys(episodes).length, 0)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -723,6 +759,23 @@ server.listen(PORT, async () => {
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
const { mediaPath } = readSettings()
|
||||
|
||||
// Reset organized.json au nouveau format (clé episode_id au lieu de filename)
|
||||
// Si le fichier existe et contient l'ancien format (valeurs string), on le vide
|
||||
// et on relance le scan pour reconstruire proprement
|
||||
try {
|
||||
if (fs.existsSync(ORGANIZED_PATH)) {
|
||||
const raw = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
const isOldFormat = Object.values(raw).some((entries: any) =>
|
||||
Object.values(entries).some(v => typeof v === 'string')
|
||||
)
|
||||
if (isOldFormat) {
|
||||
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
|
||||
fs.writeFileSync(ORGANIZED_PATH, '{}', 'utf-8')
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
loadEnrichedSeriesData()
|
||||
.then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData))
|
||||
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
|
||||
|
||||
+68
-34
@@ -18,6 +18,20 @@ function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'war
|
||||
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
|
||||
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────
|
||||
export interface OrgEntry {
|
||||
at : string
|
||||
season : number
|
||||
episode : number
|
||||
episode_id : number
|
||||
src_filename : string
|
||||
dest_filename: string
|
||||
dest_path : string
|
||||
}
|
||||
|
||||
// organized.json : Record<hash, Record<episode_id_string, OrgEntry>>
|
||||
type Organized = Record<string, Record<string, OrgEntry>>
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
|
||||
try {
|
||||
@@ -28,22 +42,22 @@ function readSettings(): { mediaPath: string; completePath: string; organizeMode
|
||||
}
|
||||
|
||||
// ─── Organized log ────────────────────────────────────────────
|
||||
function loadOrganized(): Record<string, Record<string, string>> {
|
||||
function loadOrganized(): Organized {
|
||||
try {
|
||||
if (!fs.existsSync(ORGANIZED_PATH)) return {}
|
||||
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
} catch { return {} }
|
||||
}
|
||||
|
||||
function markOrganized(hash: string, filename: string) {
|
||||
function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
|
||||
const data = loadOrganized()
|
||||
if (!data[hash]) data[hash] = {}
|
||||
data[hash][filename] = new Date().toISOString()
|
||||
data[hash][String(episodeId)] = entry
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
function isOrganized(hash: string, filename: string): boolean {
|
||||
return !!loadOrganized()[hash]?.[filename]
|
||||
function isOrganized(hash: string, episodeId: number): boolean {
|
||||
return !!loadOrganized()[hash]?.[String(episodeId)]
|
||||
}
|
||||
|
||||
// ─── Lookup torrent dans les seriesData ───────────────────────
|
||||
@@ -90,9 +104,8 @@ function buildFileMap(
|
||||
hash : string,
|
||||
nfoSupport : boolean,
|
||||
seasonFilter?: number,
|
||||
): Map<string, { season_number: number; nfo_filename: string; fullPath: string }> {
|
||||
// FIX : type de retour utilise nfo_filename au lieu de original_filename
|
||||
const map = new Map<string, { season_number: number; nfo_filename: string; fullPath: string }>()
|
||||
): Map<string, { season_number: number; episode_number: number; episode_id: number; nfo_filename: string; fullPath: string }> {
|
||||
const map = new Map<string, { season_number: number; episode_number: number; episode_id: number; nfo_filename: string; fullPath: string }>()
|
||||
const h = hash.toLowerCase()
|
||||
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
@@ -121,8 +134,6 @@ function buildFileMap(
|
||||
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`)
|
||||
}
|
||||
|
||||
// FIX : priorité à nfo_filename, fallback sur formatted_name puis filename brut
|
||||
// On remplace l'extension du nfo_filename par celle du fichier source (.nfo → .mkv)
|
||||
const srcExt = path.extname(filename)
|
||||
const resolvedName = nfoSupport
|
||||
? (ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
@@ -130,9 +141,11 @@ function buildFileMap(
|
||||
const destName = swapExtension(resolvedName, srcExt)
|
||||
|
||||
map.set(filename, {
|
||||
season_number: season.season_number,
|
||||
nfo_filename : destName,
|
||||
fullPath : matchedPath,
|
||||
season_number : season.season_number,
|
||||
episode_number: ep.episode_number,
|
||||
episode_id : ep.id,
|
||||
nfo_filename : destName,
|
||||
fullPath : matchedPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -240,8 +253,6 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
|
||||
|
||||
// ─── Filesystem ops ───────────────────────────────────────────
|
||||
|
||||
// FIX : remplace l'extension d'un nom de fichier par celle du fichier source
|
||||
// ex: "[Shalouf] Dandadan 01.nfo" + ".mkv" → "[Shalouf] Dandadan 01.mkv"
|
||||
function swapExtension(filename: string, ext: string): string {
|
||||
const currentExt = path.extname(filename)
|
||||
if (!currentExt || currentExt === ext) return filename
|
||||
@@ -304,18 +315,16 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: null
|
||||
|
||||
// FIX : priorité à nfo_filename pour épisode unique aussi
|
||||
// On swap l'extension (.nfo → extension du fichier source)
|
||||
const srcExt = path.extname(filename)
|
||||
const srcExt = path.extname(filename)
|
||||
const resolvedName = nfoSupport
|
||||
? (ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
const destName = swapExtension(resolvedName, srcExt)
|
||||
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||
const dest = path.join(destDir, destName)
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||
const dest = path.join(destDir, destName)
|
||||
|
||||
if (isOrganized(hash, filename)) return { ...result, total: 1, skipped: 1 }
|
||||
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
|
||||
|
||||
const candidates = filePath ? [
|
||||
path.join(savePath, filePath),
|
||||
@@ -328,11 +337,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
]
|
||||
|
||||
let src: string | null = null
|
||||
|
||||
for (const c of candidates) {
|
||||
debug(`Candidate : ${c} → ${fs.existsSync(c)}`)
|
||||
if (fs.existsSync(c)) { src = c; break }
|
||||
}
|
||||
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
||||
|
||||
if (!src) {
|
||||
error(`Fichier source introuvable : ${filename}`)
|
||||
@@ -345,10 +350,22 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||
} else {
|
||||
await fsp.copyFile(src, dest)
|
||||
markOrganized(hash, ep.id, {
|
||||
at: new Date().toISOString(), season: season.season_number ?? 1,
|
||||
episode: ep.episode_number, episode_id: ep.id,
|
||||
src_filename: filename, dest_filename: destName, dest_path: dest,
|
||||
})
|
||||
await fsp.unlink(src)
|
||||
log(`${destName} → Saison ${season.season_number}`)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
}
|
||||
}
|
||||
markOrganized(hash, filename)
|
||||
|
||||
markOrganized(hash, ep.id, {
|
||||
at: new Date().toISOString(), season: season.season_number ?? 1,
|
||||
episode: ep.episode_number, episode_id: ep.id,
|
||||
src_filename: filename, dest_filename: destName, dest_path: dest,
|
||||
})
|
||||
log(`${destName} → Saison ${season.season_number}`)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
}
|
||||
@@ -364,9 +381,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
|
||||
result.total = fileMap.size
|
||||
|
||||
// FIX : destructure nfo_filename au lieu de original_filename
|
||||
for (const [filename, { season_number, nfo_filename, fullPath }] of fileMap) {
|
||||
if (isOrganized(hash, filename)) { result.skipped++; continue }
|
||||
for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) {
|
||||
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
|
||||
|
||||
const candidates = [
|
||||
path.join(savePath, fullPath),
|
||||
@@ -386,15 +402,32 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number))
|
||||
const dest = path.join(destDir, nfo_filename)
|
||||
|
||||
if (fs.existsSync(dest)) { markOrganized(hash, filename); result.skipped++; continue }
|
||||
if (fs.existsSync(dest)) {
|
||||
markOrganized(hash, episode_id, {
|
||||
at: new Date().toISOString(), season: season_number,
|
||||
episode: episode_number, episode_id,
|
||||
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
|
||||
})
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (organizeMode === 'hardlink') {
|
||||
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||
markOrganized(hash, episode_id, {
|
||||
at: new Date().toISOString(), season: season_number,
|
||||
episode: episode_number, episode_id,
|
||||
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
|
||||
})
|
||||
} else {
|
||||
await fsp.copyFile(src, dest)
|
||||
markOrganized(hash, filename)
|
||||
markOrganized(hash, episode_id, {
|
||||
at: new Date().toISOString(), season: season_number,
|
||||
episode: episode_number, episode_id,
|
||||
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
|
||||
})
|
||||
await fsp.unlink(src)
|
||||
}
|
||||
result.done++
|
||||
@@ -430,9 +463,10 @@ parentPort?.on('message', async (msg: any) => {
|
||||
const { torrent, serieData } = found
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
|
||||
const orgHash = organized[t.hash] ?? {}
|
||||
const allDone = fileMap.size > 0
|
||||
? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
|
||||
: !!organized[t.hash]?.[t.name]
|
||||
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
|
||||
: Object.keys(orgHash).length > 0
|
||||
|
||||
if (allDone) {
|
||||
debug(`"${t.name}" déjà entièrement importé — skip`)
|
||||
|
||||
+60
-32
@@ -30,6 +30,18 @@ export interface OrganizeResult {
|
||||
errors : { file: string; error: string }[]
|
||||
}
|
||||
|
||||
interface OrgEntry {
|
||||
at : string
|
||||
season : number
|
||||
episode : number
|
||||
episode_id : number
|
||||
src_filename : string
|
||||
dest_filename: string
|
||||
dest_path : string
|
||||
}
|
||||
|
||||
type Organized = Record<string, Record<string, OrgEntry>>
|
||||
|
||||
// ── Scan initial ──────────────────────────────────────────────
|
||||
|
||||
function swapExt(filename: string, ext: string): string {
|
||||
@@ -52,10 +64,15 @@ export async function scanMediaPath(
|
||||
|
||||
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
|
||||
|
||||
// FIX : on indexe le nom brut ET les noms de destination possibles
|
||||
// (nfo_filename, formatted_name) pour matcher les fichiers déjà renommés
|
||||
// Map : nom_sur_disque → { hash, srcFilename }
|
||||
const filenameIndex = new Map<string, { hash: string; srcFilename: string }>()
|
||||
// Map : nom_sur_disque → { hash, srcFilename, episodeId, season, episode }
|
||||
const filenameIndex = new Map<string, {
|
||||
hash : string
|
||||
srcFilename : string
|
||||
episodeId : number
|
||||
season : number
|
||||
episode : number
|
||||
destFilename: string
|
||||
}>()
|
||||
|
||||
for (const sd of seriesData) {
|
||||
for (const season of sd.seasons ?? []) {
|
||||
@@ -69,39 +86,46 @@ export async function scanMediaPath(
|
||||
if (!srcFilename) continue
|
||||
|
||||
const srcExt = path.extname(srcFilename)
|
||||
const entry = {
|
||||
hash,
|
||||
srcFilename,
|
||||
episodeId : ep.id,
|
||||
season : season.season_number,
|
||||
episode : ep.episode_number,
|
||||
destFilename: srcFilename,
|
||||
}
|
||||
|
||||
// 1. Nom brut du fichier source
|
||||
filenameIndex.set(srcFilename, { hash, srcFilename })
|
||||
// 1. Nom brut source
|
||||
filenameIndex.set(srcFilename, entry)
|
||||
|
||||
// 2. nfo_filename avec extension swappée
|
||||
if (ep.nfo_filename) {
|
||||
const nfoRenamed = swapExt(ep.nfo_filename, srcExt)
|
||||
filenameIndex.set(nfoRenamed, { hash, srcFilename })
|
||||
filenameIndex.set(nfoRenamed, { ...entry, destFilename: nfoRenamed })
|
||||
}
|
||||
|
||||
// 3. original_filename
|
||||
if (ep.original_filename) {
|
||||
filenameIndex.set(ep.original_filename, { hash, srcFilename })
|
||||
filenameIndex.set(ep.original_filename, { ...entry, destFilename: ep.original_filename })
|
||||
}
|
||||
|
||||
// 4. formatted_name + .mkv
|
||||
if (ep.formatted_name?.trim()) {
|
||||
const fmtName = ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + '.mkv'
|
||||
filenameIndex.set(fmtName, { hash, srcFilename })
|
||||
filenameIndex.set(fmtName, { ...entry, destFilename: fmtName })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
// FIX : on track les paires hash:srcFilename (clé dans organized.json)
|
||||
// et non hash:nom_sur_disque
|
||||
// Track les paires hash:episodeId présentes
|
||||
const presentFiles = new Set<string>()
|
||||
|
||||
function walk(dir: string) {
|
||||
@@ -117,32 +141,37 @@ export async function scanMediaPath(
|
||||
const match = filenameIndex.get(entry.name)
|
||||
if (!match) continue
|
||||
|
||||
const { hash, srcFilename } = match
|
||||
// La clé dans organized.json est toujours le srcFilename (nom brut)
|
||||
presentFiles.add(`${hash}:${srcFilename}`)
|
||||
if (organized[hash]?.[srcFilename]) continue
|
||||
const { hash, srcFilename, episodeId, season, episode, destFilename } = match
|
||||
presentFiles.add(`${hash}:${episodeId}`)
|
||||
|
||||
if (organized[hash]?.[String(episodeId)]) continue
|
||||
|
||||
if (!organized[hash]) organized[hash] = {}
|
||||
organized[hash][srcFilename] = new Date().toISOString()
|
||||
organized[hash][String(episodeId)] = {
|
||||
at : new Date().toISOString(),
|
||||
season,
|
||||
episode,
|
||||
episode_id : episodeId,
|
||||
src_filename : srcFilename,
|
||||
dest_filename: destFilename,
|
||||
dest_path : full,
|
||||
}
|
||||
result.added++
|
||||
logger.debug('organize', `Scan match : "${entry.name}" → hash ${hash.slice(0, 8)}… (src: ${srcFilename})`)
|
||||
logger.debug('organize', `Scan match : "${entry.name}" → ep ${episodeId} (S${season}E${episode})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(mediaPath)
|
||||
|
||||
// FIX : supprimer uniquement les entrées dont le fichier source
|
||||
// n'est plus présent ET qui sont connues dans l'index
|
||||
// (évite de supprimer des entrées pour des fichiers hors catalogue)
|
||||
const allSrcFilenames = new Set([...filenameIndex.values()].map(v => v.srcFilename))
|
||||
// Supprimer uniquement les entrées connues dans l'index et absentes du disque
|
||||
const allEpisodeIds = new Set([...filenameIndex.values()].map(v => `${v.hash}:${v.episodeId}`))
|
||||
|
||||
let removed = 0
|
||||
for (const [hash, files] of Object.entries(organized)) {
|
||||
for (const srcFilename of Object.keys(files)) {
|
||||
// Ne supprimer que si le fichier est dans notre catalogue
|
||||
// ET qu'il n'est plus présent dans la médiathèque
|
||||
if (allSrcFilenames.has(srcFilename) && !presentFiles.has(`${hash}:${srcFilename}`)) {
|
||||
delete organized[hash][srcFilename]
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const episodeId of Object.keys(episodes)) {
|
||||
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
|
||||
delete organized[hash][episodeId]
|
||||
removed++
|
||||
}
|
||||
}
|
||||
@@ -197,11 +226,10 @@ export async function autoOrganizeAll(
|
||||
|
||||
for (const t of torrents) _prevStates.set(t.hash, t.state)
|
||||
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
if (fs.existsSync(ORGANIZED_PATH))
|
||||
organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
const hasUnorganized = torrents.some(t =>
|
||||
|
||||
@@ -139,6 +139,56 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
|
||||
return { ...client, config }
|
||||
}
|
||||
|
||||
// ─── Backoff par client ───────────────────────────────────────────────────────
|
||||
// Si un client échoue à l'auth, on le met en cooldown pour éviter le ban IP
|
||||
|
||||
interface BackoffEntry {
|
||||
failCount : number
|
||||
nextRetry : number // timestamp ms
|
||||
}
|
||||
|
||||
const _backoff = new Map<string, BackoffEntry>()
|
||||
|
||||
// Délais progressifs : 1min, 5min, 15min, 30min, 60min (plafonné)
|
||||
const BACKOFF_DELAYS = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
|
||||
|
||||
function isInCooldown(uuid: string): boolean {
|
||||
const entry = _backoff.get(uuid)
|
||||
if (!entry) return false
|
||||
return Date.now() < entry.nextRetry
|
||||
}
|
||||
|
||||
function cooldownRemaining(uuid: string): string {
|
||||
const entry = _backoff.get(uuid)
|
||||
if (!entry) return ''
|
||||
const remaining = Math.max(0, entry.nextRetry - Date.now())
|
||||
const m = Math.ceil(remaining / 60_000)
|
||||
return m > 0 ? `${m}min` : ''
|
||||
}
|
||||
|
||||
function registerFailure(uuid: string, clientName: string) {
|
||||
const entry = _backoff.get(uuid) ?? { failCount: 0, nextRetry: 0 }
|
||||
entry.failCount = Math.min(entry.failCount + 1, BACKOFF_DELAYS.length - 1) + (entry.failCount === 0 ? 0 : 0)
|
||||
const delay = BACKOFF_DELAYS[Math.min(entry.failCount - 1, BACKOFF_DELAYS.length - 1)]
|
||||
entry.nextRetry = Date.now() + delay
|
||||
_backoff.set(uuid, entry)
|
||||
const min = Math.round(delay / 60_000)
|
||||
logger.warn('torrent-clients', `Client "${clientName}" en cooldown ${min}min après ${entry.failCount} échec(s)`)
|
||||
}
|
||||
|
||||
function registerSuccess(uuid: string) {
|
||||
_backoff.delete(uuid)
|
||||
}
|
||||
|
||||
// Exposé pour l'API de status
|
||||
export function getClientCooldowns(): Record<string, { failCount: number; nextRetry: number }> {
|
||||
const result: Record<string, { failCount: number; nextRetry: number }> = {}
|
||||
for (const [uuid, entry] of _backoff.entries()) {
|
||||
if (Date.now() < entry.nextRetry) result[uuid] = entry
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Remote path remapping ────────────────────────────────────────────────────
|
||||
|
||||
// FIX : remplace le préfixe remotePath par localPath dans save_path
|
||||
@@ -194,25 +244,41 @@ export async function dispatchList(category?: string): Promise<(TorrentInfo & {
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) continue
|
||||
|
||||
// Skip si le client est en cooldown suite à des échecs d'auth
|
||||
if (isInCooldown(client.uuid)) {
|
||||
const remaining = cooldownRemaining(client.uuid)
|
||||
logger.debug('torrent-clients', `Client "${client.name}" en cooldown — retry dans ${remaining}`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const clientCategory = (client.config.category as string) || category
|
||||
const torrents = await driver.list(client.config, clientCategory)
|
||||
|
||||
registerSuccess(client.uuid)
|
||||
|
||||
const remotePath = String(client.config.remotePath ?? '')
|
||||
const localPath = String(client.config.localPath ?? '')
|
||||
const hasRemap = remotePath && localPath
|
||||
|
||||
for (const t of torrents) {
|
||||
// FIX : remap save_path si remotePath et localPath sont configurés
|
||||
const save_path = hasRemap
|
||||
? remapSavePath(t.save_path, remotePath, localPath)
|
||||
: t.save_path
|
||||
|
||||
results.push({ ...t, save_path, client_uuid: client.uuid, client_name: client.name })
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('torrent-clients', `Impossible de récupérer la liste depuis "${client.name}" : ${msg}`)
|
||||
// Déclencher le backoff si c'est une erreur d'auth
|
||||
const isAuthError = msg.toLowerCase().includes('login') ||
|
||||
msg.toLowerCase().includes('auth') ||
|
||||
msg.toLowerCase().includes('ban') ||
|
||||
msg.toLowerCase().includes('forbidden') ||
|
||||
msg.toLowerCase().includes('401') ||
|
||||
msg.toLowerCase().includes('403')
|
||||
if (isAuthError) registerFailure(client.uuid, client.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* rTorrent Driver
|
||||
* ===============
|
||||
* Communique via XML-RPC (HTTP POST).
|
||||
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── XML-RPC helpers ──────────────────────────────────────────
|
||||
|
||||
function xmlValue(val: any): string {
|
||||
if (typeof val === 'string') return `<value><string>${val}</string></value>`
|
||||
if (typeof val === 'number') return `<value><i8>${val}</i8></value>`
|
||||
if (typeof val === 'boolean') return `<value><boolean>${val ? 1 : 0}</boolean></value>`
|
||||
if (Array.isArray(val)) return `<value><array><data>${val.map(xmlValue).join('')}</data></array></value>`
|
||||
return `<value><string>${String(val)}</string></value>`
|
||||
}
|
||||
|
||||
function xmlCall(method: string, params: any[] = []): string {
|
||||
return `<?xml version="1.0"?>
|
||||
<methodCall>
|
||||
<methodName>${method}</methodName>
|
||||
<params>${params.map(p => `<param>${xmlValue(p)}</param>`).join('')}</params>
|
||||
</methodCall>`
|
||||
}
|
||||
|
||||
function parseXmlValue(node: Element): any {
|
||||
const child = node.firstElementChild
|
||||
if (!child) return node.textContent ?? ''
|
||||
switch (child.tagName) {
|
||||
case 'string' : return child.textContent ?? ''
|
||||
case 'int' :
|
||||
case 'i4' :
|
||||
case 'i8' : return parseInt(child.textContent ?? '0', 10)
|
||||
case 'double' : return parseFloat(child.textContent ?? '0')
|
||||
case 'boolean' : return child.textContent === '1'
|
||||
case 'array' : {
|
||||
const data = child.querySelector('data')
|
||||
if (!data) return []
|
||||
return Array.from(data.children).map(parseXmlValue)
|
||||
}
|
||||
case 'struct' : {
|
||||
const result: Record<string, any> = {}
|
||||
for (const member of Array.from(child.querySelectorAll(':scope > member'))) {
|
||||
const name = member.querySelector(':scope > name')?.textContent ?? ''
|
||||
const value = member.querySelector(':scope > value')
|
||||
if (name && value) result[name] = parseXmlValue(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
default: return child.textContent ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
async function rpcCall(
|
||||
config : Record<string, string | number>,
|
||||
method : string,
|
||||
params : any[] = [],
|
||||
): Promise<any> {
|
||||
const body = xmlCall(method, params)
|
||||
const headers : Record<string, string> = { 'Content-Type': 'text/xml' }
|
||||
|
||||
if (config.username && config.password) {
|
||||
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
|
||||
}
|
||||
|
||||
const rpcPath = String(config.rpcPath || '/RPC2')
|
||||
const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}`
|
||||
|
||||
const res = await fetch(url, { method: 'POST', headers, body })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
const text = await res.text()
|
||||
|
||||
// Parse XML — Node.js n'a pas DOMParser natif, on fait un parse minimaliste
|
||||
const faultMatch = text.match(/<name>faultString<\/name>\s*<value><string>([^<]*)<\/string>/)
|
||||
if (faultMatch) throw new Error(`rTorrent fault : ${faultMatch[1]}`)
|
||||
|
||||
const valueMatch = text.match(/<methodResponse>\s*<params>\s*<param>\s*<value>([\s\S]*?)<\/value>\s*<\/param>/)
|
||||
if (!valueMatch) return null
|
||||
|
||||
// Parse simplifié pour les types courants
|
||||
const inner = valueMatch[1].trim()
|
||||
|
||||
// Array
|
||||
if (inner.startsWith('<array>')) {
|
||||
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
|
||||
return items.map(m => parseInnerValue(m[1].trim()))
|
||||
}
|
||||
|
||||
return parseInnerValue(inner)
|
||||
}
|
||||
|
||||
function parseInnerValue(inner: string): any {
|
||||
if (inner.startsWith('<array>')) {
|
||||
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
|
||||
return items.map(m => parseInnerValue(m[1].trim()))
|
||||
}
|
||||
const strMatch = inner.match(/^<string>([\s\S]*?)<\/string>$/)
|
||||
if (strMatch) return strMatch[1]
|
||||
const intMatch = inner.match(/^<(?:i4|i8|int)>([\s\S]*?)<\/(?:i4|i8|int)>$/)
|
||||
if (intMatch) return parseInt(intMatch[1], 10)
|
||||
// Valeur brute sans tag
|
||||
if (!inner.includes('<')) return inner
|
||||
return inner
|
||||
}
|
||||
|
||||
// multicall pour récupérer plusieurs champs en une requête
|
||||
async function d_multicall(
|
||||
config : Record<string, string | number>,
|
||||
view : string,
|
||||
methods: string[],
|
||||
): Promise<any[][]> {
|
||||
const params = [view, ...methods.map(m => `${m}=`)]
|
||||
const result = await rpcCall(config, 'd.multicall2', ['', ...params])
|
||||
return Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
// ─── State mapping ────────────────────────────────────────────
|
||||
// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1)
|
||||
// + is_open (0/1)
|
||||
function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] {
|
||||
if (isChecking) return 'checking'
|
||||
if (!isOpen) return 'paused'
|
||||
if (!isActive) return 'paused'
|
||||
if (isComplete) return 'seeding'
|
||||
return 'downloading'
|
||||
}
|
||||
|
||||
const RT: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'rtorrent',
|
||||
label : 'rTorrent',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||
{ key: 'rpcPath', label: 'Chemin RPC', type: 'text', placeholder: '/RPC2', required: false, default: '/RPC2' },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
|
||||
{ key: 'category', label: 'Catégorie (label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
const version = await rpcCall(config, 'system.client_version')
|
||||
logger.info('rtorrent', `Test de connexion réussi sur ${config.url} — version ${version}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.warn('rtorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const version = await rpcCall(config, 'system.client_version')
|
||||
logger.debug('rtorrent', `Healthcheck OK — version ${version}`)
|
||||
return { online: true, version: String(version) }
|
||||
} catch (err) {
|
||||
logger.debug('rtorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const methods = [
|
||||
'd.hash',
|
||||
'd.name',
|
||||
'd.is_open',
|
||||
'd.is_active',
|
||||
'd.is_hash_checking',
|
||||
'd.complete',
|
||||
'd.size_bytes',
|
||||
'd.bytes_done',
|
||||
'd.down.rate',
|
||||
'd.left_bytes',
|
||||
'd.down.rate',
|
||||
'd.directory',
|
||||
'd.custom1', // label rTorrent
|
||||
]
|
||||
|
||||
const rows = await d_multicall(config, 'main', methods)
|
||||
|
||||
return rows
|
||||
.map(r => {
|
||||
const [hash, name, isOpen, isActive, isChecking, isComplete,
|
||||
size, downloaded, speed, leftBytes, , directory, label] = r
|
||||
|
||||
const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0
|
||||
const eta = speed > 0 && leftBytes > 0 ? Math.round(leftBytes / speed) : -1
|
||||
|
||||
return {
|
||||
hash : String(hash).toLowerCase(),
|
||||
name : String(name),
|
||||
state : mapState(Number(isOpen), Number(isActive), Number(isChecking), Number(isComplete)),
|
||||
progress,
|
||||
size : Number(size),
|
||||
downloaded: Number(downloaded),
|
||||
speed : Number(speed),
|
||||
eta,
|
||||
save_path : String(directory),
|
||||
category : String(label ?? ''),
|
||||
} satisfies TorrentInfo
|
||||
})
|
||||
.filter(t => {
|
||||
if (!category) return true
|
||||
return t.category === category
|
||||
})
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
// load_start charge et démarre le torrent
|
||||
const method = url.startsWith('magnet:') ? 'load.start' : 'load.start'
|
||||
const args: any[] = ['', url]
|
||||
|
||||
if (config.savePath) {
|
||||
args.push(`d.directory.set="${config.savePath}"`)
|
||||
}
|
||||
|
||||
if (config.category) {
|
||||
args.push(`d.custom1.set=${config.category}`)
|
||||
}
|
||||
|
||||
await rpcCall(config, method, args)
|
||||
logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default RT
|
||||
@@ -109,7 +109,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
@@ -205,6 +205,27 @@ function onTypeChange() {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset tested dès que l'utilisateur modifie un champ (nom ou config)
|
||||
// sauf si c'est un mot de passe masqué non touché
|
||||
watch(() => modal.value.name, () => {
|
||||
if (modal.value.uuid) modal.value.tested = false
|
||||
})
|
||||
|
||||
watch(() => modal.value.config, (newConfig) => {
|
||||
if (!modal.value.uuid) return
|
||||
const driver = currentDefinition.value
|
||||
if (!driver) return
|
||||
// Si au moins un champ non-password a changé → reset tested
|
||||
// Pour les passwords, on reset seulement si la valeur n'est plus le masque
|
||||
for (const [key, val] of Object.entries(newConfig)) {
|
||||
const field = driver.fields.find(f => f.key === key)
|
||||
if (!field) continue
|
||||
if (field.type === 'password' && val === '••••••••') continue
|
||||
modal.value.tested = false
|
||||
return
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
async function testNewClient() {
|
||||
modal.value.testing = true
|
||||
modal.value.tested = false
|
||||
|
||||
Reference in new issue
Block a user