天空vt刷流脚本

_

功能是1小时后开始下载或者前一小时限速一小时后不限速,分享率达标自动删除,空间不足时自动删种
根据大佬们的建议,已支持自动调整限速
initialUploadLimit设置为0为暂停模式,大于0是限速模式,具体值可以自己摸索,建议不要低于250
感谢各位大佬捧场

qb设置取消勾选为所有文件预分配磁盘空间

1769509467-480918-image.png

vt设置rss任务,勾选添加种子时暂停,分类设置为SKY

1769509340-630875-image.png

vt设置定时


cron:* * * * *
脚本:

async () => {
  const moment = require('moment')
  const util = require('../libs/util')
  const clients = global.runningClient

  const CONFIG = {
    category: 'sky',                       // 分类
    resumeDelay: 1 * 60 * 60,              // 初始等待时长 (1小时)
    maxRatio: 3.1,                         // 目标分享率 (扣除初始量)
    minFreeSpace: 12 * 1024 * 1024 * 1024, // 触发清理的空间阈值
    panicSpace: 5 * 1024 * 1024 * 1024,    // 恐慌空间 (无视大部分保护)
    gracePeriod: 10 * 60,                  // 暂停模式特有的保护时间
    initialUploadLimit: 500,               // 初始限速 (KB/s),0 为暂停
    standardUploadLimit: 0                 // 标准限速 (KB/s),0 为无限制
  }

  // 判定种子是否被站点删除/封禁
  const checkIsInvalid = (torrent) => {
    const { trackerStatus } = torrent
    if (!trackerStatus) return false
    const deletedMessages = [
      "torrent banned",
      "Torrent not exists",
      "torrent not registered with this tracker",
      "unregistered torrent",
      "Invalid Torrent:"
    ]
    const trackerMessage = trackerStatus.toLowerCase()
    return deletedMessages.some(msg => trackerMessage.includes(msg.toLowerCase()))
  }

  // 获取扣除等待期上传量后的统计数据
  const getEffectiveStats = async (torrent) => {
    const boundaryTime = torrent.addedTime + CONFIG.resumeDelay
    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 ? record.upload : 0
    const effectiveUpload = Math.max(0, torrent.uploaded - uploadAtBoundary)
    const effectiveRatio = torrent.size > 0 ? (effectiveUpload / torrent.size) : 0
    return { effectiveUpload, effectiveRatio }
  }

  for (const clientId of Object.keys(clients)) {
    const client = clients[clientId]
    const maindata = client.maindata
    const serverState = maindata?.serverState || maindata?.server_state
    if (!maindata || !maindata.torrents || !serverState) continue

    const torrents = maindata.torrents
    const now = moment().unix()
    const currentFreeSpace = serverState.free_space_on_disk || 0
    const isPanic = currentFreeSpace < CONFIG.panicSpace
    
    // 筛选并预计算
    const skyTorrents = []
    for (const t of torrents) {
      if ((t.category || '').toLowerCase() === CONFIG.category) {
        const stats = await getEffectiveStats(t)
        t.effectiveRatio = stats.effectiveRatio
        t.effectiveUpload = stats.effectiveUpload
        t.actualSize = (t.size || 0) * (t.progress || 0) // 实际磁盘占用
        t.isInvalid = checkIsInvalid(t)                  // 站点失效标记
        skyTorrents.push(t)
      }
    }

    const gracefulDelete = async (torrent, reason) => {
      try {
        if (client.reannounceTorrent) {
          await client.reannounceTorrent(torrent)
          await new Promise(r => setTimeout(r, 2000))
        }
        logger.info(`[脚本] ${reason}: ${torrent.name} | 有效Ratio: ${torrent.effectiveRatio.toFixed(3)} | 状态: ${torrent.state}`)
        await client.deleteTorrent(torrent, { alias: '脚本自动' })
        return true
      } catch (e) {
        logger.error(`[脚本] 删除失败: ${e.message}`)
        return false
      }
    }

    // ================= 达标删除 =================
    let freedSpace = 0
    for (const torrent of skyTorrents) {
      if (torrent.effectiveRatio >= CONFIG.maxRatio) {
        if (await gracefulDelete(torrent, ' 达标删除')) {
          freedSpace += torrent.actualSize
        }
      }
    }

    // 计算达标删除后的虚拟空间
    const virtualFreeSpace = currentFreeSpace + freedSpace

    // ================= 空间清理 =================
    if (virtualFreeSpace < CONFIG.minFreeSpace) {
      const candidateList = []
      
      for (const t of skyTorrents) {
        if (t.effectiveRatio >= CONFIG.maxRatio) continue 

        const isError = t.state.toLowerCase().includes('error')
        // 失效种子和常规报错优先进入候选
        if (t.isInvalid || isError) {
          candidateList.push({ torrent: t })
          continue
        }

        const have = (t.progress > 0)
        const timeActive = now - t.addedTime
        const isUnderDelay = timeActive < CONFIG.resumeDelay
        const isUnderGrace = (CONFIG.initialUploadLimit === 0)
          ? (timeActive < (CONFIG.resumeDelay + CONFIG.gracePeriod))
          : false

        if (isPanic) {
          if (isUnderDelay && !have) continue
        } else {
          if (isUnderDelay) continue
          if (isUnderGrace) continue
        }

        candidateList.push({ torrent: t })
      }

      // 排序逻辑
      candidateList.sort((a, b) => {
        const tA = a.torrent
        const tB = b.torrent

        // 站点已删种子绝对优先
        if (tA.isInvalid !== tB.isInvalid) return tA.isInvalid ? -1 : 1

        // 常规报错次优先
        const aErr = tA.state.toLowerCase().includes('error')
        const bErr = tB.state.toLowerCase().includes('error')
        if (aErr !== bErr) return aErr ? -1 : 1

        // 恐慌模式先删除占用大的
        if (isPanic) {
          return tB.actualSize - tA.actualSize
        }

        // 正常模式:先看上传速度差异
        const upDiff = tA.upspeed - tB.upspeed
        if (Math.abs(upDiff) > 10 * 1024) {
          return upDiff
        }

        // 速度差不多(<=10KB/s)时,删除占用大的
        const sizeDiff = tB.actualSize - tA.actualSize
        if (sizeDiff !== 0) return sizeDiff

        // 保护下载中的种子
        if (tA.progress === 1 && tB.progress < 1) return -1
        if (tB.progress === 1 && tA.progress < 1) return 1

        return 0
      })

      if (candidateList.length > 0) {
        const mode = isPanic ? " [恐慌清理]" : " [空间清理]"
        await gracefulDelete(candidateList[0].torrent, mode)
      }
    }

    // ================= 恢复与限速逻辑 =================
    if (currentFreeSpace > CONFIG.panicSpace) {
      const pausedStates = ['pausedDL', 'pausedUP', 'Stopped', 'stopped']
      
      for (const torrent of skyTorrents) {
        const isUnderDelay = (now - torrent.addedTime < CONFIG.resumeDelay)

        const currentUpLimit = torrent.originProp ? torrent.originProp.up_limit : 0
        const initialLimitByte = CONFIG.initialUploadLimit * 1024
        const standardLimitByte = CONFIG.standardUploadLimit * 1024

        if (CONFIG.initialUploadLimit > 0) {
          if (isUnderDelay) {
            if (pausedStates.includes(torrent.state)) {
              await client.resumeTorrent(torrent.hash)
            }
            if (currentUpLimit !== initialLimitByte) {
              await client.setSpeedLimit(torrent.hash, 'upload', initialLimitByte)
            }
          } else {
            if (currentUpLimit !== standardLimitByte) {
              await client.setSpeedLimit(torrent.hash, 'upload', standardLimitByte)
            }
          }
        } else {
          if (!isUnderDelay) {
            if (currentUpLimit !== standardLimitByte) {
              await client.setSpeedLimit(torrent.hash, 'upload', standardLimitByte)
            }
            if (pausedStates.includes(torrent.state)) {
              await client.resumeTorrent(torrent.hash)
            }
          }
        }
      }
    }
  }
}
async () => {
  const moment = require('moment')
  const util = require('../libs/util')
  const clients = global.runningClient || {}

  const BASE_CONFIG = {
    category: 'sky',                       // 分类
    debug: true                            // 调试日志开关,稳定后可改为 false
  }

  const RATIO_CONFIG = {
    resumeDelay: 58 * 60,              // 初始等待时长 (1小时)
    maxRatio: 3.1                         // 目标分享率 (扣除初始量)
  }

  const SPACE_CLEANUP_CONFIG = {
    minFreeSpace: 25 * 1024 * 1024 * 1024, // 触发清理的空间阈值
    targetFreeSpace: 30 * 1024 * 1024 * 1024, // 清理目标空间
    panicSpace: 5 * 1024 * 1024 * 1024,    // 恐慌空间 (无视大部分保护)
    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 = {
    initialUploadLimit: 1024,               // 初始限速 (KB/s),0 为不限速,负数为暂停
    standardUploadLimit: 460 * 1024                 // 标准限速 (KB/s),0 为无限制
  }

  const IDLE_CLEANUP_CONFIG = {
    enabled: true,                          // 是否开启空闲种主动清理,默认开启
    idleWindow: 30 * 60,                    // 最近多少秒内上传/下载都无增量才算空闲
    minAge: 90 * 60,                        // 添加满 90 分钟才允许空闲清理
    requireStandardLimit: true,             // 必须已经进入标准限速阶段,避免误删新种
    requireCurrentSpeedZero: true,          // 当前上传/下载速度必须为 0
    requireFlowRecord: true,                // 最近窗口必须有流量记录;没有记录不当作空闲
    minEffectiveRatio: 0,                   // 有效 Ratio 低于该值时不做空闲清理
    maxDeletePerRun: 0                      // 每轮最多删多少空闲种;0 表示不限制
  }

  // 保留统一 CONFIG,方便下面逻辑复用,同时让用户按功能块看配置。
  const CONFIG = {
    ...BASE_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 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 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.resumeDelay
    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 standardLimitByte = CONFIG.standardUploadLimit * 1024
      if (getCurrentUpLimit(torrent) !== standardLimitByte) {
        return { matched: false, reason: '未处于标准限速' }
      }
    }

    if (idleConfig.requireCurrentSpeedZero && ((torrent.upspeed || 0) !== 0 || (torrent.dlspeed || 0) !== 0)) {
      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) {
      return idleConfig.requireFlowRecord
        ? { matched: false, reason: '空闲窗口缺少流量记录' }
        : { matched: true, reason: `最近${Math.floor(idleConfig.idleWindow / 60)}分钟无流量记录` }
    }

    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))

    if (uploadDiff !== 0 || downloadDiff !== 0) {
      return {
        matched: false,
        reason: `窗口内仍有流量 上传${formatSize(uploadDiff)} 下载${formatSize(downloadDiff)}`
      }
    }

    return {
      matched: true,
      reason: `持续空闲${Math.floor(idleConfig.idleWindow / 60)}分钟`
    }
  }

  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.resumeDelay + 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) {
      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(`[${SCRIPT_NAME}] [${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.panicSpace
        const torrentsType = Array.isArray(torrentsRaw) ? 'array' : typeof torrentsRaw

        debugLog(`[${clientId}] 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(`[${clientId}] 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}] 删除原因: ${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}] 删除失败 | 原因: ${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(`[${clientId}] 达标删除: ${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 isUnderDelay = t.timeActive < CONFIG.resumeDelay
            const isUnderPostLimitGrace = !hasExitedProtection(t)

            // 正常清理保护初始低限速期、解除限速观察期、优质活跃未完成种
            if (!isPanic) {
              if (isUnderPostLimitGrace) continue
              if (isHighValueActive(t)) continue
            }

            if (isPanic) {
              if (isUnderDelay && !have) continue
            }

            t.cleanupTag = getCleanupTag(t)
            candidateList.push({ torrent: t })
          }

          debugLog(`[${clientId}] 空间清理候选: ${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.upspeed || 0) - (tB.upspeed || 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 initialLimitByte = CONFIG.initialUploadLimit * 1024
          const standardLimitByte = CONFIG.standardUploadLimit * 1024
          const limitStats = {
            initialSet: 0,
            standardSet: 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}] [${clientId}] 下载器不支持 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}] [${clientId}] 种子缺少 hash,跳过限速: ${torrentName}`)
              continue
            }

            if (!addedTime) {
              limitStats.skipped++
              logger.error(`[${SCRIPT_NAME}] [${clientId}] 种子缺少添加时间,跳过限速: ${torrentName}`)
              continue
            }

            const isUnderDelay = (now - addedTime < CONFIG.resumeDelay)
            const currentUpLimit = getCurrentUpLimit(torrent)
            let targetLimit = null
            let targetLabel = ''
            let shouldPause = false

            if (isUnderDelay) {
              if (CONFIG.initialUploadLimit < 0) {
                shouldPause = true
                targetLabel = '初始暂停'
              } else {
                targetLimit = initialLimitByte
                targetLabel = CONFIG.initialUploadLimit === 0 ? '初始不限速' : '初始限速'
              }
            } else {
              targetLimit = standardLimitByte
              targetLabel = '标准限速'
            }

            if (shouldPause) {
              if (pausedStates.includes(torrent.state)) {
                limitStats.unchanged++
                continue
              }

              if (!canPauseTorrent) {
                logger.error(`[${SCRIPT_NAME}] [${clientId}] 下载器不支持 pauseTorrent,无法暂停: ${torrentName}`)
                limitStats.failed++
                continue
              }

              try {
                await client.pauseTorrent(torrent.hash)
                limitStats.paused++
                debugLog(`[${clientId}] 初始期暂停种子成功: ${torrentName}`)
              } catch (e) {
                limitStats.failed++
                logger.error(`[${SCRIPT_NAME}] [${clientId}] 初始期暂停种子失败: ${torrentName} | ${formatError(e)}`)
              }
              continue
            }

            if (targetLimit === null) {
              limitStats.skipped++
              continue
            }

            if ((CONFIG.initialUploadLimit <= 0 || isUnderDelay) && pausedStates.includes(torrent.state)) {
              if (typeof client.resumeTorrent !== 'function') {
                logger.error(`[${SCRIPT_NAME}] [${clientId}] 下载器不支持 resumeTorrent,无法恢复: ${torrentName}`)
                limitStats.failed++
              } else {
                try {
                  await client.resumeTorrent(torrent.hash)
                  limitStats.resumed++
                  debugLog(`[${clientId}] 恢复种子成功: ${torrentName}`)
                } catch (e) {
                  limitStats.failed++
                  logger.error(`[${SCRIPT_NAME}] [${clientId}] 恢复种子失败: ${torrentName} | ${formatError(e)}`)
                }
              }
            }

            if (currentUpLimit === targetLimit) {
              limitStats.unchanged++
              continue
            }

            if (!canSetSpeedLimit) {
              limitStats.failed++
              continue
            }

            try {
              await client.setSpeedLimit(torrent.hash, 'upload', targetLimit)
              if (isUnderDelay) {
                limitStats.initialSet++
              } else {
                limitStats.standardSet++
              }
              debugLog(`[${clientId}] 设置${targetLabel}成功: ${torrentName} | ${formatSpeed(currentUpLimit)} -> ${formatSpeed(targetLimit)}`)
            } catch (e) {
              limitStats.failed++
              logger.error(`[${SCRIPT_NAME}] [${clientId}] 设置${targetLabel}失败: ${torrentName} | ${formatSpeed(currentUpLimit)} -> ${formatSpeed(targetLimit)} | ${formatError(e)}`)
            }
          }

          debugLog(`[${clientId}] 限速汇总: 初始限速${limitStats.initialSet},标准限速${limitStats.standardSet},暂停${limitStats.paused},已符合${limitStats.unchanged},恢复${limitStats.resumed},跳过${limitStats.skipped},失败${limitStats.failed}`)
        } else {
          debugLog(`[${clientId}] 剩余空间 ${formatSize(currentFreeSpace)} <= 恐慌线 ${formatSize(CONFIG.panicSpace)},跳过恢复与限速`)
        }
      } catch (e) {
        logger.error(`[${SCRIPT_NAME}] [${clientId}] 处理异常: ${formatError(e)}`)
      }
    }

    debugLog('本轮运行完成')
  } catch (e) {
    logger.error(`[${SCRIPT_NAME}] 脚本运行异常: ${formatError(e)}`)
  }
}

WEBUI的Mediainfo/BDinfo截图工具 2026-05-14