├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── config.example ├── go.mod ├── go.sum ├── http │ └── main.go ├── main.go └── util │ ├── jsonparse.go │ ├── notify.go │ ├── request.go │ ├── thread.go │ └── util.go ├── go-build.sh ├── install-qbrs.sh └── uninstall-qbrs.sh /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: go build 2 | on: 3 | push: 4 | tags: 5 | - "v*.*.*" 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Setup Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: '1.20' 22 | 23 | - name: Build 24 | run: bash go-build.sh 25 | 26 | - name: Create Release and Upload Release Asset 27 | uses: softprops/action-gh-release@v1 28 | with: 29 | draft: false 30 | prerelease: false 31 | files: | 32 | app/build/* 33 | - name: Publish as tag 'latest' 34 | uses: softprops/action-gh-release@v1 35 | with: 36 | name: 'latest' 37 | tag_name: 'latest' 38 | draft: false 39 | prerelease: false 40 | files: | 41 | app/build/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | */**/config.env 2 | */**/build/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 durianice 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #### 如在使用中有任何问题,请先使用一键更新命令升级到最新版本~ 2 | 3 | ## 功能 4 | - 动态启动停止(硬盘使用xx时停止下载、占用小于xx时恢复下载) 5 | - 可选保种选项 6 | - 多线程上传到远程盘 7 | - Telegram机器人通知 8 | - 检查新版本 9 | 10 | ## 开始 11 | 客户端 qBittorrent v4.x.x 12 | 13 | 14 | 网盘挂载 rclone 15 | 16 | 17 | 远端存储 Google Drive / One Drive 18 | 19 | 20 | 本机 Ubuntu 20.04 / 2CPU / 1GB RAM / 硬盘40GB 21 | 22 | 23 | 支持的平台:[见发行版](https://github.com/durianice/qBittorrent-rclone-sync/releases) 24 | 25 | 26 | ## 安装/更新 27 | ``` 28 | sudo bash -c "$(curl -sL https://raw.githubusercontent.com/durianice/qBittorrent-rclone-sync/release/install-qbrs.sh)" 29 | ``` 30 | 31 | ## 卸载 32 | ``` 33 | sudo bash -c "$(curl -sL https://raw.githubusercontent.com/durianice/qBittorrent-rclone-sync/release/uninstall-qbrs.sh)" 34 | ``` 35 | 36 | ## 配置文件 37 | [config.example](https://github.com/durianice/qBittorrent-rclone-sync/blob/release/app/config.example) 38 | 39 | ### 分类 40 | - 启动程序会自动创建 "_电影"、"_电视节目" 这两个分类 41 | - 传入自定义分类:如“合集”,对应的保存目录则为 `env.RCLONE_LOCAL_DIR/env.RCLONE_REMOTE_DIR/合集/` 42 | 43 | 44 | **注意:请在新增下载时选择分类之一,否则不会自动同步~** 45 | 46 | ### 标签 47 | 想保留本地资源用于做种,给下载任务添加**保种**标签 48 | 49 | ## 本地开发&手动编译 50 | ``` 51 | git clone -b release https://github.com/durianice/qBittorrent-rclone-sync.git 52 | sudo bash go-build.sh 53 | ``` 54 | 55 | 56 | ## Todo 57 | - [ ] qBittorrent自动打标签 58 | - [x] 按qBittorrent分类来分目录上传保存路径 59 | - [ ] 更多的自定义配置 60 | - [ ] ... 61 | 62 | -------------------------------------------------------------------------------- /app/config.example: -------------------------------------------------------------------------------- 1 | # qbittorrent路径(浏览器访问的真实地址,ip:端口 结尾不用 "/") 2 | QBIT_URL="http://127.0.0.1:8080" 3 | 4 | QBIT_USER="admin" 5 | QBIT_PASSWD="adminadmin" 6 | 7 | # 挂载的磁盘 (df -h 查看 Mounted on 挂载的目录) 8 | DISK_LOCAL="/" 9 | 10 | # rclone挂载名称 11 | RCLONE_NAME="gdrive:" 12 | 13 | # rclone本地挂载目录 14 | RCLONE_LOCAL_DIR="/GoogleDrive" 15 | 16 | # 远程资源保存路径(以 "/" 结尾)[请在云盘先手动创建这个文件夹] 17 | RCLONE_REMOTE_DIR="/media/" 18 | 19 | # rclone命令参数(一般不需要改) 20 | MULTI_THREAD_STREAMS="10" 21 | 22 | # 程序运行(错误)日志(一般不需要改) 23 | LOG_FILE="/root/qbit_rclone_sync.log" 24 | 25 | # rclone同步到远程并发线程数(根据实际情况修改,CPU不行就改小) 26 | THREAD=3 27 | 28 | # 内存控制 29 | # VPS内存占用大于等于这个值时停止下载 30 | MAX_MEM=90% 31 | # VPS内存占用小于这个值时恢复下载 32 | MIN_MEM=40% 33 | 34 | # Telegram Bot通知 35 | CHAT_ID="123456" 36 | BOT_TOKEN="123456:abcdef" 37 | 38 | # 非 Docker 版本下载器直接跳过,保持默认 39 | # 本地 qbittorrent 下载读取绝对路径(Docker版可以在这里指定读取目录) 40 | # 请用绝对路径,如:"/qb/downloads",结尾不用 "/" 41 | QBIT_DIR="" 42 | -------------------------------------------------------------------------------- /app/go.mod: -------------------------------------------------------------------------------- 1 | module qbittorrentRcloneSync 2 | 3 | go 1.20 4 | 5 | require golang.org/x/net v0.14.0 6 | 7 | require github.com/joho/godotenv v1.5.1 8 | -------------------------------------------------------------------------------- /app/go.sum: -------------------------------------------------------------------------------- 1 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 2 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 3 | golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= 4 | golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= 5 | -------------------------------------------------------------------------------- /app/http/main.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "qbittorrentRcloneSync/util" 7 | ) 8 | 9 | var ( 10 | host string 11 | user string 12 | password string 13 | ) 14 | 15 | func getHost() string { 16 | if host == "" { 17 | host = os.Getenv("QBIT_URL") 18 | } 19 | return host 20 | } 21 | 22 | func getUser() string { 23 | if user == "" { 24 | user = os.Getenv("QBIT_USER") 25 | } 26 | return user 27 | } 28 | 29 | func getPasswd() string { 30 | if password == "" { 31 | password = os.Getenv("QBIT_PASSWD") 32 | } 33 | return password 34 | } 35 | 36 | func Login() { 37 | url := getHost() + "/api/v2/auth/login" 38 | h := make(map[string]string) 39 | h["Referer"] = getHost() 40 | p := make(map[string]string) 41 | p["username"] = getUser() 42 | p["password"] = getPasswd() 43 | res, _ := util.PostForm(url, h, p) 44 | if res == "Fails." { 45 | log.Fatal("登录失败") 46 | } 47 | } 48 | 49 | func GetInfo() ([]map[string]interface{}) { 50 | url := getHost() + "/api/v2/torrents/info" 51 | h := make(map[string]string) 52 | h["Referer"] = getHost() 53 | p := make(map[string]interface{}) 54 | res, _ := util.Get(url, h, p) 55 | list := util.ParseJsonStr(res) 56 | return list 57 | } 58 | 59 | func GetDetail(hash string) ([]map[string]interface{}) { 60 | url := getHost() + "/api/v2/torrents/files" 61 | h := make(map[string]string) 62 | h["Referer"] = getHost() 63 | p := make(map[string]interface{}) 64 | p["hash"] = hash 65 | res, _ := util.Get(url, h, p) 66 | list := util.ParseJsonStr(res) 67 | return list 68 | } 69 | 70 | func Resume(hash string) { 71 | url := getHost() + "/api/v2/torrents/resume" 72 | h := make(map[string]string) 73 | h["Referer"] = getHost() 74 | p := make(map[string]string) 75 | p["hashes"] = hash 76 | _, err := util.PostForm(url, h, p) 77 | if err != nil { 78 | log.Fatal("恢复下载失败") 79 | } 80 | } 81 | 82 | func Pause(hash string) { 83 | url := getHost() + "/api/v2/torrents/pause" 84 | h := make(map[string]string) 85 | h["Referer"] = getHost() 86 | p := make(map[string]string) 87 | p["hashes"] = hash 88 | _, err := util.PostForm(url, h, p) 89 | if err != nil { 90 | log.Fatal("暂停下载失败") 91 | } 92 | } 93 | 94 | func ToggleSequentialDownload(hash string) { 95 | url := getHost() + "/api/v2/torrents/toggleSequentialDownload" 96 | h := make(map[string]string) 97 | h["Referer"] = getHost() 98 | p := make(map[string]string) 99 | p["hashes"] = hash 100 | _, err := util.PostForm(url, h, p) 101 | if err != nil { 102 | log.Fatal("切换下载类型失败") 103 | } 104 | } 105 | 106 | func AddTags(hash string, tags string) { 107 | url := getHost() + "/api/v2/torrents/addTags" 108 | h := make(map[string]string) 109 | h["Referer"] = getHost() 110 | p := make(map[string]string) 111 | p["hashes"] = hash 112 | p["tags"] = tags 113 | _, err := util.PostForm(url, h, p) 114 | if err != nil { 115 | log.Fatal("添加标签失败") 116 | } 117 | } 118 | 119 | func CreateCategory(category string, savePath string) { 120 | url := getHost() + "/api/v2/torrents/createCategory" 121 | h := make(map[string]string) 122 | h["Referer"] = getHost() 123 | p := make(map[string]string) 124 | p["category"] = category 125 | p["savePath"] = savePath 126 | _, err := util.PostForm(url, h, p) 127 | if err != nil { 128 | log.Fatal("创建分类失败或分类已存在") 129 | } 130 | } 131 | 132 | func DeleteTorrents(hashes string) { 133 | url := getHost() + "/api/v2/torrents/delete" 134 | h := make(map[string]string) 135 | h["Referer"] = getHost() 136 | p := make(map[string]string) 137 | p["hashes"] = hashes 138 | p["deleteFiles"] = "true" 139 | _, err := util.PostForm(url, h, p) 140 | if err != nil { 141 | log.Fatal("删除失败") 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "qbittorrentRcloneSync/http" 7 | "qbittorrentRcloneSync/util" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/joho/godotenv" 13 | ) 14 | 15 | var ( 16 | RCLONE_NAME string 17 | RCLONE_LOCAL_DIR string 18 | RCLONE_REMOTE_DIR string 19 | MULTI_THREAD_STREAMS string 20 | LOG_FILE string 21 | THREAD string 22 | DISK_LOCAL string 23 | MAX_MEM string 24 | MIN_MEM string 25 | QBIT_DIR string 26 | ) 27 | 28 | const CATEGORY_1 = "_电影" 29 | const CATEGORY_2 = "_电视节目" 30 | const STAY_TAG = "保种" 31 | const CTRL_TAG = "脚本控制" 32 | 33 | const currentVersion = "v2.0.2" 34 | 35 | var qBitList []map[string]interface{} 36 | 37 | func rcloneTask(sourceFile string, targetFile string, keepSourceFile bool, syncMsg string) error { 38 | option := "moveto" 39 | if keepSourceFile { 40 | option = "copyto" 41 | } 42 | log_level := "ERROR" 43 | // %s%s%s 防止路径中有全角字符,使用%q会转换为Unicode 44 | command := fmt.Sprintf("/usr/bin/rclone -P %s --multi-thread-streams %s --log-file %q --log-level %q %s%s%s %s%s%s", option, MULTI_THREAD_STREAMS, LOG_FILE, log_level, "\"", sourceFile, "\"", "\"", targetFile, "\"") 45 | util.Notify(fmt.Sprintf("🍪 正在你的小鸡上执行\n%s\n", command), "") 46 | err := util.RunRcloneCommand(command, syncMsg, sourceFile) 47 | if err != nil { 48 | return err 49 | } 50 | return nil 51 | 52 | } 53 | 54 | func memoryControl() string { 55 | used := util.GetUsedSpacePercentage(DISK_LOCAL) 56 | res, _ := util.PercentageToDecimal(used) 57 | MAX, _ := util.PercentageToDecimal(MAX_MEM) 58 | MIN, _ := util.PercentageToDecimal(MIN_MEM) 59 | if res >= MAX { 60 | return "P" 61 | } 62 | if res < MIN { 63 | return "D" 64 | } 65 | return "N" 66 | } 67 | 68 | func getList() []map[string]interface{} { 69 | http.Login() 70 | list := http.GetInfo() 71 | // 按标签过滤 72 | inCtrlList := util.Filter(list, func(obj map[string]interface{}) bool { 73 | dir := obj["content_path"].(string) 74 | progress := obj["progress"].(float64) 75 | isEmpty, err := util.CheckPathStatus(dir) 76 | if err != nil { 77 | fmt.Println(err) 78 | } 79 | if isEmpty && progress == 1 { 80 | http.DeleteTorrents(obj["hash"].(string)) 81 | util.Notify(fmt.Sprintf("%v\n😁 这个同步完了,删除本地空目录和torrents任务\n", dir), "") 82 | } 83 | return strings.Contains(obj["tags"].(string), CTRL_TAG) || strings.Contains(obj["category"].(string), CATEGORY_1) || strings.Contains(obj["category"].(string), CATEGORY_2) || obj["category"].(string) != "" 84 | }) 85 | res := util.Map(inCtrlList, func(obj map[string]interface{}) map[string]interface{} { 86 | name, _ := obj["name"].(string) 87 | hash, _ := obj["hash"].(string) 88 | tags, _ := obj["tags"].(string) 89 | category, _ := obj["category"].(string) 90 | seqDl, _ := obj["seq_dl"].(bool) 91 | state, _ := obj["state"].(string) 92 | downloadPath, _ := obj["download_path"].(string) 93 | contentPath, _ := obj["content_path"].(string) 94 | savePath, _ := obj["save_path"].(string) 95 | // 过滤已下载完成的子内容 96 | subListDownloaded := util.Filter(http.GetDetail(hash), func(obj map[string]interface{}) bool { 97 | return obj["progress"].(float64) == 1 98 | }) 99 | subListDownloaded = util.Map(subListDownloaded, func(subObj map[string]interface{}) map[string]interface{} { 100 | subName, _ := subObj["name"].(string) 101 | return map[string]interface{}{ 102 | "name": name, 103 | "subName": subName, 104 | "hash": hash, 105 | "tags": tags, 106 | "category": category, 107 | "seqDl": seqDl, 108 | "state": state, 109 | "downloadPath": downloadPath, 110 | "savePath": savePath, 111 | "contentPath": contentPath, 112 | } 113 | }) 114 | memState := memoryControl() 115 | if memState == "P" && state == "downloading" { 116 | util.Notify("🤢 内存不够了暂停一下先", "") 117 | http.Pause(hash) 118 | } 119 | if memState == "D" && state == "pausedDL" { 120 | util.Notify("😸 元气满满,恢复下载", "") 121 | http.Resume(hash) 122 | } 123 | if !seqDl { 124 | http.ToggleSequentialDownload(hash) 125 | util.Notify("🥶 已强制按顺序下载,不然鸡爆了", "") 126 | } 127 | return map[string]interface{}{ 128 | "subListDownloaded": subListDownloaded, 129 | } 130 | }) 131 | var r []map[string]interface{} 132 | for _, obj := range res { 133 | subListDownloaded, _ := obj["subListDownloaded"].([]map[string]interface{}) 134 | r = append(r, subListDownloaded...) 135 | } 136 | return r 137 | } 138 | 139 | func mainTask(index int, obj map[string]interface{}) { 140 | total := len(qBitList) 141 | contentPath, _ := obj["contentPath"].(string) 142 | isEmpty, _ := util.CheckPathStatus(contentPath) 143 | if isEmpty { 144 | util.Notify(fmt.Sprintf("%v\n😓 文件不在了或者目录为空,下一个", contentPath), "") 145 | return 146 | } 147 | 148 | name, _ := obj["name"].(string) 149 | tags, _ := obj["tags"].(string) 150 | category, _ := obj["category"].(string) 151 | downloadPath, _ := obj["downloadPath"].(string) 152 | if QBIT_DIR != "" { 153 | downloadPath = QBIT_DIR 154 | } 155 | savePath, _ := obj["savePath"].(string) 156 | subName, _ := obj["subName"].(string) 157 | sourcePath := downloadPath + "/" + subName 158 | targetPath := RCLONE_NAME + RCLONE_REMOTE_DIR + category2Path(category) + subName 159 | localTargetPath := RCLONE_LOCAL_DIR + RCLONE_REMOTE_DIR + category2Path(category) + subName 160 | if !util.FileExists(sourcePath) { 161 | sourcePath = savePath + "/" + subName 162 | if !util.FileExists(sourcePath) { 163 | // util.Notify(fmt.Sprintf("%v\n未找到或已同步该资源", sourcePath), "") 164 | return 165 | } 166 | } 167 | if util.FileExists(localTargetPath) { 168 | if util.FileExists(sourcePath) { 169 | if strings.Contains(tags, STAY_TAG) { 170 | util.Notify(fmt.Sprintf("%v\n😵‍💫 同步过了,保下种", sourcePath), "") 171 | } else { 172 | command := fmt.Sprintf("sudo rm %q", sourcePath) 173 | util.RunShellCommand(command) 174 | util.Notify(fmt.Sprintf("%v\n😅 同步过了,不保种,删了", sourcePath), "") 175 | } 176 | } 177 | return 178 | } 179 | syncMsg := fmt.Sprintf("🤡 在同步了 (%v/%v)\n%v\n%v", index+1, total, name, subName) 180 | err := rcloneTask(sourcePath, targetPath, strings.Contains(tags, STAY_TAG), syncMsg) 181 | if err != nil { 182 | util.Notify(fmt.Sprintf("🥵 同步错误 (%v/%v)\n%v\n%v \n错误原因 %v", index+1, total, name, subName, err), "") 183 | return 184 | } 185 | } 186 | 187 | func initConfig() { 188 | err := godotenv.Load(util.GetRealAbsolutePath() + "/config.env") 189 | if err != nil { 190 | panic(err) 191 | } 192 | RCLONE_NAME = os.Getenv("RCLONE_NAME") 193 | RCLONE_LOCAL_DIR = os.Getenv("RCLONE_LOCAL_DIR") 194 | RCLONE_REMOTE_DIR = os.Getenv("RCLONE_REMOTE_DIR") 195 | MULTI_THREAD_STREAMS = os.Getenv("MULTI_THREAD_STREAMS") 196 | LOG_FILE = os.Getenv("LOG_FILE") 197 | THREAD = os.Getenv("THREAD") 198 | DISK_LOCAL = os.Getenv("DISK_LOCAL") 199 | MAX_MEM = os.Getenv("MAX_MEM") 200 | MIN_MEM = os.Getenv("MIN_MEM") 201 | QBIT_DIR = os.Getenv("QBIT_DIR") 202 | } 203 | 204 | func category2Path(category string) string { 205 | if category == CATEGORY_1 { 206 | return "movie/" 207 | } else if category == CATEGORY_2 { 208 | return "tv/" 209 | } else { 210 | return util.Trim(category) + "/" 211 | } 212 | } 213 | 214 | func checkVersion() { 215 | owner := "durianice" 216 | repo := "qBittorrent-rclone-sync" 217 | 218 | latestVersion, err := util.GetLatestRelease(owner, repo) 219 | if err != nil { 220 | util.Notify(fmt.Sprintf("🤯 获取版本信息失败 %s", err), "") 221 | return 222 | } 223 | 224 | outdated, err := util.IsVersionOutdated(currentVersion, latestVersion) 225 | if err != nil { 226 | fmt.Printf("版本信息比较失败: %s\n", err) 227 | return 228 | } 229 | if outdated { 230 | util.Notify(fmt.Sprintf("😆 发现新的版本 %s\n当前版本 %s\n", latestVersion, currentVersion), "") 231 | for _, obj := range qBitList { 232 | http.Pause(obj["hash"].(string)) 233 | } 234 | url := "https://github.com/durianice/qBittorrent-rclone-sync" 235 | util.Notify(fmt.Sprintf("😄 已暂停全部下载,请手动更新程序\n\n👀 前往更新", url), "") 236 | os.Exit(0) 237 | } else { 238 | util.Notify(fmt.Sprintf("😄 当前为最新版本 %s", latestVersion), "") 239 | } 240 | } 241 | 242 | func monitorTask(ticker *time.Ticker) { 243 | defer ticker.Stop() 244 | for range ticker.C { 245 | qBitList := getList() 246 | util.Notify(fmt.Sprintf("🤖 查询到 %v 个已下载文件", len(qBitList)), "") 247 | util.Notify(fmt.Sprintf("🫣 小鸡已用空间:%s ", util.GetUsedSpacePercentage(DISK_LOCAL)), "") 248 | util.Notify(fmt.Sprintf("📌 网盘已用空间:%s ", util.GetUsedSpacePercentage(RCLONE_LOCAL_DIR)), "") 249 | } 250 | } 251 | 252 | func restartSelf() { 253 | util.Notify("🍉 10s后重启程序", "you are perfect") 254 | time.Sleep(10 * time.Second) 255 | output, err := util.RunShellCommand("systemctl restart qbrs") 256 | if err != nil { 257 | util.Notify(fmt.Sprintf("🌚 qbrs重启失败 %s", err), "") 258 | } else { 259 | util.Notify(fmt.Sprintf("🍄 已重启qbrs %s", output), "") 260 | } 261 | os.Exit(0) 262 | } 263 | 264 | func main() { 265 | util.Env() 266 | initConfig() 267 | 268 | util.Notify("🤠 欢迎使用", "") 269 | checkVersion() 270 | 271 | util.CreateFileIfNotExist(LOG_FILE) 272 | qBitList = getList() 273 | http.CreateCategory(CATEGORY_1, "") 274 | http.CreateCategory(CATEGORY_2, "") 275 | 276 | ticker := time.NewTicker(55 * time.Second) 277 | go monitorTask(ticker) 278 | 279 | THREAD, err := strconv.Atoi(THREAD) 280 | if err != nil { 281 | panic("Error converting THREAD to int") 282 | } 283 | pool := util.NewGoroutinePool(THREAD) 284 | for index, obj := range qBitList { 285 | i := index 286 | o := obj 287 | pool.Add(func() { 288 | mainTask(i, o) 289 | // util.Notify(fmt.Sprintf("%v %v", i, o), "") 290 | }) 291 | } 292 | pool.Wait() 293 | // watching 294 | for { 295 | qBitList = getList() 296 | if len(qBitList) != 0 { 297 | restartSelf() 298 | break 299 | } 300 | util.Notify("💤💤💤 暂无下载任务", "") 301 | time.Sleep(60 * time.Second) 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /app/util/jsonparse.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | type JSONParser struct { 9 | data map[string]interface{} 10 | } 11 | 12 | func NewJSONParser() *JSONParser { 13 | return &JSONParser{} 14 | } 15 | 16 | func (jp *JSONParser) Parse(jsonStr string) error { 17 | err := json.Unmarshal([]byte(jsonStr), &jp.data) 18 | if err != nil { 19 | return err 20 | } 21 | return nil 22 | } 23 | 24 | func (jp *JSONParser) Get(keys ...string) (interface{}, error) { 25 | var targetMap map[string]interface{} = jp.data 26 | for _, key := range keys { 27 | value, ok := targetMap[key] 28 | if !ok { 29 | return nil, fmt.Errorf("key '%s' not found", key) 30 | } 31 | 32 | if m, ok := value.(map[string]interface{}); ok { 33 | targetMap = m 34 | } else { 35 | return value, nil 36 | } 37 | } 38 | return targetMap, nil 39 | } 40 | 41 | -------------------------------------------------------------------------------- /app/util/notify.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | ) 8 | 9 | type notifyMap map[string]interface{} 10 | 11 | var notify notifyMap 12 | 13 | func init() { 14 | notify = make(notifyMap) 15 | } 16 | 17 | func Notify(msg string, _type string) { 18 | 19 | fmt.Printf("%v\n", msg) 20 | msg = "[" + time.Now().Format("2006-01-02 15:04:05") + "]\n\n" + msg 21 | if _type != "" && notify[_type] != nil && notify[_type] != "" { 22 | editTgBotMessage(msg, notify[_type]) 23 | return 24 | } 25 | res := sendTgBotMessage(msg) 26 | parser := JSONParser{} 27 | err := parser.Parse(res) 28 | if err != nil { 29 | fmt.Println("解析 TG MSG JSON 失败:", err) 30 | return 31 | } 32 | message_id, err := parser.Get("result", "message_id") 33 | if err != nil { 34 | fmt.Println("获取message_id失败:", err) 35 | } 36 | if _type != "" { 37 | // fmt.Println("message_id:", message_id) 38 | notify[_type] = message_id 39 | } else { 40 | go func() { 41 | time.Sleep(30 * time.Second) 42 | deleteTgBotMessage(message_id) 43 | }() 44 | } 45 | } 46 | 47 | func DeleteMsg(_type string) { 48 | if notify[_type] != nil && notify[_type] != "" { 49 | deleteTgBotMessage(notify[_type]) 50 | } 51 | } 52 | 53 | func sendTgBotMessage(msg string) string { 54 | url := "https://api.telegram.org/bot" + os.Getenv("BOT_TOKEN") + "/sendMessage" 55 | h := make(map[string]string) 56 | p := make(map[string]interface{}) 57 | p["chat_id"] = os.Getenv("CHAT_ID") 58 | p["text"] = msg 59 | p["parse_mode"] = "html" 60 | res, err := Post(url, h, p) 61 | if err != nil { 62 | fmt.Println("sendTgBotMessage Error", res) 63 | return "" 64 | } 65 | return res 66 | } 67 | 68 | func editTgBotMessage(msg string, id interface{}) bool { 69 | url := "https://api.telegram.org/bot" + os.Getenv("BOT_TOKEN") + "/editMessageText" 70 | h := make(map[string]string) 71 | p := make(map[string]interface{}) 72 | p["chat_id"] = os.Getenv("CHAT_ID") 73 | p["message_id"] = id 74 | p["text"] = msg 75 | res, err := Post(url, h, p) 76 | if err != nil { 77 | fmt.Println("editTgBotMessage Error", res) 78 | return false 79 | } 80 | parser := JSONParser{} 81 | parseErr := parser.Parse(res) 82 | if parseErr != nil { 83 | fmt.Println("解析 TG MSG JSON 失败:", err) 84 | return false 85 | } 86 | ok, msgErr := parser.Get("ok") 87 | if msgErr != nil { 88 | fmt.Println("解析 TG MSG JSON 失败:", err) 89 | return false 90 | } 91 | return ok.(bool) 92 | } 93 | 94 | func deleteTgBotMessage(id interface{}) bool { 95 | url := "https://api.telegram.org/bot" + os.Getenv("BOT_TOKEN") + "/deletemessage" 96 | h := make(map[string]string) 97 | p := make(map[string]interface{}) 98 | p["chat_id"] = os.Getenv("CHAT_ID") 99 | p["message_id"] = id 100 | res, err := Post(url, h, p) 101 | if err != nil { 102 | fmt.Println("deleteTgBotMessage Error", res) 103 | return false 104 | } 105 | parser := JSONParser{} 106 | parseErr := parser.Parse(res) 107 | if parseErr != nil { 108 | fmt.Println("解析 TG MSG JSON 失败:", err) 109 | return false 110 | } 111 | ok, msgErr := parser.Get("ok") 112 | if msgErr != nil { 113 | fmt.Println("解析 TG MSG JSON 失败:", err) 114 | return false 115 | } 116 | return ok.(bool) 117 | } 118 | -------------------------------------------------------------------------------- /app/util/request.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "bytes" 6 | "crypto/tls" 7 | "encoding/json" 8 | "io" 9 | "net/http" 10 | "time" 11 | netUrl "net/url" 12 | "net/http/cookiejar" 13 | "golang.org/x/net/publicsuffix" 14 | ) 15 | 16 | var client *http.Client 17 | var jar, _ = cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) 18 | 19 | 20 | func init() { 21 | def := http.DefaultTransport 22 | defPot, ok := def.(*http.Transport) 23 | if !ok { 24 | panic("Init Request Error") 25 | } 26 | defPot.MaxIdleConns = 100 27 | defPot.MaxIdleConnsPerHost = 100 28 | defPot.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} 29 | client = &http.Client{ 30 | Timeout: time.Second * time.Duration(20), 31 | Transport: defPot, 32 | Jar: jar, 33 | } 34 | } 35 | 36 | func Get(url string, header map[string]string, params map[string]interface{}) (string, error) { 37 | req, err := http.NewRequest("GET", url, nil) 38 | if err != nil { 39 | fmt.Println(err) 40 | return "", err 41 | } 42 | 43 | req.Header.Set("Content-Type", "application/json") 44 | for key, value := range header { 45 | req.Header.Add(key, value) 46 | } 47 | 48 | query := req.URL.Query() 49 | if params != nil { 50 | for key, val := range params { 51 | v, _ := toString(val) 52 | query.Add(key, v) 53 | } 54 | req.URL.RawQuery = query.Encode() 55 | } 56 | 57 | resp, err := client.Do(req) 58 | if err != nil { 59 | fmt.Println(err) 60 | return "", err 61 | } 62 | defer resp.Body.Close() 63 | if resp.StatusCode != 200 { 64 | fmt.Println(err) 65 | return "", err 66 | } 67 | bodyBytes, err := io.ReadAll(resp.Body) 68 | if err != nil { 69 | fmt.Println(err) 70 | return "", err 71 | } 72 | return string(bodyBytes), nil 73 | } 74 | 75 | func Post(url string, header map[string]string, params map[string]interface{}) (string, error) { 76 | dd, _ := json.Marshal(params) 77 | re := bytes.NewReader(dd) 78 | req, err := http.NewRequest("POST", url, re) 79 | if err != nil { 80 | fmt.Println(err) 81 | return "", err 82 | } 83 | req.Header.Set("Content-Type", "application/json") 84 | for key, value := range header { 85 | req.Header.Add(key, value) 86 | } 87 | resp, err := client.Do(req) 88 | if err != nil { 89 | fmt.Println(err) 90 | return "", err 91 | } 92 | defer resp.Body.Close() 93 | bodyBytes, err := io.ReadAll(resp.Body) 94 | if err != nil { 95 | fmt.Println(err) 96 | return "", err 97 | } 98 | return string(bodyBytes), nil 99 | } 100 | 101 | func PostForm(url string, header map[string]string, params map[string]string) (string, error) { 102 | formValue := netUrl.Values{} 103 | for key, value := range params { 104 | strValue, _ := toString(value) 105 | formValue.Set(key, strValue) 106 | } 107 | req, err := http.NewRequest("POST", url, bytes.NewBufferString(formValue.Encode())) 108 | if err != nil { 109 | fmt.Println(err) 110 | return "", err 111 | } 112 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 113 | for key, value := range header { 114 | req.Header.Add(key, value) 115 | } 116 | resp, err := client.Do(req) 117 | if err != nil { 118 | fmt.Println(err) 119 | return "", err 120 | } 121 | defer resp.Body.Close() 122 | bodyBytes, err := io.ReadAll(resp.Body) 123 | if err != nil { 124 | fmt.Println(err) 125 | return "", err 126 | } 127 | return string(bodyBytes), nil 128 | } 129 | 130 | func PostMultipart(url string, header map[string]string, payload *bytes.Buffer) (string, error) { 131 | req, err := http.NewRequest("POST", url, payload) 132 | if err != nil { 133 | fmt.Println(err) 134 | return "", err 135 | } 136 | for key, value := range header { 137 | req.Header.Add(key, value) 138 | } 139 | resp, err := client.Do(req) 140 | if err != nil { 141 | fmt.Println(err) 142 | return "", err 143 | } 144 | defer resp.Body.Close() 145 | bodyBytes, err := io.ReadAll(resp.Body) 146 | if err != nil { 147 | fmt.Println(err) 148 | return "", err 149 | } 150 | return string(bodyBytes), nil 151 | } 152 | -------------------------------------------------------------------------------- /app/util/thread.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // GoroutinePool 是一个用于管理并发执行的goroutines的池 8 | type GoroutinePool struct { 9 | concurrency int 10 | semaphore chan struct{} 11 | wg sync.WaitGroup 12 | } 13 | 14 | // NewGoroutinePool 创建一个新的GoroutinePool 15 | func NewGoroutinePool(concurrency int) *GoroutinePool { 16 | return &GoroutinePool{ 17 | concurrency: concurrency, 18 | semaphore: make(chan struct{}, concurrency), 19 | } 20 | } 21 | 22 | // Add 将任务添加到池中并开始执行 23 | func (p *GoroutinePool) Add(task func()) { 24 | p.wg.Add(1) 25 | go func() { 26 | defer p.wg.Done() 27 | p.semaphore <- struct{}{} // 获取信号量资源 28 | defer func() { <-p.semaphore }() // 释放信号量资源 29 | task() 30 | }() 31 | } 32 | 33 | // Wait 阻塞直到所有任务完成 34 | func (p *GoroutinePool) Wait() { 35 | p.wg.Wait() 36 | } 37 | -------------------------------------------------------------------------------- /app/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "html/template" 9 | "io" 10 | "io/fs" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "reflect" 15 | "regexp" 16 | "runtime" 17 | "strconv" 18 | "strings" 19 | "time" 20 | ) 21 | 22 | func ParseJsonStr(jsonStr string) []map[string]interface{} { 23 | 24 | var unknownObjects []json.RawMessage 25 | 26 | err := json.Unmarshal([]byte(jsonStr), &unknownObjects) 27 | if err != nil { 28 | fmt.Println("Error:", err) 29 | return nil 30 | } 31 | 32 | var array []map[string]interface{} 33 | 34 | for _, rawMsg := range unknownObjects { 35 | var obj map[string]interface{} 36 | err := json.Unmarshal(rawMsg, &obj) 37 | if err != nil { 38 | fmt.Println("Error:", err) 39 | continue 40 | } 41 | 42 | array = append(array, obj) 43 | } 44 | 45 | // fmt.Println("Parsed objects:", array) 46 | 47 | return array 48 | } 49 | 50 | func RunShellCommand(command string) (string, error) { 51 | cmd := exec.Command("bash", "-c", command) 52 | 53 | output, err := cmd.CombinedOutput() 54 | if err != nil { 55 | fmt.Println("Error:", err) 56 | fmt.Println("Error cmd:", command) 57 | return "", err 58 | } 59 | return string(output), nil 60 | } 61 | 62 | func RunRcloneCommand(command string, syncMsg string, flag string) error { 63 | cmd := exec.Command("bash", "-c", command) 64 | stdoutPipe, err := cmd.StdoutPipe() 65 | if err != nil { 66 | return fmt.Errorf("创建输出管道失败:%s", err) 67 | } 68 | err = cmd.Start() 69 | if err != nil { 70 | return fmt.Errorf("启动命令失败:%s", err) 71 | } 72 | reader := bufio.NewReader(stdoutPipe) 73 | for { 74 | line, err := reader.ReadString('\n') 75 | if err != nil && err != io.EOF { 76 | return fmt.Errorf("读取命令输出失败:%s", err) 77 | } 78 | if strings.Contains(strings.ToLower(line), "error") || strings.Contains(strings.ToLower(line), "fail") { 79 | Notify(fmt.Sprintf("🥵 同步发生错误 %v \n", line), line) 80 | return errors.New(line) 81 | } 82 | if !strings.Contains(line, "ETA") { 83 | continue 84 | } 85 | syncProcess := getFormatOutput(line) 86 | if strings.Contains(syncProcess, "100%") { 87 | syncMsg = strings.ReplaceAll(syncMsg, "🤡 在同步了", "✅ 完成") 88 | } 89 | Notify(fmt.Sprintf("%v", syncMsg+"\n\n🎃 实时进度\n"+syncProcess), flag) 90 | if err == io.EOF || strings.Contains(syncProcess, "100%") { 91 | go func() { 92 | time.Sleep(60 * time.Second) 93 | DeleteMsg(flag) 94 | }() 95 | break 96 | } 97 | } 98 | err = cmd.Wait() 99 | if err != nil { 100 | return fmt.Errorf("命令执行失败:%s", err) 101 | } 102 | return nil 103 | } 104 | 105 | func getFormatOutput(input string) string { 106 | parts := strings.Split(input, "Transferred:") 107 | if len(parts) != 0 { 108 | re := regexp.MustCompile(`\s+`) 109 | trimmed := strings.ReplaceAll(parts[1], " ", "") 110 | trimmed = re.ReplaceAllString(trimmed, "") 111 | trimmed = strings.ReplaceAll(trimmed, "ETA", "预计剩余时间 ") 112 | return strings.ReplaceAll(trimmed, ",", "\n") 113 | } 114 | return "" 115 | } 116 | 117 | func Env() { 118 | switch runtime.GOOS { 119 | case "windows": 120 | panic("Windows not support") 121 | case "linux": 122 | fmt.Println("Running on Linux") 123 | case "darwin": 124 | panic("MacOS not support") 125 | default: 126 | panic("Current OS not support") 127 | } 128 | } 129 | 130 | func FileExists(filename string) bool { 131 | _, err := os.Stat(filename) 132 | if err == nil { 133 | return true 134 | } 135 | if os.IsNotExist(err) { 136 | return false 137 | } 138 | return false // 无法确定文件是否存在 139 | } 140 | 141 | func CreateDirIfNotExist(dirPath string) { 142 | if _, err := os.Stat(dirPath); os.IsNotExist(err) { 143 | err := os.MkdirAll(dirPath, 0755) 144 | if err != nil { 145 | fmt.Printf("Create '%v' Error : %v", dirPath, err) 146 | os.Exit(1) 147 | } 148 | fmt.Println("Directory created:", dirPath) 149 | } else { 150 | fmt.Println("Directory already exists:", dirPath) 151 | } 152 | } 153 | 154 | func CreateFileIfNotExist(filepath string) error { 155 | if _, err := os.Stat(filepath); os.IsNotExist(err) { 156 | // 文件不存在 157 | file, err := os.Create(filepath) 158 | if err != nil { 159 | // 创建文件时出错 160 | return err 161 | } 162 | defer file.Close() 163 | } 164 | // 文件已存在或已成功创建 165 | return nil 166 | } 167 | 168 | func GetFreeSpace(dir string, unit string) (int, error) { 169 | command := fmt.Sprintf("df --output=avail %v | tail -n 1", dir) 170 | freeSpaceKBStr, _ := RunShellCommand(command) 171 | freeSpaceKB := 0 172 | fmt.Sscanf(freeSpaceKBStr, "%d", &freeSpaceKB) 173 | 174 | switch unit { 175 | case "KB": 176 | return freeSpaceKB, nil 177 | case "MB": 178 | return freeSpaceKB / 1024, nil 179 | case "GB": 180 | return freeSpaceKB / 1024 / 1024, nil 181 | default: 182 | return 0, fmt.Errorf("unsupported unit: %s", unit) 183 | } 184 | } 185 | 186 | func GetUsedSpacePercentage(disk string) string { 187 | command := fmt.Sprintf("df --output=pcent %v | tail -n 1", disk) 188 | usedStr, _ := RunShellCommand(command) 189 | usedStr = strings.ReplaceAll(usedStr, " ", "") 190 | usedStr = strings.ReplaceAll(usedStr, "\n", "") 191 | return usedStr 192 | } 193 | 194 | func PercentageToDecimal(percentageStr string) (float64, error) { 195 | percentageStr = strings.ReplaceAll(percentageStr, " ", "") 196 | percentageStr = strings.ReplaceAll(percentageStr, "\n", "") 197 | percentageStr = strings.TrimRight(percentageStr, "%") 198 | percentage, err := strconv.ParseFloat(percentageStr, 64) 199 | if err != nil { 200 | fmt.Println(err) 201 | return 0, err 202 | } 203 | decimal := percentage / 100 204 | return decimal, nil 205 | } 206 | 207 | func MeasureExecutionTime(function func()) time.Duration { 208 | startTime := time.Now() 209 | function() 210 | elapsed := time.Since(startTime) 211 | return elapsed 212 | } 213 | 214 | func GetRealAbsolutePath() string { 215 | res, _ := RunShellCommand("pwd") 216 | res = strings.ReplaceAll(res, "\n", "") 217 | return res 218 | } 219 | 220 | func Filter[T any](array []T, condition func(T) bool) []T { 221 | var result []T 222 | for _, item := range array { 223 | if condition(item) { 224 | result = append(result, item) 225 | } 226 | } 227 | return result 228 | } 229 | 230 | func Map[T any](array []T, mapper func(T) T) []T { 231 | var result []T 232 | for _, item := range array { 233 | result = append(result, mapper(item)) 234 | } 235 | return result 236 | } 237 | 238 | func toString(i interface{}) (string, error) { 239 | i = indirectToStringerOrError(i) 240 | 241 | switch s := i.(type) { 242 | case string: 243 | return s, nil 244 | case bool: 245 | return strconv.FormatBool(s), nil 246 | case float64: 247 | return strconv.FormatFloat(s, 'f', -1, 64), nil 248 | case float32: 249 | return strconv.FormatFloat(float64(s), 'f', -1, 32), nil 250 | case int: 251 | return strconv.Itoa(s), nil 252 | case int64: 253 | return strconv.FormatInt(s, 10), nil 254 | case int32: 255 | return strconv.Itoa(int(s)), nil 256 | case int16: 257 | return strconv.FormatInt(int64(s), 10), nil 258 | case int8: 259 | return strconv.FormatInt(int64(s), 10), nil 260 | case uint: 261 | return strconv.FormatUint(uint64(s), 10), nil 262 | case uint64: 263 | return strconv.FormatUint(uint64(s), 10), nil 264 | case uint32: 265 | return strconv.FormatUint(uint64(s), 10), nil 266 | case uint16: 267 | return strconv.FormatUint(uint64(s), 10), nil 268 | case uint8: 269 | return strconv.FormatUint(uint64(s), 10), nil 270 | case []byte: 271 | return string(s), nil 272 | case template.HTML: 273 | return string(s), nil 274 | case template.URL: 275 | return string(s), nil 276 | case template.JS: 277 | return string(s), nil 278 | case template.CSS: 279 | return string(s), nil 280 | case template.HTMLAttr: 281 | return string(s), nil 282 | case nil: 283 | return "", nil 284 | case fmt.Stringer: 285 | return s.String(), nil 286 | case error: 287 | return s.Error(), nil 288 | default: 289 | return "", fmt.Errorf("unable to cast %#v of type %T to string", i, i) 290 | } 291 | } 292 | 293 | func indirectToStringerOrError(a interface{}) interface{} { 294 | if a == nil { 295 | return nil 296 | } 297 | 298 | var errorType = reflect.TypeOf((*error)(nil)).Elem() 299 | var fmtStringerType = reflect.TypeOf((*fmt.Stringer)(nil)).Elem() 300 | 301 | v := reflect.ValueOf(a) 302 | for !v.Type().Implements(fmtStringerType) && !v.Type().Implements(errorType) && v.Kind() == reflect.Ptr && !v.IsNil() { 303 | v = v.Elem() 304 | } 305 | return v.Interface() 306 | } 307 | 308 | type Release struct { 309 | TagName string `json:"tag_name"` 310 | } 311 | 312 | func GetLatestRelease(owner, repo string) (string, error) { 313 | url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) 314 | header := map[string]string{ 315 | "Accept": "application/vnd.github.v3+json", 316 | } 317 | params := map[string]interface{}{} // No params needed for this request 318 | 319 | response, err := Get(url, header, params) 320 | if err != nil { 321 | return "", err 322 | } 323 | 324 | var release Release 325 | err = json.Unmarshal([]byte(response), &release) 326 | if err != nil { 327 | return "", err 328 | } 329 | 330 | return release.TagName, nil 331 | } 332 | 333 | func IsVersionOutdated(currentVersion, latestVersion string) (bool, error) { 334 | currentParts := strings.Split(strings.TrimPrefix(currentVersion, "v"), ".") 335 | latestParts := strings.Split(strings.TrimPrefix(latestVersion, "v"), ".") 336 | 337 | minLength := len(currentParts) 338 | if len(latestParts) < minLength { 339 | minLength = len(latestParts) 340 | } 341 | 342 | for i := 0; i < minLength; i++ { 343 | currentInt, err := strconv.Atoi(currentParts[i]) 344 | if err != nil { 345 | return false, err 346 | } 347 | latestInt, err := strconv.Atoi(latestParts[i]) 348 | if err != nil { 349 | return false, err 350 | } 351 | if currentInt < latestInt { 352 | return true, nil 353 | } else if currentInt > latestInt { 354 | return false, nil 355 | } 356 | } 357 | 358 | // If all parts so far were equal, the shorter version is outdated if it has fewer parts. 359 | return len(currentParts) < len(latestParts), nil 360 | } 361 | 362 | // CheckPathStatus checks if a path is an empty directory or if a file exists. 363 | func CheckPathStatus(path string) (bool, error) { 364 | // Check if the path exists 365 | info, err := os.Stat(path) 366 | if err != nil { 367 | if os.IsNotExist(err) { 368 | // The path does not exist 369 | return true, nil 370 | } 371 | // Other error 372 | return true, err 373 | } 374 | 375 | // Check if the path is a file 376 | if !info.IsDir() { 377 | // The path is a file and it exists 378 | return false, nil 379 | } 380 | 381 | // The path is a directory, now check if it's empty 382 | isEmpty := true 383 | err = filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { 384 | if err != nil { 385 | return err 386 | } 387 | 388 | // Found a file, the directory is not empty 389 | if !d.IsDir() { 390 | isEmpty = false 391 | // Return a special error to stop walking the directory 392 | return fs.SkipDir 393 | } 394 | 395 | return nil 396 | }) 397 | 398 | if err != nil && err != fs.SkipDir { 399 | // An error occurred that wasn't the special 'SkipDir' error 400 | return false, err 401 | } 402 | 403 | // If we got here, the directory is either empty or we encountered 'SkipDir' 404 | return isEmpty, nil 405 | } 406 | 407 | func Trim(originalString string) string { 408 | reg := regexp.MustCompile(`\s+`) 409 | noSpacesString := reg.ReplaceAllString(originalString, "") 410 | return noSpacesString 411 | } 412 | -------------------------------------------------------------------------------- /go-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd app 4 | if [[ -d "build" ]]; then 5 | rm -r build 6 | fi 7 | archs=(amd64 arm64 arm ppc64le ppc64 s390x) 8 | for arch in ${archs[@]} 9 | do 10 | env CGO_ENABLED=0 GOOS=linux GOARCH=${arch} go build -o ./build/qbrs_${arch} -v main.go 11 | done 12 | 13 | echo "已编译以下平台:" 14 | echo "$(file ./build/qbrs_*)" -------------------------------------------------------------------------------- /install-qbrs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | get_platform() { 4 | ARCH=$(uname -m) 5 | result="" 6 | case "$ARCH" in 7 | x86_64) 8 | result="amd64" 9 | ;; 10 | aarch64) 11 | result="arm64" 12 | ;; 13 | armv7l) 14 | result="arm" 15 | ;; 16 | ppc64le) 17 | result="ppc64le" 18 | ;; 19 | ppc64) 20 | result="ppc64" 21 | ;; 22 | s390x) 23 | result="s390x" 24 | ;; 25 | *) 26 | result="" 27 | ;; 28 | esac 29 | if [[ $result == "" ]]; then 30 | echo "暂不支持该平台: $ARCH,请手动编译" 31 | exit 1 32 | fi 33 | echo "$result" 34 | } 35 | 36 | install() { 37 | if ! command -v wget &>/dev/null; then 38 | echo "请先安装 wget" 39 | exit 1 40 | fi 41 | type=$(get_platform) 42 | filename="qbrs_${type}" 43 | cd ~ 44 | REPO_URL="https://api.github.com/repos/durianice/qBittorrent-rclone-sync/releases/latest" 45 | TAG=$(wget -qO- -t1 -T2 ${REPO_URL} | grep "tag_name" | head -n 1 | awk -F ":" '{print $2}' | sed 's/\"//g;s/,//g;s/ //g') 46 | if [[ -z $TAG ]]; then 47 | TAG="latest" 48 | fi 49 | wget "https://github.com/durianice/qBittorrent-rclone-sync/releases/download/$TAG/$filename" 50 | wget -O config.example https://raw.githubusercontent.com/durianice/qBittorrent-rclone-sync/release/app/config.example 51 | 52 | if [[ ! -f "$filename" ]]; then 53 | echo "运行程序 qbrs 不存在" 54 | exit 1 55 | fi 56 | 57 | if [[ ! -f "config.example" ]]; then 58 | echo "配置文件 config.env 不存在" 59 | exit 1 60 | fi 61 | 62 | WORK_DIR="/usr/local/bin" 63 | 64 | if [[ ! -f "${WORK_DIR}/config.env" ]]; then 65 | cp config.example ${WORK_DIR}/config.env 66 | rm config.example 67 | fi 68 | 69 | mv $filename ${WORK_DIR}/ 70 | chmod +x "${WORK_DIR}/$filename" 71 | 72 | cpu_cores=$(grep -c ^processor /proc/cpuinfo) 73 | quota=$((cpu_cores * 75)) 74 | 75 | echo "[Unit] 76 | Description=qBittorrent-rclone-sync 77 | After=network.target 78 | 79 | [Service] 80 | CPUQuota=${quota}% 81 | ExecStart=${WORK_DIR}/$filename 82 | WorkingDirectory=${WORK_DIR}/ 83 | Restart=on-abnormal 84 | 85 | [Install] 86 | WantedBy=default.target" > /etc/systemd/system/qbrs.service 87 | 88 | systemctl daemon-reload 89 | systemctl start qbrs 90 | systemctl enable qbrs 91 | systemctl status qbrs 92 | echo "" 93 | echo "手动编辑配置文件 ${WORK_DIR}/config.env" 94 | echo "" 95 | echo "======== QBRS ========" 96 | echo "启动 systemctl start qbrs" 97 | echo "停止 systemctl stop qbrs" 98 | echo "重启 systemctl restart qbrs" 99 | echo "状态 systemctl status qbrs" 100 | echo "配置文件 ${WORK_DIR}/config.env" 101 | echo "开机自启 systemctl enable qbrs" 102 | echo "禁用自启 systemctl disable qbrs" 103 | echo "更多https://github.com/durianice/qBittorrent-rclone-sync" 104 | echo "======== QBRS ========" 105 | } 106 | 107 | uninstall() { 108 | sudo bash -c "$(curl -sL https://raw.githubusercontent.com/durianice/qBittorrent-rclone-sync/release/uninstall-qbrs.sh)" 109 | } 110 | 111 | if [[ -f "/etc/systemd/system/qbrs.service" ]]; then 112 | uninstall 113 | fi 114 | install 115 | 116 | -------------------------------------------------------------------------------- /uninstall-qbrs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ -f "/etc/systemd/system/qbrs.service" ]]; then 4 | systemctl stop qbrs 5 | systemctl disable qbrs 6 | 7 | rm /usr/local/bin/qbrs_* 8 | 9 | rm /etc/systemd/system/qbrs.service 10 | systemctl daemon-reload 11 | fi 12 | 13 | if [[ ! -f "/etc/systemd/system/qbrs.service" ]]; then 14 | echo "已卸载qbrs" 15 | echo "https://github.com/durianice/qBittorrent-rclone-sync/" 16 | fi 17 | --------------------------------------------------------------------------------