234 lines
10 KiB
TypeScript
234 lines
10 KiB
TypeScript
/** Real-Debrid : torrents téléchargés côté RD, fichiers lus depuis le dossier local monté (rclone, WebDAV…). */
|
|
|
|
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
|
|
import { logger } from '../logger.js'
|
|
|
|
const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
|
|
|
|
function rdHeaders(config: ClientConfig) {
|
|
return {
|
|
Authorization: `Bearer ${config.apiKey}`,
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
}
|
|
}
|
|
|
|
async function rdFetch(
|
|
config : ClientConfig,
|
|
method : 'GET' | 'POST' | 'DELETE',
|
|
path : string,
|
|
body ?: URLSearchParams,
|
|
): Promise<any> {
|
|
const res = await fetch(`${RD_BASE}${path}`, {
|
|
method,
|
|
headers: rdHeaders(config),
|
|
body : body,
|
|
})
|
|
if (res.status === 204) return null
|
|
if (res.status === 401 || res.status === 403) throw new Error('Clé API Real-Debrid invalide ou compte non premium')
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null)
|
|
throw new Error(`Real-Debrid a répondu HTTP ${res.status}${data?.error ? ` : ${data.error}` : ''}`)
|
|
}
|
|
return res.json()
|
|
}
|
|
|
|
function mapStatus(status: string): TorrentInfo['state'] {
|
|
switch (status) {
|
|
case 'downloaded': return 'seeding'
|
|
case 'downloading':
|
|
case 'queued':
|
|
case 'compressing':
|
|
case 'uploading':
|
|
case 'magnet_conversion': return 'downloading'
|
|
case 'waiting_files_selection': return 'paused'
|
|
case 'magnet_error':
|
|
case 'error':
|
|
case 'virus':
|
|
case 'dead': return 'error'
|
|
default: return 'unknown'
|
|
}
|
|
}
|
|
|
|
const rdDriver: TorrentClientDriver = {
|
|
definition: {
|
|
id : 'real-debrid',
|
|
label : 'Real-Debrid',
|
|
fields: [
|
|
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
|
|
{ key: 'localPath', label: 'Dossier où Real-Debrid est monté (rclone, WebDAV…)', type: 'text', placeholder: '/mnt/real-debrid', required: false },
|
|
],
|
|
},
|
|
|
|
async test(config) {
|
|
try {
|
|
const user = await rdFetch(config, 'GET', '/user')
|
|
logger.info('real-debrid', `Connexion réussie (utilisateur ${user.username})`)
|
|
return { ok: true, message: `Connecté en tant que ${user.username}` }
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
|
logger.warn('real-debrid', `Échec du test de connexion : ${msg}`)
|
|
return { ok: false, message: msg }
|
|
}
|
|
},
|
|
|
|
async healthcheck(config) {
|
|
try {
|
|
const user = await rdFetch(config, 'GET', '/user')
|
|
return { online: true, version: `Compte ${user.username}` }
|
|
} catch {
|
|
return { online: false }
|
|
}
|
|
},
|
|
|
|
async add(config, url, options?: DownloadOptions) {
|
|
const isMagnet = url.startsWith('magnet:')
|
|
const hash = (options?.infohash?.toLowerCase() ?? null)
|
|
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|
|
|| null
|
|
|
|
if (hash) {
|
|
try {
|
|
const list: any[] = await rdFetch(config, 'GET', '/torrents')
|
|
const existing = list.find((t: any) => t.hash?.toLowerCase() === hash)
|
|
if (existing) {
|
|
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur Real-Debrid`)
|
|
if (options?.file_index != null && existing.status === 'waiting_files_selection') {
|
|
const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`)
|
|
.then((info: any) => info.files ?? [])
|
|
const fileId = files[options.file_index]?.id
|
|
if (fileId) {
|
|
await rdFetch(config, 'POST', `/torrents/selectFiles/${existing.id}`,
|
|
new URLSearchParams({ files: String(fileId) }))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
let torrentId: string
|
|
|
|
if (isMagnet) {
|
|
const data = await rdFetch(config, 'POST', '/torrents/addMagnet',
|
|
new URLSearchParams({ magnet: url }))
|
|
torrentId = data.id
|
|
logger.info('real-debrid', `Magnet ajouté (ID ${torrentId})`)
|
|
} else {
|
|
const torrentRes = await fetch(url)
|
|
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status})`)
|
|
const torrentBuf = await torrentRes.arrayBuffer()
|
|
const formData = new FormData()
|
|
formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
|
|
const uploadRes = await fetch(`${RD_BASE}/torrents/addTorrent`, {
|
|
method : 'PUT',
|
|
headers: { Authorization: `Bearer ${config.apiKey}` },
|
|
body : formData,
|
|
})
|
|
if (!uploadRes.ok) throw new Error(`Échec de l'envoi du fichier .torrent (HTTP ${uploadRes.status})`)
|
|
const data = await uploadRes.json()
|
|
torrentId = data.id
|
|
logger.info('real-debrid', `Fichier .torrent envoyé (ID ${torrentId})`)
|
|
}
|
|
|
|
if (options?.file_index != null) {
|
|
let files: any[] = []
|
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
await new Promise(r => setTimeout(r, 500))
|
|
try {
|
|
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
|
|
if (info.status === 'waiting_files_selection' && Array.isArray(info.files) && info.files.length > 0) {
|
|
files = info.files
|
|
break
|
|
}
|
|
if (info.status === 'downloading' || info.status === 'downloaded') break
|
|
} catch {}
|
|
}
|
|
if (files.length > 0 && options.file_index < files.length) {
|
|
const fileId = files[options.file_index]?.id
|
|
if (fileId) {
|
|
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
|
|
new URLSearchParams({ files: String(fileId) }))
|
|
logger.info('real-debrid', `Fichier n° ${options.file_index} sélectionné pour le torrent Real-Debrid ${torrentId}`)
|
|
}
|
|
} else if (files.length > 0) {
|
|
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
|
|
new URLSearchParams({ files: 'all' }))
|
|
}
|
|
} else {
|
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
await new Promise(r => setTimeout(r, 500))
|
|
try {
|
|
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
|
|
if (info.status === 'waiting_files_selection') {
|
|
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
|
|
new URLSearchParams({ files: 'all' }))
|
|
break
|
|
}
|
|
if (info.status !== 'magnet_conversion') break
|
|
} catch {}
|
|
}
|
|
}
|
|
},
|
|
|
|
async list(config, _category?) {
|
|
const torrents: any[] = await rdFetch(config, 'GET', '/torrents')
|
|
const localBase = String(config.localPath ?? '').replace(/\/+$/, '')
|
|
|
|
return torrents.map((t: any): TorrentInfo => {
|
|
const progress = typeof t.progress === 'number' ? t.progress / 100 : 0
|
|
// save_path virtuel : dossier monté et nom du torrent
|
|
const save_path = localBase ? `${localBase}/${t.filename ?? t.id}` : ''
|
|
|
|
const files: TorrentFileProgress[] | undefined =
|
|
Array.isArray(t.files) && t.files.length > 0
|
|
? t.files.map((f: any, i: number) => ({
|
|
index : i,
|
|
name : String(f.path ?? f.name ?? ''),
|
|
progress: f.selected ? progress : 0,
|
|
}))
|
|
: undefined
|
|
|
|
return {
|
|
hash : String(t.hash ?? t.id ?? '').toLowerCase(),
|
|
name : String(t.filename ?? t.id ?? ''),
|
|
state : mapStatus(t.status ?? ''),
|
|
progress : Math.round(progress * 100),
|
|
size : typeof t.bytes === 'number' ? t.bytes : 0,
|
|
downloaded: typeof t.bytes === 'number' ? Math.round(t.bytes * progress) : 0,
|
|
uploaded : 0,
|
|
ratio : 0,
|
|
speed : typeof t.speed === 'number' ? t.speed : 0,
|
|
upspeed : 0,
|
|
eta : -1,
|
|
save_path,
|
|
category : '',
|
|
files,
|
|
}
|
|
})
|
|
},
|
|
|
|
async remove(config, hash, _deleteFiles?) {
|
|
const list: any[] = await rdFetch(config, 'GET', '/torrents')
|
|
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
|
|
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur Real-Debrid`)
|
|
await rdFetch(config, 'DELETE', `/torrents/delete/${target.id}`)
|
|
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… supprimé de Real-Debrid`)
|
|
},
|
|
|
|
async getFiles(config, hash) {
|
|
const list: any[] = await rdFetch(config, 'GET', '/torrents')
|
|
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
|
|
if (!target) return []
|
|
const info = await rdFetch(config, 'GET', `/torrents/info/${target.id}`)
|
|
const overallProgress = typeof info.progress === 'number' ? info.progress / 100 : 0
|
|
if (!Array.isArray(info.files)) return []
|
|
return info.files.map((f: any, i: number) => ({
|
|
index : i,
|
|
name : String(f.path ?? f.name ?? ''),
|
|
progress: f.selected ? overallProgress : 0,
|
|
}))
|
|
},
|
|
}
|
|
|
|
export default rdDriver
|