32浏览量
1参与者
p
活动 98天
parkertaylor
✨这是 parkertaylor 首次发帖 - 让我们欢迎他/她加入社区吧!
parkertaylor
parkertaylor
楼主
~~~
async () => {
const moment = require('moment')
const util = require('../libs/util')
const clients = global.runningClient || {}
const BASE_CONFIG = {
category: 'sky', // 分类
debug: true // 调试日志开关,稳定后可改为 false
}
const SCOPE_CONFIG = {
includeClientAliases: [], // 空数组表示处理所有下载器;非空时只处理这些下载器别名
excludeClientAliases: [] // 排除这些下载器别名,优先级高于 includeClientAliases
}
const RATIO_CONFIG = {
effectiveRatioDelay: 60 * 60, // 站点有效 Ratio 起算边界
maxRatio: 3.1 // 目标分享率 (扣除初始量)
}
const SPACE_CLEANUP_CONFIG = {
minFreeSpace: 25 * 1024 * 1024 * 1024, // 触发清理的空间阈值
targetFreeSpace: 30 * 1024 * 1024 * 1024, // 清理目标空间
panicSpace: 5 * 1024 * 1024 * 1024, // 恐慌空间 (无视大部分保护)
cleanupProtectionDelay: 60 * 60, // 清理保护基础时长
protectUploadSpeed: 10 * 1024 * 1024, // 优质未完成种保护阈值
uploadSpeedPriorityDiff: 10 * 1024, // 近期平均上传速度差超过10KB/s时,按速度排序
postLimitGrace: 10 * 60, // 清理保护结束后的观察时间
slowWindow: 10 * 60, // 持续低上传统计窗口
slowUploadSpeed: 1 * 1024 * 1024, // 持续低上传阈值
stuckProgress: 0.995, // 近完成低效种进度阈值
stuckDownloadSpeed: 128 * 1024, // 近完成低效种下载速度阈值
addedTimePriorityDiff: 30 * 60, // 添加时间差超过30分钟时,优先删更早添加的
ratioPriorityDiff: 0.1 // 有效Ratio差超过0.1时,优先删更低的
}
const SPEED_LIMIT_CONFIG = {
initialPauseDelay: 50 * 60, // 添加后前 50 分钟暂停,节省下载空间
uploadResumeDelay: 58 * 60, // 58 分钟恢复标准上传,提前连接 peer
catchupUploadLimit: 1024, // 50-58 分钟追赶期上传限速 (KB/s)
catchupDownloadLimit: 0, // 50-58 分钟追赶期下载限速 (KB/s),0 为不限速
standardUploadLimit: 460 * 1024, // 标准上传限速 (KB/s),0 为无限制
standardDownloadLimit: 0 // 标准下载限速 (KB/s),0 为无限制
}
const IDLE_CLEANUP_CONFIG = {
enabled: true, // 是否开启空闲种主动清理,默认开启
idleWindow: 30 * 60, // 最近多少秒内平均速度低于阈值才算空闲
minAge: 90 * 60, // 添加满 90 分钟才允许空闲清理
requireStandardLimit: true, // 必须已经进入标准限速阶段,避免误删新种
idleUploadSpeedThreshold: 50*1024, // 空闲窗口平均上传速度阈值 (bytes/s)
idleDownloadSpeedThreshold: 0, // 空闲窗口平均下载速度阈值 (bytes/s)
requireFlowRecord: true, // 最近窗口必须有流量记录;没有记录不当作空闲
minEffectiveRatio: 0, // 有效 Ratio 低于该值时不做空闲清理
maxDeletePerRun: 0 // 每轮最多删多少空闲种;0 表示不限制
}
// 保留统一 CONFIG,方便下面逻辑复用,同时让用户按功能块看配置。
const CONFIG = {
...BASE_CONFIG,
scope: SCOPE_CONFIG,
...RATIO_CONFIG,
...SPACE_CLEANUP_CONFIG,
...SPEED_LIMIT_CONFIG,
idleCleanup: IDLE_CLEANUP_CONFIG
}
const SCRIPT_NAME = '空控速删种'
const formatError = (error) => {
if (!error) return '未知错误'
return error.stack || error.message || String(error)
}
const debugLog = (message) => {
if (CONFIG.debug) {
logger.info(`[${SCRIPT_NAME}][DEBUG] ${message}`)
}
}
const getTorrentList = (torrents) => {
if (Array.isArray(torrents)) return torrents
if (torrents && typeof torrents === 'object') return Object.values(torrents)
return []
}
const normalizeAlias = (value) => {
return String(value || '').trim()
}
const normalizeAliasList = (list) => {
return Array.isArray(list) ? list.map(normalizeAlias).filter(Boolean) : []
}
const getClientAlias = (client, clientId) => {
return normalizeAlias(client?.alias || client?._client?.alias || clientId)
}
const isClientInScope = (client, clientId) => {
const alias = getClientAlias(client, clientId)
const included = normalizeAliasList(CONFIG.scope.includeClientAliases)
const excluded = normalizeAliasList(CONFIG.scope.excludeClientAliases)
if (excluded.includes(alias)) return false
if (included.length > 0 && !included.includes(alias)) return false
return true
}
const getAddedTime = (torrent) => {
return Number(torrent.addedTime ?? torrent.added_on ?? torrent.addedTimeStamp ?? 0)
}
const getCurrentUpLimit = (torrent) => {
return Number(torrent.originProp?.up_limit ?? torrent.up_limit ?? 0)
}
const getCurrentDlLimit = (torrent) => {
return Number(torrent.originProp?.dl_limit ?? torrent.dl_limit ?? 0)
}
// 判定种子是否被站点删除/封禁
const checkIsInvalid = (torrent) => {
const trackerMessage = [
torrent.trackerStatus,
torrent.tracker_status,
torrent.message,
torrent.error
].filter(Boolean).join(' ').toLowerCase()
if (!trackerMessage) return false
const deletedMessages = [
"torrent banned",
"Torrent not exists",
"torrent not registered with this tracker",
"unregistered torrent",
"Invalid Torrent:"
]
return deletedMessages.some(msg => trackerMessage.includes(msg.toLowerCase()))
}
// 获取扣除等待期上传量后的统计数据
const getEffectiveStats = async (torrent) => {
const addedTime = getAddedTime(torrent)
if (!torrent.hash || !addedTime) return { effectiveUpload: 0, effectiveRatio: 0 }
const boundaryTime = addedTime + CONFIG.effectiveRatioDelay
const now = moment().unix()
if (now <= boundaryTime) return { effectiveUpload: 0, effectiveRatio: 0 }
const record = await util.getRecord(
'SELECT upload FROM torrent_flow WHERE hash = ? AND time >= ? ORDER BY time ASC LIMIT 1',
[torrent.hash, boundaryTime]
)
const uploadAtBoundary = record ? Number(record.upload) || 0 : 0
const size = Number(torrent.size) || 0
const effectiveUpload = Math.max(0, (Number(torrent.uploaded) || 0) - uploadAtBoundary)
const effectiveRatio = size > 0 ? (effectiveUpload / size) : 0
return { effectiveUpload, effectiveRatio }
}
const getRecentUploadSpeed = async (torrent, now) => {
const windowStart = now - CONFIG.slowWindow
const record = await util.getRecord(
'SELECT upload, time FROM torrent_flow WHERE hash = ? AND time >= ? ORDER BY time ASC LIMIT 1',
[torrent.hash, windowStart]
)
if (!record || record.upload === undefined || record.time === undefined) {
return torrent.upspeed || 0
}
const elapsed = Math.max(1, now - Number(record.time))
const uploadDiff = Math.max(0, (torrent.uploaded || 0) - (Number(record.upload) || 0))
return uploadDiff / elapsed
}
// 空闲清理是主动腾空间功能:不等空间告急,只清理已过保护期且窗口平均速度低于阈值的种子。
const checkIsIdleForCleanup = async (torrent, now) => {
const idleConfig = CONFIG.idleCleanup
const addedTime = getAddedTime(torrent)
if (!idleConfig.enabled) return { matched: false, reason: '空闲清理未开启' }
if (torrent.__skyDeleted) return { matched: false, reason: '本轮已删除' }
if (!torrent.hash) return { matched: false, reason: '缺少 hash' }
if (!addedTime) return { matched: false, reason: '缺少添加时间' }
if (now - addedTime < idleConfig.minAge) return { matched: false, reason: '未达到空闲清理最小年龄' }
if (!hasExitedProtection(torrent)) return { matched: false, reason: '仍在保护/观察期' }
if ((torrent.effectiveRatio || 0) < idleConfig.minEffectiveRatio) return { matched: false, reason: '有效 Ratio 未达空闲清理阈值' }
if (idleConfig.requireStandardLimit) {
const standardUploadLimitByte = CONFIG.standardUploadLimit * 1024
const standardDownloadLimitByte = CONFIG.standardDownloadLimit * 1024
if (getCurrentUpLimit(torrent) !== standardUploadLimitByte || getCurrentDlLimit(torrent) !== standardDownloadLimitByte) {
return { matched: false, reason: '未处于标准限速' }
}
}
if (torrent.uploaded === undefined || torrent.downloaded === undefined) {
return { matched: false, reason: '缺少上传/下载统计字段' }
}
const windowStart = now - idleConfig.idleWindow
const record = await util.getRecord(
'SELECT upload, download, time FROM torrent_flow WHERE hash = ? AND time >= ? ORDER BY time ASC LIMIT 1',
[torrent.hash, windowStart]
)
if (!record) {
if (idleConfig.requireFlowRecord) {
return { matched: false, reason: '空闲窗口缺少流量记录' }
}
const fallbackUploadSpeed = torrent.upspeed || 0
const fallbackDownloadSpeed = torrent.dlspeed || 0
const fallbackMatched = fallbackUploadSpeed <= idleConfig.idleUploadSpeedThreshold &&
fallbackDownloadSpeed <= idleConfig.idleDownloadSpeedThreshold
return fallbackMatched
? { matched: true, reason: `空闲窗口无流量记录,当前速度低于阈值 上传${formatSpeed(fallbackUploadSpeed)} 下载${formatSpeed(fallbackDownloadSpeed)}` }
: { matched: false, reason: `空闲窗口无流量记录,当前速度高于阈值 上传${formatSpeed(fallbackUploadSpeed)} 下载${formatSpeed(fallbackDownloadSpeed)}` }
}
if (record.upload === undefined || record.download === undefined) {
return { matched: false, reason: '空闲窗口记录缺少上传/下载字段' }
}
const uploadDiff = Math.max(0, (Number(torrent.uploaded) || 0) - (Number(record.upload) || 0))
const downloadDiff = Math.max(0, (Number(torrent.downloaded) || 0) - (Number(record.download) || 0))
const elapsed = Math.max(1, now - Number(record.time))
const avgUploadSpeed = uploadDiff / elapsed
const avgDownloadSpeed = downloadDiff / elapsed
if (avgUploadSpeed > idleConfig.idleUploadSpeedThreshold || avgDownloadSpeed > idleConfig.idleDownloadSpeedThreshold) {
return {
matched: false,
reason: `窗口平均速度高于阈值 上传${formatSpeed(avgUploadSpeed)} 下载${formatSpeed(avgDownloadSpeed)}`
}
}
return {
matched: true,
reason: `持续低速${Math.floor(idleConfig.idleWindow / 60)}分钟 上传${formatSpeed(avgUploadSpeed)} 下载${formatSpeed(avgDownloadSpeed)}`
}
}
const formatSize = (bytes) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = Number(bytes) || 0
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index++
}
return `${value.toFixed(index === 0 ? 0 : 2)}${units[index]}`
}
const formatSpeed = (bytes) => `${formatSize(bytes)}/s`
const isHighValueActive = (torrent) => {
return (torrent.progress || 0) < 1 && (torrent.upspeed || 0) > CONFIG.protectUploadSpeed
}
const hasExitedProtection = (torrent) => {
return (torrent.timeActive || 0) >= CONFIG.cleanupProtectionDelay + CONFIG.postLimitGrace
}
const isNearCompleteSlow = (torrent) => {
const progress = torrent.progress || 0
return progress >= CONFIG.stuckProgress &&
progress < 1 &&
hasExitedProtection(torrent) &&
(torrent.recentUploadSpeed || 0) < CONFIG.slowUploadSpeed &&
(torrent.dlspeed || 0) < CONFIG.stuckDownloadSpeed
}
const isSustainedSlowUpload = (torrent) => {
return hasExitedProtection(torrent) &&
(torrent.recentUploadSpeed || 0) < CONFIG.slowUploadSpeed
}
const CLEANUP_TAG_PRIORITY = {
invalid: 0,
stuckNearComplete: 1,
sustainedSlowUpload: 2,
normal: 3,
highValueFallback: 4
}
const CLEANUP_TAG_LABEL = {
invalid: '站点失效',
stuckNearComplete: '近完成低效种',
sustainedSlowUpload: '持续低上传种',
normal: '普通候选',
highValueFallback: '优质活跃种兜底'
}
const getCleanupTag = (torrent) => {
if (torrent.isInvalid) return 'invalid'
if (isHighValueActive(torrent)) return 'highValueFallback'
if (isNearCompleteSlow(torrent)) return 'stuckNearComplete'
if (isSustainedSlowUpload(torrent)) return 'sustainedSlowUpload'
return 'normal'
}
const buildCleanupReason = (torrent, isPanicMode) => {
const reasons = [isPanicMode ? '恐慌清理' : '空间清理']
const state = torrent.state || ''
if (torrent.cleanupTag) reasons.push(CLEANUP_TAG_LABEL[torrent.cleanupTag] || torrent.cleanupTag)
if (torrent.isInvalid) reasons.push('站点失效')
if (state.toLowerCase().includes('error')) reasons.push('状态报错')
reasons.push(`进度${((torrent.progress || 0) * 100).toFixed(1)}%`)
reasons.push(`添加${Math.floor((moment().unix() - getAddedTime(torrent)) / 60)}分钟前`)
reasons.push(`当前上传${formatSpeed(torrent.upspeed || 0)}`)
reasons.push(`10分钟均速${formatSpeed(torrent.recentUploadSpeed || 0)}`)
reasons.push(`当前下载${formatSpeed(torrent.dlspeed || 0)}`)
reasons.push(`有效Ratio${torrent.effectiveRatio.toFixed(3)}`)
reasons.push(`占用${formatSize(torrent.actualSize || 0)}`)
return reasons.join(' / ')
}
try {
const clientIds = Object.keys(clients)
debugLog(`开始运行,下载器数量: ${clientIds.length}`)
if (clientIds.length === 0) {
logger.error(`[${SCRIPT_NAME}] 未找到正在运行的下载器`)
return
}
for (const clientId of clientIds) {
let clientLabel = clientId
try {
const client = clients[clientId]
const clientAlias = getClientAlias(client, clientId)
clientLabel = `${clientAlias}/${clientId}`
if (!isClientInScope(client, clientId)) {
debugLog(`[${clientLabel}] 下载器别名不在处理范围,跳过`)
continue
}
const maindata = client && client.maindata
const serverState = maindata && (maindata.serverState || maindata.server_state)
if (!client || !maindata || !maindata.torrents || !serverState) {
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 下载器信息不完整,跳过`)
continue
}
const torrentsRaw = maindata.torrents
const torrents = getTorrentList(torrentsRaw)
const now = moment().unix()
const currentFreeSpace = Number(serverState.free_space_on_disk || 0)
const isPanic = currentFreeSpace < CONFIG.panicSpace
const torrentsType = Array.isArray(torrentsRaw) ? 'array' : typeof torrentsRaw
debugLog(`[${clientLabel}] torrents 类型: ${torrentsType},总数: ${torrents.length},剩余空间: ${formatSize(currentFreeSpace)},恐慌模式: ${isPanic ? '是' : '否'}`)
// 筛选并预计算
const skyTorrents = []
let missingAddedTime = 0
for (const t of torrents) {
if (!t || (t.category || '').toLowerCase() !== CONFIG.category) continue
const addedTime = getAddedTime(t)
if (!addedTime) missingAddedTime++
const stats = await getEffectiveStats(t)
t.effectiveRatio = stats.effectiveRatio
t.effectiveUpload = stats.effectiveUpload
t.actualSize = (Number(t.size) || 0) * (Number(t.progress) || 0) // 实际磁盘占用
t.isInvalid = checkIsInvalid(t) // 站点失效标记
t.timeActive = Math.max(0, now - (addedTime || now))
t.recentUploadSpeed = await getRecentUploadSpeed(t, now)
skyTorrents.push(t)
}
debugLog(`[${clientLabel}] sky 分类种子: ${skyTorrents.length},缺少添加时间: ${missingAddedTime}`)
const gracefulDelete = async (torrent, reason, extraInfo = '') => {
try {
if (client.reannounceTorrent) {
await client.reannounceTorrent(torrent)
await new Promise(r => setTimeout(r, 2000))
}
const extraText = extraInfo ? ` | ${extraInfo}` : ''
logger.info(`[${SCRIPT_NAME}] [${clientLabel}] 删除原因: ${reason} | 种子: ${torrent.name} | 有效Ratio: ${torrent.effectiveRatio.toFixed(3)} | 状态: ${torrent.state}${extraText}`)
await client.deleteTorrent(torrent, { alias: '脚本自动' })
torrent.__skyDeleted = true
return true
} catch (e) {
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 删除失败 | 原因: ${reason} | 种子: ${torrent.name} | 错误: ${formatError(e)}`)
return false
}
}
// ================= 达标删除 =================
let freedSpace = 0
let ratioDeleted = 0
for (const torrent of skyTorrents) {
if (torrent.effectiveRatio >= CONFIG.maxRatio) {
const reason = `达标删除 / 有效Ratio ${torrent.effectiveRatio.toFixed(3)} >= ${CONFIG.maxRatio}`
const extraInfo = `预计释放: ${formatSize(torrent.actualSize || 0)}`
if (await gracefulDelete(torrent, reason, extraInfo)) {
freedSpace += torrent.actualSize
ratioDeleted++
}
}
}
// ================= 空闲种主动清理 =================
let idleDeleted = 0
if (CONFIG.idleCleanup.enabled) {
for (const torrent of skyTorrents) {
if (torrent.__skyDeleted || torrent.effectiveRatio >= CONFIG.maxRatio) continue
if (CONFIG.idleCleanup.maxDeletePerRun > 0 && idleDeleted >= CONFIG.idleCleanup.maxDeletePerRun) break
const idleCheck = await checkIsIdleForCleanup(torrent, now)
if (!idleCheck.matched) continue
const reason = `空闲删除 / ${idleCheck.reason}`
const extraInfo = `预计释放: ${formatSize(torrent.actualSize || 0)}`
if (await gracefulDelete(torrent, reason, extraInfo)) {
freedSpace += torrent.actualSize
idleDeleted++
}
}
}
// 计算达标删除、空闲删除后的虚拟空间
let virtualFreeSpace = currentFreeSpace + freedSpace
const shouldCleanSpace = currentFreeSpace < CONFIG.minFreeSpace || virtualFreeSpace < CONFIG.minFreeSpace
debugLog(`[${clientLabel}] 达标删除: ${ratioDeleted},空闲删除: ${idleDeleted},虚拟剩余空间: ${formatSize(virtualFreeSpace)},触发空间清理: ${shouldCleanSpace ? '是' : '否'}`)
// ================= 空间清理 =================
if (shouldCleanSpace) {
const candidateList = []
for (const t of skyTorrents) {
if (t.__skyDeleted || t.effectiveRatio >= CONFIG.maxRatio) continue
// 站点失效种子最高优先级,直接进入候选
if (t.isInvalid) {
t.cleanupTag = getCleanupTag(t)
candidateList.push({ torrent: t })
continue
}
const have = (t.progress > 0)
const isUnderCleanupProtection = t.timeActive < CONFIG.cleanupProtectionDelay
const isUnderPostLimitGrace = !hasExitedProtection(t)
// 正常清理保护新种保护期、观察期、优质活跃未完成种
if (!isPanic) {
if (isUnderPostLimitGrace) continue
if (isHighValueActive(t)) continue
}
if (isPanic) {
if (isUnderCleanupProtection && !have) continue
}
t.cleanupTag = getCleanupTag(t)
candidateList.push({ torrent: t })
}
debugLog(`[${clientLabel}] 空间清理候选: ${candidateList.length}`)
// 排序逻辑
candidateList.sort((a, b) => {
const tA = a.torrent
const tB = b.torrent
const tagDiff = CLEANUP_TAG_PRIORITY[tA.cleanupTag] - CLEANUP_TAG_PRIORITY[tB.cleanupTag]
if (tagDiff !== 0) return tagDiff
// 同标签内,近期平均上传速度慢的优先删除
const upDiff = (tA.recentUploadSpeed || 0) - (tB.recentUploadSpeed || 0)
if (Math.abs(upDiff) > CONFIG.uploadSpeedPriorityDiff) {
return upDiff
}
// 上传速度接近时,优先删除添加时间更早的种子
const addedTimeDiff = getAddedTime(tA) - getAddedTime(tB)
if (Math.abs(addedTimeDiff) > CONFIG.addedTimePriorityDiff) {
return addedTimeDiff
}
// 添加时间也接近时,优先删除有效Ratio更低的种子
const ratioDiff = (tA.effectiveRatio || 0) - (tB.effectiveRatio || 0)
if (Math.abs(ratioDiff) > CONFIG.ratioPriorityDiff) {
return ratioDiff
}
// Ratio也接近时,才用占用空间大的作为兜底排序
const sizeDiff = tB.actualSize - tA.actualSize
if (sizeDiff !== 0) return sizeDiff
// 普通报错是最后的轻微兜底因素
const aErr = (tA.state || '').toLowerCase().includes('error')
const bErr = (tB.state || '').toLowerCase().includes('error')
if (aErr !== bErr) return aErr ? -1 : 1
return 0
})
for (const candidate of candidateList) {
if (virtualFreeSpace > CONFIG.targetFreeSpace) break
const nextFreeSpace = virtualFreeSpace + candidate.torrent.actualSize
const reason = buildCleanupReason(candidate.torrent, isPanic)
const extraInfo = `预计空间: ${formatSize(virtualFreeSpace)} -> ${formatSize(nextFreeSpace)}`
if (await gracefulDelete(candidate.torrent, reason, extraInfo)) {
virtualFreeSpace = nextFreeSpace
}
}
}
// ================= 恢复与限速逻辑 =================
if (currentFreeSpace > CONFIG.panicSpace) {
const pausedStates = ['pausedDL', 'pausedUP', 'Stopped', 'stopped']
const catchupUploadLimitByte = CONFIG.catchupUploadLimit * 1024
const catchupDownloadLimitByte = CONFIG.catchupDownloadLimit * 1024
const standardUploadLimitByte = CONFIG.standardUploadLimit * 1024
const standardDownloadLimitByte = CONFIG.standardDownloadLimit * 1024
const limitStats = {
catchupUploadSet: 0,
catchupDownloadSet: 0,
standardUploadSet: 0,
standardDownloadSet: 0,
paused: 0,
unchanged: 0,
resumed: 0,
failed: 0,
skipped: 0
}
const canSetSpeedLimit = typeof client.setSpeedLimit === 'function'
const canPauseTorrent = typeof client.pauseTorrent === 'function'
if (!canSetSpeedLimit) {
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 下载器不支持 setSpeedLimit,限速逻辑无法执行`)
}
for (const torrent of skyTorrents) {
if (torrent.__skyDeleted) {
limitStats.skipped++
continue
}
const addedTime = getAddedTime(torrent)
const torrentName = torrent.name || torrent.hash || '未知种子'
if (!torrent.hash) {
limitStats.skipped++
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 种子缺少 hash,跳过限速: ${torrentName}`)
continue
}
if (!addedTime) {
limitStats.skipped++
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 种子缺少添加时间,跳过限速: ${torrentName}`)
continue
}
const age = now - addedTime
const isPaused = pausedStates.includes(torrent.state)
if (age < CONFIG.initialPauseDelay) {
if (isPaused) {
limitStats.unchanged++
continue
}
if (!canPauseTorrent) {
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 下载器不支持 pauseTorrent,无法初始暂停: ${torrentName}`)
limitStats.failed++
continue
}
try {
await client.pauseTorrent(torrent.hash)
limitStats.paused++
debugLog(`[${clientLabel}] 初始期暂停种子成功: ${torrentName}`)
} catch (e) {
limitStats.failed++
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 初始期暂停种子失败: ${torrentName} | ${formatError(e)}`)
}
continue
}
const isCatchupStage = age < CONFIG.uploadResumeDelay
const currentUpLimit = getCurrentUpLimit(torrent)
const currentDlLimit = getCurrentDlLimit(torrent)
const targetUploadLimit = isCatchupStage ? catchupUploadLimitByte : standardUploadLimitByte
const targetDownloadLimit = isCatchupStage ? catchupDownloadLimitByte : standardDownloadLimitByte
const targetLabel = isCatchupStage ? '追赶期' : '标准'
const needsUploadLimit = currentUpLimit !== targetUploadLimit
const needsDownloadLimit = currentDlLimit !== targetDownloadLimit
let changed = false
if ((needsUploadLimit || needsDownloadLimit) && !canSetSpeedLimit) {
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 下载器不支持 setSpeedLimit,无法设置${targetLabel}限速: ${torrentName}`)
limitStats.failed++
continue
}
if (needsUploadLimit) {
try {
await client.setSpeedLimit(torrent.hash, 'upload', targetUploadLimit)
changed = true
if (isCatchupStage) {
limitStats.catchupUploadSet++
} else {
limitStats.standardUploadSet++
}
debugLog(`[${clientLabel}] 设置${targetLabel}上传限速成功: ${torrentName} | ${formatSpeed(currentUpLimit)} -> ${formatSpeed(targetUploadLimit)}`)
} catch (e) {
limitStats.failed++
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 设置${targetLabel}上传限速失败: ${torrentName} | ${formatSpeed(currentUpLimit)} -> ${formatSpeed(targetUploadLimit)} | ${formatError(e)}`)
continue
}
}
if (needsDownloadLimit) {
try {
await client.setSpeedLimit(torrent.hash, 'download', targetDownloadLimit)
changed = true
if (isCatchupStage) {
limitStats.catchupDownloadSet++
} else {
limitStats.standardDownloadSet++
}
debugLog(`[${clientLabel}] 设置${targetLabel}下载限速成功: ${torrentName} | ${formatSpeed(currentDlLimit)} -> ${formatSpeed(targetDownloadLimit)}`)
} catch (e) {
limitStats.failed++
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 设置${targetLabel}下载限速失败: ${torrentName} | ${formatSpeed(currentDlLimit)} -> ${formatSpeed(targetDownloadLimit)} | ${formatError(e)}`)
continue
}
}
if (isPaused) {
if (typeof client.resumeTorrent !== 'function') {
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 下载器不支持 resumeTorrent,无法恢复: ${torrentName}`)
limitStats.failed++
continue
} else {
try {
await client.resumeTorrent(torrent.hash)
limitStats.resumed++
changed = true
debugLog(`[${clientLabel}] 恢复种子成功: ${torrentName}`)
} catch (e) {
limitStats.failed++
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 恢复种子失败: ${torrentName} | ${formatError(e)}`)
continue
}
}
}
if (!changed) {
limitStats.unchanged++
}
}
debugLog(`[${clientLabel}] 限速汇总: 追赶上传${limitStats.catchupUploadSet},追赶下载${limitStats.catchupDownloadSet},标准上传${limitStats.standardUploadSet},标准下载${limitStats.standardDownloadSet},暂停${limitStats.paused},已符合${limitStats.unchanged},恢复${limitStats.resumed},跳过${limitStats.skipped},失败${limitStats.failed}`)
} else {
debugLog(`[${clientLabel}] 剩余空间 ${formatSize(currentFreeSpace)} <= 恐慌线 ${formatSize(CONFIG.panicSpace)},跳过恢复与限速`)
}
} catch (e) {
logger.error(`[${SCRIPT_NAME}] [${clientLabel}] 处理异常: ${formatError(e)}`)
}
}
debugLog('本轮运行完成')
} catch (e) {
logger.error(`[${SCRIPT_NAME}] 脚本运行异常: ${formatError(e)}`)
}
}
~~~
# 空控速删种脚本功能与删种逻辑
## 更新日志
### v1.5
```text
新增下载器别名范围配置,可默认处理全部下载器,也可按别名包含/排除指定下载器。
拆分时间边界:50分钟前暂停,50-58分钟追赶下载,58分钟恢复标准上传,60分钟后计算有效 Ratio,70分钟后结束正常清理保护。
新增下载限速配置,追赶期和标准期均可分别配置上传/下载限速。
空间清理排序改用 slowWindow 内近期平均上传速度,不再用瞬时上传速度排序。
空闲清理改为使用 idleWindow 内平均上传/下载速度阈值,默认阈值为 0,保持原有无流量行为。
```
这是一个 Vertex 定时脚本,用来管理 Vertex 中运行中下载器客户端里的 `sky` 分类种子。
脚本会遍历:
```js
global.runningClient
```
默认情况下,它不只管理 Vertex 所在 VPS 的 qB,也会管理 Vertex 中配置并运行的其他远程 qB。也可以通过下载器别名配置限定处理范围。
## 配置分组
当前脚本把配置按功能拆成几组:
```js
BASE_CONFIG
SCOPE_CONFIG
RATIO_CONFIG
SPACE_CLEANUP_CONFIG
SPEED_LIMIT_CONFIG
IDLE_CLEANUP_CONFIG
```
底部再合并成统一的 `CONFIG`,方便脚本内部复用。
## 当前配置摘要
基础配置:
```js
category: 'sky'
debug: true
```
下载器范围:
```js
includeClientAliases: []
excludeClientAliases: []
```
`includeClientAliases` 为空时处理所有下载器;非空时只处理这些下载器别名。`excludeClientAliases` 用来排除指定下载器,优先级高于 `includeClientAliases`。下载器别名使用 Vertex 下载器列表中的“别名”字段,精确匹配。
有效 Ratio:
```js
effectiveRatioDelay: 60 * 60
maxRatio: 3.1
```
空间清理:
```js
minFreeSpace: 25GB
targetFreeSpace: 30GB
panicSpace: 5GB
cleanupProtectionDelay: 60 * 60
protectUploadSpeed: 10MB/s
postLimitGrace: 10 * 60
slowWindow: 10 * 60
slowUploadSpeed: 1MB/s
stuckProgress: 0.995
stuckDownloadSpeed: 128KB/s
uploadSpeedPriorityDiff: 10KB/s
addedTimePriorityDiff: 30分钟
ratioPriorityDiff: 0.1
```
限速:
```js
initialPauseDelay: 50 * 60
uploadResumeDelay: 58 * 60
catchupUploadLimit: 1024
catchupDownloadLimit: 0
standardUploadLimit: 460 * 1024
standardDownloadLimit: 0
```
单位是 `KB/s`,调用下载器接口时会再乘以 `1024` 转成 bytes/s。
空闲清理:
```js
enabled: true
idleWindow: 30 * 60
minAge: 90 * 60
requireStandardLimit: true
idleUploadSpeedThreshold: 0
idleDownloadSpeedThreshold: 0
requireFlowRecord: true
minEffectiveRatio: 0
maxDeletePerRun: 0
```
`maxDeletePerRun: 0` 表示每轮不限制空闲删除数量。
## 执行顺序
每个下载器内的执行顺序:
```text
1. 按下载器别名筛选下载器
2. 筛选 sky 分类种子
3. 预计算有效 Ratio、实际占用、tracker 状态、最近上传均速
4. 达标删除:有效 Ratio >= 3.1
5. 空闲种主动清理
6. 计算达标删除和空闲删除后的虚拟剩余空间
7. 空间不足时执行空间清理
8. 空间不低于恐慌线时维护恢复/限速
```
删除成功后会给种子打 `__skyDeleted` 标记,避免同一轮重复进入后续清理或限速逻辑。
## 有效 Ratio
脚本不会直接使用 qB 的总 Ratio。
它会以:
```text
种子添加时间 + effectiveRatioDelay
```
作为边界,从 Vertex 的 `torrent_flow` 表读取边界之后的上传记录,只计算站点计分边界之后的上传量。
当前 `effectiveRatioDelay` 为 `60分钟`。
计算方式:
```text
有效上传量 = 当前总上传量 - effectiveRatioDelay 边界时的上传量
有效 Ratio = 有效上传量 / 种子大小
```
有效 Ratio 达到 `3.1` 时,种子会被达标删除。
## 空闲种主动清理
空闲清理默认开启,并且不要求空间不足。
它的目标是主动释放下载器空间,让 Vertex 更容易按空闲空间选择下载器,也让空间可以给其他站点使用。
空闲删除条件需要同时满足:
```text
属于 sky 分类
本轮未被删除
hash 存在
添加时间存在
添加时间 >= 90分钟
已经过 60 分钟清理保护期和 10 分钟观察期
有效 Ratio >= minEffectiveRatio
当前处于标准上传/下载限速
uploaded / downloaded 字段存在
最近 30 分钟有 torrent_flow 记录
最近 30 分钟平均上传 <= idleUploadSpeedThreshold
最近 30 分钟平均下载 <= idleDownloadSpeedThreshold
```
默认 `requireFlowRecord: true`,所以最近 30 分钟没有流量记录时,不会直接当作空闲,避免 Vertex 统计缺失导致误删。
当前 `idleUploadSpeedThreshold` 和 `idleDownloadSpeedThreshold` 都是 `0`,所以默认仍等价于窗口内没有上传/下载增量。
如果以后把 `requireFlowRecord` 改成 `false`,没有窗口记录时会用当前上传/下载速度和空闲阈值做兜底判断。
## 空间阈值
当前配置:
```js
minFreeSpace: 25GB
targetFreeSpace: 30GB
panicSpace: 5GB
```
含义:
```text
当前空间低于 25GB 时触发空间清理
达标删除、空闲删除后预计空间仍低于 25GB 时,也触发空间清理
一旦触发空间清理,会循环删除候选种子,直到预计剩余空间大于 30GB,或无候选可删
当前空间低于 5GB 时进入恐慌模式,部分保护会放宽
```
## 正常空间清理保护
正常空间清理时,以下种子不会进入候选:
```text
添加时间 < cleanupProtectionDelay + postLimitGrace
优质活跃未完成种:未完成且当前上传速度 > 10MB/s
```
当前配置下,正常空间清理会保护添加后约 `70分钟` 内的种子:
```text
60分钟清理保护期 + 10分钟观察期
```
恐慌模式下,优质活跃种不再绝对保护,但会被打到 `优质活跃种兜底` 标签,排序靠后。
恐慌模式下,如果种子仍在 60 分钟清理保护期内且进度为 `0`,仍会保护;如果已有进度,则可以进入空间清理候选。
## 候选标签
空间清理时,脚本会先给候选种子打标签。
标签优先级如下:
```text
1. 站点失效
2. 近完成低效种
3. 持续低上传种
4. 普通候选
5. 优质活跃种兜底
```
### 近完成低效种
满足:
```text
进度 >= 99.5%
进度 < 100%
已过 60 分钟清理保护期和 10 分钟观察期
最近 10 分钟平均上传 < 1MB/s
当前下载速度 < 128KB/s
```
### 持续低上传种
满足:
```text
已过 60 分钟清理保护期和 10 分钟观察期
最近 10 分钟平均上传 < 1MB/s
```
### 普通候选
不属于以上低效标签,但已经允许参与空间清理的普通种子。
### 优质活跃种兜底
满足:
```text
未完成
当前上传速度 > 10MB/s
```
正常空间清理时这类种子会被保护,不进入候选。恐慌模式下才会作为最后兜底。
## 组内排序
同一标签内,再按以下阶梯排序:
```text
1. 最近 slowWindow 内平均上传速度差超过 10KB/s 时,平均上传更慢的优先删
2. 平均上传速度接近时,如果添加时间差超过 30分钟,更早添加的优先删
3. 添加时间也接近时,如果有效 Ratio 差超过 0.1,有效 Ratio 更低的优先删
4. 有效 Ratio 也接近时,占用空间更大的优先删
5. 前面都接近时,状态包含 error 的作为轻微兜底优先
```
也就是说,添加时间、有效 Ratio、占用空间不是和近期平均上传速度平级的独立优先级,而是在前一个指标接近时才继续比较。
## 删种顺序总结
整体可以理解为:
```text
先删有效 Ratio 达标种
再主动清理空闲种
如果空间仍不足,再进入空间清理
空间清理先保护新种和优质活跃种
再优先清理站点失效种
再清理近完成但低效的卡死种
再清理持续低上传种
再处理普通候选
优质活跃种只在恐慌模式最后兜底
```
这套逻辑的目标是:
```text
保留真正有上传价值的种子
主动释放长期空闲种占用的空间
优先清理已经给过时间但仍然低效、占空间却收益很低的种子
```
## 限速逻辑
空间没有低于恐慌线时,脚本会维护三阶段限速/恢复:
```text
添加未满 50分钟:暂停种子
添加满 50分钟但未满 58分钟:恢复种子,进入追赶期
添加满 58分钟:恢复标准上传/下载限速
```
当前配置:
```js
initialPauseDelay: 50 * 60
uploadResumeDelay: 58 * 60
catchupUploadLimit: 1024
catchupDownloadLimit: 0
standardUploadLimit: 460 * 1024
standardDownloadLimit: 0
```
含义:
```text
0-50分钟:暂停,尽量节省下载空间
50-58分钟:下载不限速,上传限速 1024KB/s
58分钟后:上传恢复 460MB/s,下载不限速
60分钟后:站点有效 Ratio 起算
70分钟后:正常空间清理保护期结束
```
恢复种子时,脚本会先设置追赶期或标准期的上传/下载限速,再执行恢复,避免恢复瞬间吃到旧限速。
当剩余空间低于或等于 `panicSpace` 时,脚本会跳过恢复与限速逻辑,避免空间危急时额外恢复种子。
98天
| 相关话题 | 回复 | 浏览量 | 活动 | |
|---|---|---|---|---|
