27浏览量
1参与者
p
活动 102天
parkertaylor
✨这是 parkertaylor 首次发帖 - 让我们欢迎他/她加入社区吧!
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天
| 相关话题 | 回复 | 浏览量 | 活动 | |
|---|---|---|---|---|
