蓝影论坛 Logo
27浏览量
1参与者
活动 102天
✨这是 parkertaylor 首次发帖 - 让我们欢迎他/她加入社区吧!
parkertaylor
楼主
~~~ async () => { const moment = require('moment') const util = require('../libs/util') const clients = global.runningClient || {} const BASE_CONFIG = { scriptName: '普通站点空间空闲清理', debug: true, // 白名单分类不会被本脚本处理;除此之外的分类都会参与空闲/空间清理。 protectedCategories: ['KEEP', 'SKY', '长期'] } const IDLE_CLEANUP_CONFIG = { enabled: true, // 默认开启空闲种清理 idleWindow: 30 * 60, // 最近 30 分钟上传/下载都无增量才算空闲 unfinishedMinAge: 1 * 60 * 60, // 未完成种添加超过该时间后也允许空闲清理 requireCurrentSpeedZero: true, // 当前上传/下载速度必须为 0 requireFlowRecord: true, // 最近窗口必须有流量记录;没有记录不当作空闲 maxDeletePerRun: 0 // 每轮最多删多少空闲种;0 表示不限制 } const SPACE_CLEANUP_CONFIG = { enabled: true, minFreeSpace: 20 * 1024 * 1024 * 1024, // 低于该空间触发空间清理 targetFreeSpace: 20 * 1024 * 1024 * 1024, // 清理到该空间后停止 panicSpace: 5 * 1024 * 1024 * 1024, // 仅用于日志标记,当前策略不额外保护 unfinishedMinAge: 1 * 60 * 60, // 未完成种添加超过该时间后可参与空间清理 recentUploadWindow: 3 * 60, // 最近 3 分钟上传均速用于排序 slowUploadSpeed: 1 * 1024 * 1024, // 低上传标签阈值,仅影响日志/标签 recentUploadSpeedPriorityDiff: 64 * 1024, // 上传均速差超过 64KB/s 才认为有明显差异 sizePriorityDiff: 1 * 1024 * 1024 * 1024, // 占用空间差超过 1GB 才按空间大小排序 downloadSpeedPriorityDiff: 128 * 1024, // 当前下载速度差超过 128KB/s 才按下载速度排序 addedTimePriorityDiff: 30 * 60 // 添加时间差超过 30 分钟才按更早添加排序 } const CONFIG = { ...BASE_CONFIG, idleCleanup: IDLE_CLEANUP_CONFIG, spaceCleanup: SPACE_CLEANUP_CONFIG } const formatError = (error) => { if (!error) return '未知错误' return error.stack || error.message || String(error) } const debugLog = (message) => { if (CONFIG.debug) { logger.info(`[${CONFIG.scriptName}][DEBUG] ${message}`) } } // Vertex 不同版本/下载器可能把 torrents 放成数组或对象,这里统一转成数组。 const getTorrentList = (torrents) => { if (Array.isArray(torrents)) return torrents if (torrents && typeof torrents === 'object') return Object.values(torrents) return [] } const getAddedTime = (torrent) => { return Number(torrent.addedTime ?? torrent.added_on ?? torrent.addedTimeStamp ?? 0) } const isFinished = (torrent) => { return (Number(torrent.progress) || 0) >= 1 } const getCategory = (torrent) => { return String(torrent.category || '').toLowerCase() } const isProtectedCategory = (torrent) => { const category = getCategory(torrent) return CONFIG.protectedCategories .map(c => String(c).toLowerCase()) .includes(category) } // tracker 明确提示种子已删除/未注册时,空间清理中最高优先级删除。 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 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` // 用 torrent_flow 计算最近几分钟上传均速,比 qB 瞬时 upspeed 更适合做删种排序。 const getRecentUploadSpeed = async (torrent, now) => { if (!torrent.hash) return Number(torrent.upspeed) || 0 const windowStart = now - CONFIG.spaceCleanup.recentUploadWindow 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 Number(torrent.upspeed) || 0 } const elapsed = Math.max(1, now - Number(record.time)) const uploadDiff = Math.max(0, (Number(torrent.uploaded) || 0) - (Number(record.upload) || 0)) return uploadDiff / elapsed } // 空闲清理看 30 分钟上传/下载增量;缺记录时默认不当作空闲,避免统计缺失误删。 const getIdleWindowStats = async (torrent, now) => { const idleConfig = CONFIG.idleCleanup if (!torrent.hash) { return { hasRecord: false, uploadDiff: 0, downloadDiff: 0, reason: '缺少 hash' } } if (torrent.uploaded === undefined || torrent.downloaded === undefined) { return { hasRecord: false, uploadDiff: 0, downloadDiff: 0, 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) { return { hasRecord: false, uploadDiff: 0, downloadDiff: 0, reason: '空闲窗口缺少流量记录' } } if (record.upload === undefined || record.download === undefined) { return { hasRecord: false, uploadDiff: 0, downloadDiff: 0, reason: '空闲窗口记录缺少上传/下载字段' } } return { hasRecord: true, uploadDiff: Math.max(0, (Number(torrent.uploaded) || 0) - (Number(record.upload) || 0)), downloadDiff: Math.max(0, (Number(torrent.downloaded) || 0) - (Number(record.download) || 0)), reason: '' } } const isOldEnoughForCleanup = (torrent, seconds) => { return isFinished(torrent) || (Number(torrent.timeActive) || 0) >= seconds } // 空闲删除必须同时满足当前无速度、窗口内无增量,以及未完成种达到最小年龄。 const checkIsIdleForCleanup = (torrent) => { const idleConfig = CONFIG.idleCleanup if (!idleConfig.enabled) return { matched: false, reason: '空闲清理未开启' } if (torrent.__generalDeleted) return { matched: false, reason: '本轮已删除' } if (isProtectedCategory(torrent)) return { matched: false, reason: '白名单分类' } if (!isOldEnoughForCleanup(torrent, idleConfig.unfinishedMinAge)) { return { matched: false, reason: '未完成种未达到清理年龄' } } if (idleConfig.requireCurrentSpeedZero && ((torrent.upspeed || 0) !== 0 || (torrent.dlspeed || 0) !== 0)) { return { matched: false, reason: '当前仍有上传/下载速度' } } if (!torrent.idleStats.hasRecord) { return idleConfig.requireFlowRecord ? { matched: false, reason: torrent.idleStats.reason } : { matched: true, reason: `最近${Math.floor(idleConfig.idleWindow / 60)}分钟无流量记录` } } if (torrent.idleStats.uploadDiff !== 0 || torrent.idleStats.downloadDiff !== 0) { return { matched: false, reason: `窗口内仍有流量 上传${formatSize(torrent.idleStats.uploadDiff)} 下载${formatSize(torrent.idleStats.downloadDiff)}` } } return { matched: true, reason: `持续空闲${Math.floor(idleConfig.idleWindow / 60)}分钟` } } const getCleanupTag = (torrent) => { if (torrent.isInvalid) return '站点失效' if (torrent.idleMatched) return '空闲种' if (!isFinished(torrent) && (torrent.recentUploadSpeed || 0) < CONFIG.spaceCleanup.slowUploadSpeed) { return '未完成低上传' } if (isFinished(torrent) && (torrent.recentUploadSpeed || 0) < CONFIG.spaceCleanup.slowUploadSpeed) { return '已完成低上传' } return '普通候选' } const buildReason = (prefix, torrent) => { const addedTime = getAddedTime(torrent) const ageMinutes = addedTime ? Math.floor((moment().unix() - addedTime) / 60) : 0 const parts = [ prefix, `分类${torrent.category || '无'}`, getCleanupTag(torrent), `进度${((torrent.progress || 0) * 100).toFixed(1)}%`, `添加${ageMinutes}分钟前`, `当前上传${formatSpeed(torrent.upspeed || 0)}`, `${Math.floor(CONFIG.spaceCleanup.recentUploadWindow / 60)}分钟上传均速${formatSpeed(torrent.recentUploadSpeed || 0)}`, `当前下载${formatSpeed(torrent.dlspeed || 0)}`, `30分钟上传增量${formatSize(torrent.idleStats.uploadDiff || 0)}`, `30分钟下载增量${formatSize(torrent.idleStats.downloadDiff || 0)}`, `占用${formatSize(torrent.actualSize || 0)}` ] if (torrent.isInvalid) parts.push('tracker失效') return parts.join(' / ') } // 空间清理排序使用“差距阈值”:指标差距很小时进入下一层,避免被微小抖动带偏。 const sortCleanupCandidates = (a, b) => { const tA = a.torrent const tB = b.torrent if (tA.isInvalid !== tB.isInvalid) return tA.isInvalid ? -1 : 1 if (tA.idleMatched !== tB.idleMatched) return tA.idleMatched ? -1 : 1 // 先看最近上传均速;只有差距明显时才按均速删除。 const recentUpDiff = (tA.recentUploadSpeed || 0) - (tB.recentUploadSpeed || 0) if (Math.abs(recentUpDiff) > CONFIG.spaceCleanup.recentUploadSpeedPriorityDiff) { return recentUpDiff } // 上传收益接近时,优先释放空间更大的种子。 const sizeDiff = (tB.actualSize || 0) - (tA.actualSize || 0) if (Math.abs(sizeDiff) > CONFIG.spaceCleanup.sizePriorityDiff) { return sizeDiff } // 空间收益也接近时,再看当前下载速度,下载更慢的先删。 const downDiff = (tA.dlspeed || 0) - (tB.dlspeed || 0) if (Math.abs(downDiff) > CONFIG.spaceCleanup.downloadSpeedPriorityDiff) { return downDiff } // 前面都接近时,优先清理添加更久的种子。 const addedTimeDiff = getAddedTime(tA) - getAddedTime(tB) if (Math.abs(addedTimeDiff) > CONFIG.spaceCleanup.addedTimePriorityDiff) { return addedTimeDiff } return 0 } try { const clientIds = Object.keys(clients) debugLog(`开始运行,下载器数量: ${clientIds.length}`) if (clientIds.length === 0) { logger.error(`[${CONFIG.scriptName}] 未找到正在运行的下载器`) return } for (const clientId of clientIds) { try { const client = clients[clientId] const maindata = client && client.maindata const serverState = maindata && (maindata.serverState || maindata.server_state) if (!client || !maindata || !maindata.torrents || !serverState) { logger.error(`[${CONFIG.scriptName}] [${clientId}] 下载器信息不完整,跳过`) 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.spaceCleanup.panicSpace debugLog(`[${clientId}] 扫描 ${torrents.length} 个种子,剩余空间 ${formatSize(currentFreeSpace)},恐慌空间 ${isPanic ? '是' : '否'}`) // 预计算后续排序/清理要用的派生字段,避免排序时反复查数据库。 const managedTorrents = [] let protectedCount = 0 let missingAddedTime = 0 for (const torrent of torrents) { if (!torrent) continue if (isProtectedCategory(torrent)) { protectedCount++ continue } const addedTime = getAddedTime(torrent) if (!addedTime) missingAddedTime++ torrent.timeActive = Math.max(0, now - (addedTime || now)) torrent.actualSize = (Number(torrent.size ?? torrent.total_size) || 0) * (Number(torrent.progress) || 0) torrent.isInvalid = checkIsInvalid(torrent) torrent.recentUploadSpeed = await getRecentUploadSpeed(torrent, now) torrent.idleStats = await getIdleWindowStats(torrent, now) torrent.idleMatched = checkIsIdleForCleanup(torrent).matched managedTorrents.push(torrent) } debugLog(`[${clientId}] 可管理种子 ${managedTorrents.length} 个,白名单跳过 ${protectedCount} 个,缺少添加时间 ${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(`[${CONFIG.scriptName}] [${clientId}] 删除原因: ${reason} | 种子: ${torrent.name} | 状态: ${torrent.state}${extraText}`) await client.deleteTorrent(torrent, { alias: '脚本自动' }) torrent.__generalDeleted = true return true } catch (e) { logger.error(`[${CONFIG.scriptName}] [${clientId}] 删除失败 | 种子: ${torrent.name} | 原因: ${reason} | 错误: ${formatError(e)}`) return false } } let freedSpace = 0 let idleDeleted = 0 // 先做主动空闲清理,让 Vertex 和其他站点能更早拿到空闲空间。 if (CONFIG.idleCleanup.enabled) { for (const torrent of managedTorrents) { if (torrent.__generalDeleted) continue if (CONFIG.idleCleanup.maxDeletePerRun > 0 && idleDeleted >= CONFIG.idleCleanup.maxDeletePerRun) break const idleCheck = checkIsIdleForCleanup(torrent) if (!idleCheck.matched) continue const reason = buildReason(`空闲删除 / ${idleCheck.reason}`, torrent) const extraInfo = `预计释放: ${formatSize(torrent.actualSize || 0)}` if (await gracefulDelete(torrent, reason, extraInfo)) { freedSpace += torrent.actualSize || 0 idleDeleted++ } } } let virtualFreeSpace = currentFreeSpace + freedSpace let spaceDeleted = 0 const shouldCleanSpace = CONFIG.spaceCleanup.enabled && (currentFreeSpace < CONFIG.spaceCleanup.minFreeSpace || virtualFreeSpace < CONFIG.spaceCleanup.minFreeSpace) debugLog(`[${clientId}] 空闲删除 ${idleDeleted} 个,虚拟剩余空间 ${formatSize(virtualFreeSpace)},触发空间清理 ${shouldCleanSpace ? '是' : '否'}`) if (shouldCleanSpace) { const candidateList = [] // 空间清理不看最低 ratio/做种时间;只跳过白名单和未达到最小年龄的未完成种。 for (const torrent of managedTorrents) { if (torrent.__generalDeleted) continue if (!torrent.isInvalid && !isOldEnoughForCleanup(torrent, CONFIG.spaceCleanup.unfinishedMinAge)) continue torrent.idleMatched = torrent.idleMatched || checkIsIdleForCleanup(torrent).matched candidateList.push({ torrent }) } candidateList.sort(sortCleanupCandidates) debugLog(`[${clientId}] 空间清理候选 ${candidateList.length} 个`) for (const candidate of candidateList) { if (virtualFreeSpace > CONFIG.spaceCleanup.targetFreeSpace) break const torrent = candidate.torrent const nextFreeSpace = virtualFreeSpace + (torrent.actualSize || 0) const reason = buildReason('空间清理', torrent) const extraInfo = `预计空间: ${formatSize(virtualFreeSpace)} -> ${formatSize(nextFreeSpace)}` if (await gracefulDelete(torrent, reason, extraInfo)) { virtualFreeSpace = nextFreeSpace spaceDeleted++ } } } debugLog(`[${clientId}] 本轮完成:空闲删除 ${idleDeleted},空间清理删除 ${spaceDeleted},预计剩余空间 ${formatSize(virtualFreeSpace)}`) } catch (e) { logger.error(`[${CONFIG.scriptName}] [${clientId}] 处理异常: ${formatError(e)}`) } } debugLog('全部下载器处理完成') } catch (e) { logger.error(`[${CONFIG.scriptName}] 脚本运行异常: ${formatError(e)}`) } } ~~~ # 普通站点空间 / 空闲清理脚本说明 这是一个 Vertex 定时脚本,用来清理普通站点中的慢速种、空闲种,核心目标是释放下载器空间,让 Vertex 可以按空闲空间调度下载器,也让空间可以被其他站点使用。 ## 适用范围 脚本会遍历: ```js global.runningClient ``` 所以 Vertex 中所有正在运行的下载器都会被处理。 脚本不按站点白名单处理,而是按分类白名单排除: ```js protectedCategories: ['KEEP', 'SKY', '长期'] ``` 这些分类不会被脚本处理。除此之外,其他分类全部适用。 ## 为什么用定时脚本 空间清理和空闲清理不建议用 Vertex 删种规则做。 原因是这套逻辑需要: - 查询最近 3 分钟上传均速。 - 查询最近 30 分钟上传 / 下载增量。 - 按近期上传均速、占用空间、下载速度、添加时间排序。 - 先空闲清理,再按虚拟剩余空间决定是否继续空间清理。 - 多下载器统一处理,并避免同一轮重复删同一个种子。 这些属于动态调度逻辑,定时脚本比删种规则更合适。 ## 执行顺序 每轮执行顺序: ```text 1. 遍历所有 runningClient 2. 排除白名单分类 3. 预计算每个种子的状态 4. 先执行空闲种主动清理 5. 计算虚拟剩余空间 6. 如果空间低于阈值,再执行空间清理 7. 写入详细日志 ``` ## 空闲清理 默认开启: ```js enabled: true ``` 默认清理全部匹配的空闲种: ```js maxDeletePerRun: 0 ``` `0` 表示不限制每轮删除数量。 空闲窗口默认 30 分钟: ```js idleWindow: 30 * 60 ``` 一个种子会被空闲清理,需要同时满足: ```text 不在白名单分类 当前上传速度 = 0 当前下载速度 = 0 最近 30 分钟上传增量 = 0 最近 30 分钟下载增量 = 0 已完成,或未完成但添加时间 >= unfinishedMinAge 最近 30 分钟有 torrent_flow 记录 ``` 未完成种默认添加超过 1 小时后也允许被清理: ```js unfinishedMinAge: 1 * 60 * 60 ``` 这里的逻辑是:未完成种如果长时间没有上传/下载流量,就说明它对当前刷流和空间利用价值都很低。 ## 空间清理 空间清理默认开启: ```js enabled: true ``` 默认阈值: ```js minFreeSpace: 20GB targetFreeSpace: 20GB panicSpace: 5GB ``` 当当前剩余空间或空闲清理后的虚拟剩余空间低于 `minFreeSpace` 时,触发空间清理。 触发后会持续删除候选种子,直到虚拟剩余空间高于 `targetFreeSpace`,或没有候选种可删。 ## 空间清理候选 候选条件: ```text 不在白名单分类 站点失效:直接作为候选 已完成:可作为候选 未完成:添加时间 >= unfinishedMinAge 后可作为候选 ``` 脚本不检查最低 Ratio,也不检查最少做种时间。 原因是这套脚本的目标不是保种达标,而是清理慢速和低空间收益种子。 ## 空间清理排序 排序优先级: ```text 1. 站点失效优先 2. 空闲种优先 3. 最近 3 分钟上传均速差距明显时,均速越低越先删 4. 上传均速相近时,实际占用空间差距明显的,大空间优先删 5. 占用空间也相近时,当前下载速度差距明显的,下载更慢优先删 6. 仍然相近时,添加时间差距明显的,更早添加优先删 ``` 最近上传均速窗口默认 3 分钟: ```js recentUploadWindow: 3 * 60 ``` 相近阈值默认: ```js recentUploadSpeedPriorityDiff: 64 * 1024 // 64KB/s sizePriorityDiff: 1 * 1024 * 1024 * 1024 // 1GB downloadSpeedPriorityDiff: 128 * 1024 // 128KB/s addedTimePriorityDiff: 30 * 60 // 30分钟 ``` 空间清理不再使用当前上传速度排序,因为 `upspeed` 是瞬时值,容易被采样抖动影响。当前上传速度仍会记录在日志里,也仍用于空闲清理中的“当前速度必须为 0”判断。 注意:未完成且长时间低速,指的是上传低速。下载速度只作为排序参考,不作为保护理由。 ## 重要配置 白名单分类: ```js protectedCategories: ['KEEP', 'SKY', '长期'] ``` 空闲清理: ```js const IDLE_CLEANUP_CONFIG = { enabled: true, idleWindow: 30 * 60, unfinishedMinAge: 1 * 60 * 60, requireCurrentSpeedZero: true, requireFlowRecord: true, maxDeletePerRun: 0 } ``` 空间清理: ```js const SPACE_CLEANUP_CONFIG = { enabled: true, minFreeSpace: 20 * 1024 * 1024 * 1024, targetFreeSpace: 20 * 1024 * 1024 * 1024, panicSpace: 5 * 1024 * 1024 * 1024, unfinishedMinAge: 1 * 60 * 60, recentUploadWindow: 3 * 60, slowUploadSpeed: 1 * 1024 * 1024, recentUploadSpeedPriorityDiff: 64 * 1024, sizePriorityDiff: 1 * 1024 * 1024 * 1024, downloadSpeedPriorityDiff: 128 * 1024, addedTimePriorityDiff: 30 * 60 } ``` ## 使用建议 上线前建议先确认: ```text 1. protectedCategories 是否包含所有不想被清理的分类。 2. minFreeSpace / targetFreeSpace 是否符合盒子容量。 3. unfinishedMinAge 是否符合站点和刷流节奏。 4. Vertex 的 torrent_flow 表是否正常记录 upload / download。 ``` 如果想先保守观察,可以临时设置: ```js maxDeletePerRun: 3 ``` 确认日志符合预期后,再改回: ```js maxDeletePerRun: 0 ``` ## 日志 删除日志会包含: ```text 删除类型 分类 进度 添加时间 当前上传速度 最近 3 分钟上传均速 当前下载速度 30 分钟上传增量 30 分钟下载增量 占用空间 预计释放或预计空间变化 ``` 这些信息用于判断是否存在误删风险,以及后续调参。
102天
相关话题回复浏览量活动