feat: add uttorent driver
fix: organiser take correct name
This commit is contained in:
1 parent
89c0db5f36
commit
0e09c85efd
4 files changed
+257
-12
No files matched your search
@@ -13,6 +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 { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js'
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||
@@ -21,6 +23,7 @@ import { systemInfo } from './system.js'
|
||||
registerDriver(qbittorrentDriver)
|
||||
registerDriver(transmissionDriver)
|
||||
registerDriver(synologyDsDriver)
|
||||
registerDriver(utorrentDriver)
|
||||
|
||||
const app = express()
|
||||
const PORT = Number(process.env.PORT) || 9898
|
||||
|
||||
@@ -394,9 +394,9 @@ 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, filename)
|
||||
await fsp.unlink(src)
|
||||
}
|
||||
markOrganized(hash, filename)
|
||||
result.done++
|
||||
debug(`${nfo_filename} → Saison ${season_number}`)
|
||||
} catch (err) {
|
||||
|
||||
+57
-11
@@ -32,6 +32,12 @@ export interface OrganizeResult {
|
||||
|
||||
// ── Scan initial ──────────────────────────────────────────────
|
||||
|
||||
function swapExt(filename: string, ext: string): string {
|
||||
const cur = path.extname(filename)
|
||||
if (!cur || cur === ext) return filename
|
||||
return filename.slice(0, -cur.length) + ext
|
||||
}
|
||||
|
||||
export async function scanMediaPath(
|
||||
mediaPath : string,
|
||||
organizedPath: string,
|
||||
@@ -46,7 +52,11 @@ export async function scanMediaPath(
|
||||
|
||||
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
|
||||
|
||||
const filenameIndex = new Map<string, string>()
|
||||
// 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 }>()
|
||||
|
||||
for (const sd of seriesData) {
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
@@ -54,8 +64,31 @@ export async function scanMediaPath(
|
||||
if (typeof p === 'string') continue
|
||||
const hash = p.infohash?.toLowerCase()
|
||||
if (!hash || !p.path) continue
|
||||
const filename = p.path.replace(/\\/g, '/').split('/').pop()
|
||||
if (filename) filenameIndex.set(filename, hash)
|
||||
|
||||
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
|
||||
if (!srcFilename) continue
|
||||
|
||||
const srcExt = path.extname(srcFilename)
|
||||
|
||||
// 1. Nom brut du fichier source
|
||||
filenameIndex.set(srcFilename, { hash, srcFilename })
|
||||
|
||||
// 2. nfo_filename avec extension swappée
|
||||
if (ep.nfo_filename) {
|
||||
const nfoRenamed = swapExt(ep.nfo_filename, srcExt)
|
||||
filenameIndex.set(nfoRenamed, { hash, srcFilename })
|
||||
}
|
||||
|
||||
// 3. original_filename
|
||||
if (ep.original_filename) {
|
||||
filenameIndex.set(ep.original_filename, { hash, srcFilename })
|
||||
}
|
||||
|
||||
// 4. formatted_name + .mkv
|
||||
if (ep.formatted_name?.trim()) {
|
||||
const fmtName = ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + '.mkv'
|
||||
filenameIndex.set(fmtName, { hash, srcFilename })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +100,8 @@ export async function scanMediaPath(
|
||||
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
|
||||
const presentFiles = new Set<string>()
|
||||
|
||||
function walk(dir: string) {
|
||||
@@ -79,24 +114,35 @@ export async function scanMediaPath(
|
||||
walk(full)
|
||||
} else if (entry.isFile() && entry.name.endsWith('.mkv')) {
|
||||
result.found++
|
||||
const hash = filenameIndex.get(entry.name)
|
||||
if (!hash) continue
|
||||
presentFiles.add(`${hash}:${entry.name}`)
|
||||
if (organized[hash]?.[entry.name]) continue
|
||||
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
|
||||
if (!organized[hash]) organized[hash] = {}
|
||||
organized[hash][entry.name] = new Date().toISOString()
|
||||
organized[hash][srcFilename] = new Date().toISOString()
|
||||
result.added++
|
||||
logger.debug('organize', `Scan match : "${entry.name}" → hash ${hash.slice(0, 8)}… (src: ${srcFilename})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
let removed = 0
|
||||
for (const [hash, files] of Object.entries(organized)) {
|
||||
for (const filename of Object.keys(files)) {
|
||||
if (!presentFiles.has(`${hash}:${filename}`) && filenameIndex.get(filename) === hash) {
|
||||
delete organized[hash][filename]
|
||||
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]
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* uTorrent Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// uTorrent status flags (bitmask) :
|
||||
// 1 = Started, 2 = Checking, 4 = Start after check, 8 = Checked,
|
||||
// 16 = Error, 32 = Paused, 64 = Queued, 128 = Loaded
|
||||
function mapState(status: number): TorrentInfo['state'] {
|
||||
if (status & 16) return 'error'
|
||||
if (status & 32) return 'paused'
|
||||
if (status & 2) return 'checking'
|
||||
// Démarré + chargé + vérifié → on regarde le ratio de progression
|
||||
if (status & 1 && status & 8) return 'downloading'
|
||||
if (status & 64) return 'downloading' // queued
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
// uTorrent nécessite un token CSRF + cookie à chaque session
|
||||
interface UTSession { token: string; cookie: string }
|
||||
|
||||
async function utGetSession(config: Record<string, string | number>): Promise<UTSession> {
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
const headers : Record<string, string> = {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.url}/gui/token.html`, { headers })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`)
|
||||
|
||||
const text = await res.text()
|
||||
const match = text.match(/<div[^>]+id=['"]token['"][^>]*>([^<]+)</)
|
||||
if (!match) throw new Error('Token CSRF introuvable dans la réponse')
|
||||
const token = match[1].trim()
|
||||
const cookie = res.headers.get('set-cookie') ?? ''
|
||||
|
||||
return { token, cookie }
|
||||
}
|
||||
|
||||
async function utRequest(
|
||||
config : Record<string, string | number>,
|
||||
params : Record<string, string>,
|
||||
session: UTSession,
|
||||
): Promise<any> {
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
const qs = new URLSearchParams({ token: session.token, ...params })
|
||||
|
||||
const res = await fetch(`${config.url}/gui/?${qs}`, {
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Cookie' : session.cookie,
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error(`uTorrent HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function mapTorrent(t: any[]): TorrentInfo {
|
||||
// uTorrent retourne un tableau par torrent :
|
||||
// [hash, status, name, size, progress, downloaded, uploaded, ratio,
|
||||
// ul_speed, dl_speed, eta, label, peers_connected, peers_swarm,
|
||||
// seeds_connected, seeds_swarm, availability, queue_order, remaining]
|
||||
const [hash, status, name, size, progress, downloaded, , , , dlSpeed, eta, label] = t
|
||||
return {
|
||||
hash : hash.toLowerCase(),
|
||||
name,
|
||||
state : progress >= 1000 ? 'seeding' : mapState(status),
|
||||
progress : Math.min(100, Math.round(progress / 10)),
|
||||
size,
|
||||
downloaded,
|
||||
speed : dlSpeed ?? 0,
|
||||
eta : eta ?? -1,
|
||||
save_path : '', // récupéré séparément via torrent properties si besoin
|
||||
category : label ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
const UT: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'utorrent',
|
||||
label : 'uTorrent',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie', 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 {
|
||||
await utGetSession(config)
|
||||
logger.info('utorrent', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.warn('utorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const session = await utGetSession(config)
|
||||
const data = await utRequest(config, { action: 'getsettings' }, session)
|
||||
const version = data?.['build']?.toString() ?? 'inconnue'
|
||||
logger.debug('utorrent', `Healthcheck OK — build ${version}`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
logger.debug('utorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const session = await utGetSession(config)
|
||||
const data = await utRequest(config, { list: '1' }, session)
|
||||
const torrents: any[][] = data?.torrents ?? []
|
||||
|
||||
return torrents
|
||||
.filter(t => {
|
||||
if (!category) return true
|
||||
return t[11] === category // index 11 = label
|
||||
})
|
||||
.map(t => {
|
||||
const info = mapTorrent(t)
|
||||
|
||||
// Récupérer le save_path depuis les properties si disponible
|
||||
const props = data?.props?.find((p: any) => p[0]?.toLowerCase() === info.hash)
|
||||
if (props) info.save_path = props[1] ?? ''
|
||||
|
||||
return info
|
||||
})
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
const session = await utGetSession(config)
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
|
||||
const form = new FormData()
|
||||
form.append('torrent_url', url)
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
token : session.token,
|
||||
action: 'add-url',
|
||||
s : url,
|
||||
})
|
||||
|
||||
if (config.savePath) {
|
||||
qs.set('download_dir', '1')
|
||||
qs.set('path', String(config.savePath))
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.url}/gui/?${qs}`, {
|
||||
method : 'GET',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Cookie' : session.cookie,
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`)
|
||||
|
||||
// Appliquer le label/catégorie si défini
|
||||
if (config.category) {
|
||||
// On ne peut pas set le label au moment de l'ajout, il faut attendre
|
||||
// que le torrent soit chargé — on le fait via setprops après un délai
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const s2 = await utGetSession(config)
|
||||
const data = await utRequest(config, { list: '1' }, s2)
|
||||
const torrents: any[][] = data?.torrents ?? []
|
||||
// Trouver le torrent le plus récemment ajouté sans label
|
||||
const target = torrents.find(t => !t[11])
|
||||
if (target) {
|
||||
await utRequest(config, {
|
||||
action: 'setprops',
|
||||
hash : target[0],
|
||||
s : 'label',
|
||||
v : String(config.category),
|
||||
}, s2)
|
||||
}
|
||||
} catch {}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
|
||||
},
|
||||
}
|
||||
|
||||
export default UT
|
||||
Reference in new issue
Block a user