蓝影论坛 Logo
32浏览量
1参与者
活动 98天
✨这是 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天
相关话题回复浏览量活动