在使用 qBittorrent 的 RSS 订阅功能后,可以实现自动下载当前热门的新种子来赚上传量

但 QBitorrent 的自动删除的功能比较鸡肋,只有分享率大于某个值或者做种时间超过某个值时自动删除

抱着赚取上传量的念头这两个都不是很合适,查阅了资料,发现可以借助 Golang 的代码来调用 QBitorrent 的 API 来实现自定义删除规则

我需要的规则如下:

  • 当种子添加时间少于7天则不进行判定
  • 每次检查都要与上一次检查时留存的分享率进行对比,若低于某个值,则删除该种子以及种子所下载的文件

实现后,已将代码和打包好的二进制程序发布到 github ,仓库如下:

使用方法是从 Releases 中下载系统对应的客户端版本,然后执行如下:

qBittorrent-manager --username=admin --password=admin

在 crontab 中设置循环 7 天执行,则每 7 天检查一次上传率的增长情况,若当前轮次增长少于 0.5 则删除该种子以及种子所下载的文件

代码逻辑如下:

package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"net/http"
	"net/http/cookiejar"
	"os"
	"time"
)

// Torrent 结构体定义
type Torrent struct {
	Hash  string  `json:"hash"`
	Name  string  `json:"name"`
	Ratio float64 `json:"ratio"`
	Addon int64   `json:"added_on"`
}

// 登录并获取会话 cookie
func login(client *http.Client, qbURL, username, password string) error {
	loginData := fmt.Sprintf("username=%s&password=%s", username, password)
	req, err := http.NewRequest("POST", qbURL+"/api/v2/auth/login", bytes.NewBufferString(loginData))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("failed to login, status code: %d", resp.StatusCode)
	}

	fmt.Println("Logged in successfully.")
	return nil
}

// 获取当前所有种子的状态
func getTorrents(client *http.Client, qbURL string) ([]Torrent, error) {
	req, err := http.NewRequest("GET", qbURL+"/api/v2/torrents/info", nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("failed to fetch torrents info, status code: %d", resp.StatusCode)
	}

	var torrents []Torrent
	if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
		return nil, err
	}

	fmt.Println("Torrents information fetched successfully.")
	return torrents, nil
}

// 删除种子
func deleteTorrent(client *http.Client, qbURL, hash string) error {
	data := fmt.Sprintf("hashes=%s&deleteFiles=true", hash)
	req, err := http.NewRequest("POST", qbURL+"/api/v2/torrents/delete", bytes.NewBufferString(data))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("failed to delete torrent, status code: %d", resp.StatusCode)
	}

	return nil
}

func searchTorrentOnLocal(hash, recordFile string) (Torrent, error) {
	var torrent Torrent
	var torrents []Torrent
	if _, err := os.Stat(recordFile); os.IsNotExist(err) {
		fmt.Println("No existing torrents found. Starting fresh.")
		return torrent, err
	}

	data, err := os.ReadFile(recordFile)
	if err != nil {
		return torrent, err
	}

	if err := json.Unmarshal(data, &torrents); err != nil {
		return torrent, err
	}

	for _, torrent := range torrents {
		if torrent.Hash == hash {
			return torrent, nil
		}
	}

	return torrent, errors.New("hash Not Found")
}

// 保存记录
func saveTorrentToLocal(torrents []Torrent, recordFile string) error {
	data, err := json.MarshalIndent(torrents, "", "  ")
	if err != nil {
		return err
	}

	if err := os.WriteFile(recordFile, data, 0644); err != nil {
		return err
	}

	fmt.Printf("Records saved to %s.\n", recordFile)
	return nil
}

func main() {
	// 定义命令行参数
	qbURL := flag.String("url", "http://localhost:8080", "The URL of the qBittorrent Web UI")
	username := flag.String("username", "admin", "The username for qBittorrent Web UI")
	password := flag.String("password", "adminadmin", "The password for qBittorrent Web UI")
	recordFile := flag.String("recordFile", "torrent-records.json", "The file to save torrent records")
	ratioIncrease := flag.Float64("ratioIncrease", 0.5, "The radio increases each time")
	protectionPeriod := flag.Int("protectionPeriod", 7, "The protection period for torrent")
	try := flag.Bool("try", false, "Display the deletion target without actually deleting the torrent")

	// 解析命令行参数
	flag.Parse()

	// 创建一个 cookie jar 来管理会话
	jar, err := cookiejar.New(nil)
	if err != nil {
		fmt.Printf("Error creating cookie jar: %v\n", err)
		return
	}

	// 创建 HTTP 客户端并设置 cookie jar
	client := &http.Client{
		Jar: jar,
	}

	// 执行登录
	if err := login(client, *qbURL, *username, *password); err != nil {
		fmt.Printf("Error: %v\n", err)
		return
	}

	// 获取种子信息
	torrents, err := getTorrents(client, *qbURL)
	if err != nil {
		fmt.Printf("Error: %v\n", err)
		return
	}

	// 检查上传率
	nowTime := time.Now()
	for _, torrent := range torrents {
		fmt.Printf("Processing torrent <%s>\n", torrent.Name)
		if record, err := searchTorrentOnLocal(torrent.Hash, *recordFile); err != nil {
			// 种子在本地没有记录则跳过
			fmt.Printf("Torrent <%s> name not found\n", torrent.Name)
			continue
		} else {
			// 保护期内的种子不会被删除
			addTime := time.Unix(torrent.Addon, 0)
			daysDiffDays := int(nowTime.Sub(addTime).Hours() / 24)
			if daysDiffDays < *protectionPeriod {
				fmt.Printf("Torrent <%s> not more than %d days\n", torrent.Name, *protectionPeriod)
				continue
			}

			// 比对分享率增长
			fmt.Printf("Torrent <%s> local record ratio %.2f and torrent current radio %.2f\n", record.Name, record.Ratio, torrent.Ratio)

			if torrent.Ratio-record.Ratio < *ratioIncrease {
				// 删除分享率上升不足的种子
				fmt.Printf("Deleting torrent <%s> due to insufficient share ratio increase(radio increase %.2f).\n", torrent.Name, torrent.Ratio-record.Ratio)
				if err := deleteTorrent(client, *qbURL, torrent.Hash); err != nil && !*try {
					fmt.Printf("Failed to delete torrent %s error: %v\n", torrent.Name, err)
				}
			}
		}
	}

	// 保存本次查询结果,方便下次查询比对分享率的增长情况
	if err := saveTorrentToLocal(torrents, *recordFile); err != nil {
		fmt.Printf("Error saving torrents to local file: %v\n", err)
		return
	}

	fmt.Println("All records processed and updated.")
}