mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优化
This commit is contained in:
@@ -0,0 +1,636 @@
|
|||||||
|
# 115 云下载 → STRM 同步 → 自动刮削 · 实施方案
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| :--- | :--- |
|
||||||
|
| 状态 | 设计已定稿,待实现(P0 未开工) |
|
||||||
|
| 适用版本 | MeBox 当前主线(`internal/service/strm_*`、`internal/service/cloud115/*`) |
|
||||||
|
| 关联文档 | `115doc/115开放平台/API列表/云下载/*`(仓库内离线文档) |
|
||||||
|
| 最后核对 | 本文所有代码事实均按仓库当前代码逐条核对,行号仅供定位参考 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
### 1.1 目标
|
||||||
|
|
||||||
|
在「115 媒体库映射」(即 STRM 管理中的一条 `StrmSyncPath`:115 目录 → 本地目录 .strm)之上提供云下载入口,用户提交磁力 / BT 种子 / ed2k / HTTP 链接后:
|
||||||
|
|
||||||
|
1. 任务提交到 115 离线(云下载);
|
||||||
|
2. 后台轮询 115 任务状态;
|
||||||
|
3. 任务完成后**自动触发**该同步目录的 STRM 增量同步;
|
||||||
|
4. 同步完成后**自动扫描**对应媒体库,生成 Media 行;
|
||||||
|
5. **自动刮削**新增媒体,无需用户再点一次。
|
||||||
|
|
||||||
|
全程无需离开 MeBox,链路上的每个阶段在任务列表里可见。
|
||||||
|
|
||||||
|
### 1.2 明确不做(本期范围外)
|
||||||
|
|
||||||
|
| 项 | 决定 | 原因 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| 磁力链接选择部分文件下载 | **不做** | 开放平台 `/open/offline/add_task_urls` 无文件选择参数,且 `/open/offline/torrent` 只接受已上传种子的 `torrent_sha1` + `pick_code`,无法解析 magnet。见第 6 节 |
|
||||||
|
| `.torrent` 勾选文件 UI | **不做** | 用户已确认本期不做选文件;种子按全选提交 |
|
||||||
|
| 磁力转种子(DHT 抓 metadata) | **不做** | 需引入 torrent 依赖与常驻 DHT 网络行为,本期不引入新依赖 |
|
||||||
|
| 非 115 网盘的离线下载 | **不做** | CD2 / OpenList / 本地目录无对应能力 |
|
||||||
|
| 普通用户自助使用 | **不做** | 首期仅管理员(沿用 `AdminRequired` + `can_manage_strm` 语义),权限位见 9.3 |
|
||||||
|
| 下载完成后自动整理/重命名(Organize) | **不做** | 与 STRM 映射模型冲突,`.strm` 必须与远端路径一一对应 |
|
||||||
|
|
||||||
|
### 1.3 非目标(但需保证不被破坏)
|
||||||
|
|
||||||
|
- 现有 STRM 手动同步 / Cron 同步 / 下载队列 / 上传队列行为不变;
|
||||||
|
- 115 令牌自动刷新机制不得被第二个客户端实例破坏(见 3.3 关键约束 K1);
|
||||||
|
- 刮削不重复刷已 `matched` 的媒体(沿用 `EnqueueLibrary` 的候选过滤)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 现状核实
|
||||||
|
|
||||||
|
### 2.1 已具备
|
||||||
|
|
||||||
|
| 能力 | 位置 | 说明 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| 115 OpenAPI 客户端 | `internal/service/cloud115/client.go`、`const.go:16` | `OpenClient`,`ProAPIBase = https://proapi.115.com`,`doJSON` 内建重试与令牌刷新 |
|
||||||
|
| 115 授权(PKCE/扫码)与令牌落库 | `cloud115/oauth.go`、`service/strm_115_oauth.go` | `OnTokenRefreshed` → `persist115Tokens` 写回 `StrmAccount.Config` |
|
||||||
|
| 115 文件上传 / 秒传 | `cloud115/upload.go:217` | `Upload(ctx, filePath, parentCID, signKey, signVal)` → `UploadCompleteResult{FileId, PickCode, Sha1}` |
|
||||||
|
| 115 目录创建 / 查找 | `cloud115/upload.go:318`(`MkDir`)、`cloud115/open.go:126`(`FindNamedContentInParent`) | 可用于建「云下载/种子文件」暂存目录 |
|
||||||
|
| 115 全局限流/熔断 | `cloud115/throttle_manager.go` | `GetGlobalThrottleManager()` |
|
||||||
|
| STRM 同步(增量/全量) | `service/strm_sync.go:82` `StartSync` | 已运行时返回「该目录正在同步中」,不排队 |
|
||||||
|
| STRM 目录/记录/队列数据层 | `model/strm.go`、`repository/strm_repository.go` | — |
|
||||||
|
| 扫描 + 自动刮削入口 | `service/scanner_scan.go`、`scanner_post_scan.go:19` | `startAutoScrape` 受 `scrape.auto_on_scan` 全局开关约束 |
|
||||||
|
| 刮削入队 | `service/scraper_queue.go:219` `EnqueueLibrary` | 仅挑 `scrape_status` 为空的 `pending` 行,天然幂等、不会重刷已匹配 |
|
||||||
|
| 周期任务框架 | `service/scheduler.go:118` `scheduledJob` | 新增 job 只需在 `Start` 里 append |
|
||||||
|
| 服务装配 | `service/service_builder.go:148` | `NewStrmService` 之后才有 `Cloud115`,可作为依赖注入范式 |
|
||||||
|
|
||||||
|
### 2.2 缺口(本方案要补的)
|
||||||
|
|
||||||
|
| 缺口 | 影响 |
|
||||||
|
| :--- | :--- |
|
||||||
|
| 全仓无任何离线/云下载代码(`grep -r "offline" internal/service/cloud115/` 无命中) | 需从零封装 4 个 API |
|
||||||
|
| `finishSync` 结束后**没有任何回调**(`strm_sync.go:323`) | 「同步完成 → 扫描」链路是断的,必须新增 hook |
|
||||||
|
| 无「本地路径 → 媒体库」反查 | 需新增 repository 查询 |
|
||||||
|
| 无 `.torrent` multipart 上传端点(现有仅 reader 书源、DB 恢复用到 `FormFile`) | 需新增端点 + 上传大小限制 |
|
||||||
|
| 无云下载任务表 | 需新增模型 + 仓储 + 迁移注册 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 总体设计
|
||||||
|
|
||||||
|
### 3.1 链路时序
|
||||||
|
|
||||||
|
```
|
||||||
|
[前端] 提交(多行链接 / .torrent 文件)+ 选择目标 STRM 同步目录
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[CloudDownloadService.Submit]
|
||||||
|
│ 1) 校验账号可用、同步目录启用
|
||||||
|
│ 2) 配额预检 GET /open/offline/get_quota_info
|
||||||
|
│ 3) .torrent: 上传到「云下载/种子文件」→ POST /open/offline/torrent 拿 info_hash
|
||||||
|
│ 4) POST /open/offline/add_task_urls(批量 ≤115/批)或 /open/offline/add_task_bt
|
||||||
|
▼
|
||||||
|
[DB] CloudDownloadTask: status=submitted(逐条记录每个链接的受理结果)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[Scheduler: cloud_download_poll 每 60s]
|
||||||
|
│ GET /open/offline/get_task_list?page=N 逐页
|
||||||
|
│ 按 info_hash(优先)/ url(回退)匹配本地任务
|
||||||
|
│ status 0/1 → downloading(percentDone) | 2 → succeeded | -1 → failed
|
||||||
|
▼
|
||||||
|
[Pipeline: succeeded 任务]
|
||||||
|
│ A. 收集受影响 syncPathID(30s 去抖合并)
|
||||||
|
│ B. StrmService.StartSync(syncPathID, incremental)
|
||||||
|
▼
|
||||||
|
[StrmService.finishSync] → SetSyncFinishHook(ok)
|
||||||
|
│ C. 反查 LocalPath 对应的媒体库 → Scanner.ScanLibrary(libraryID)
|
||||||
|
│ D. Scraper.EnqueueLibrary(libraryID, 默认 options)(仅 pending 行)
|
||||||
|
▼
|
||||||
|
[DB] 回写 Stage=done;前端列表展示各阶段结果
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 模块职责
|
||||||
|
|
||||||
|
| 模块 | 职责 | 明确不负责 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `cloud115/offline.go` | 纯 API 封装:参数拼装、响应解析、错误码映射 | 不碰数据库、不做编排 |
|
||||||
|
| `CloudDownloadService` | 输入解析、校验、提交、状态机、轮询、编排 | 不直接读写 .strm 文件 |
|
||||||
|
| `StrmService` | 现有同步逻辑 + 新增完成回调 | 不感知云下载概念 |
|
||||||
|
| `ScannerService` / `ScraperService` | 现有扫描/刮削逻辑 | 不感知云下载概念 |
|
||||||
|
|
||||||
|
### 3.3 关键约束(实现时必须遵守)
|
||||||
|
|
||||||
|
**K1 — 必须复用 StrmService 已缓存的 115 客户端,禁止自行 `NewOpenClient`。**
|
||||||
|
`strm_service.go:524` 的 `newProvider` 里给 `OpenClient` 挂了 `OnTokenRefreshed` → `persist115Tokens`。115 的 `refresh_token` 是一次性轮转的:若云下载服务另建一个客户端,两边各自刷新会互相作废,最终把账号刷成「授权已失效」。
|
||||||
|
|
||||||
|
落地方式:给 `StrmService` 增加导出方法
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Open115Client 返回账号已缓存的 115 客户端(含令牌自动刷新与落库)。
|
||||||
|
func (s *StrmService) Open115Client(ctx context.Context, accountID string) (*cloud115.OpenClient, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
内部复用 `providerFor` + `cloud.OpenAPI115Provider` 断言(与 `strm_sync.go:379` 的用法一致)。
|
||||||
|
|
||||||
|
**K2 — 去抖。** `walk115Flat`(`strm_sync.go:682`)是整树自适应拉取,不是只看新目录。多个任务在短时间内完成时,同一 syncPath 只应同步一次。
|
||||||
|
|
||||||
|
**K3 — 阶段失败隔离。** STRM 同步失败不得把 115 下载状态改成失败;反之亦然。下载状态与后处理阶段分别落在不同字段(见 4.1)。
|
||||||
|
|
||||||
|
**K4 — 同步不排队。** `StartSync` 在运行中直接报错,Pipeline 必须自己实现「等待 + 重投」,不能靠重试打满日志。
|
||||||
|
|
||||||
|
**K5 — 所有 115 调用走全局 throttle。** 轮询在无活跃任务时不得发起请求。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 数据模型
|
||||||
|
|
||||||
|
### 4.1 `model.CloudDownloadTask`(新表 `cloud_download_tasks`)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/model/cloud_download.go
|
||||||
|
type CloudDownloadTask struct {
|
||||||
|
Base
|
||||||
|
AccountID string `gorm:"size:36;index" json:"account_id"`
|
||||||
|
SyncPathID string `gorm:"size:36;index" json:"sync_path_id"` // 绑定的 STRM 同步目录
|
||||||
|
LibraryID string `gorm:"size:36;index" json:"library_id"` // 由 LocalPath 反查得到,可为空
|
||||||
|
Provider string `gorm:"size:32" json:"provider"` // 固定 cloud115
|
||||||
|
|
||||||
|
SourceType string `gorm:"size:16;index" json:"source_type"` // magnet / torrent / ed2k / http / ftp
|
||||||
|
SourceURL string `gorm:"type:text" json:"source_url"` // 原始链接(.torrent 时存文件名)
|
||||||
|
SourceKey string `gorm:"size:512;index" json:"source_key"` // 归一化后的匹配键:大写 info_hash 或 trim 后的 url
|
||||||
|
InfoHash string `gorm:"size:64;index" json:"info_hash"`
|
||||||
|
TorrentName string `gorm:"size:512" json:"torrent_name,omitempty"`
|
||||||
|
TorrentSha1 string `gorm:"size:64" json:"torrent_sha1,omitempty"` // .torrent 路径用
|
||||||
|
PickCode string `gorm:"size:128" json:"pick_code,omitempty"` // .torrent 路径用
|
||||||
|
|
||||||
|
WpPathID string `gorm:"size:64" json:"wp_path_id"` // 115 目标目录 ID(= syncPath.RemotePath)
|
||||||
|
SavePath string `gorm:"size:512" json:"save_path,omitempty"` // 相对 wp_path_id 的子路径(本期留空)
|
||||||
|
|
||||||
|
Status string `gorm:"size:16;index" json:"status"` // queued/submitted/downloading/succeeded/failed/canceled
|
||||||
|
Percent int `json:"percent"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
Error string `gorm:"size:1024" json:"error"`
|
||||||
|
|
||||||
|
Stage string `gorm:"size:16;index" json:"stage"` // pending/waiting_sync/syncing/scanning/scraping/done/na
|
||||||
|
StageError string `gorm:"size:1024" json:"stage_error"`
|
||||||
|
StrmRecordID string `gorm:"size:36" json:"strm_record_id,omitempty"`
|
||||||
|
ScanAdded int `json:"scan_added"`
|
||||||
|
ScrapeQueued int `json:"scrape_queued"`
|
||||||
|
|
||||||
|
RetryCount int `json:"retry_count"`
|
||||||
|
LastPollAt *time.Time `json:"last_poll_at,omitempty"`
|
||||||
|
NextPollAt *time.Time `gorm:"index" json:"next_poll_at,omitempty"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注册点:`model.AllModels()`(`internal/model/model.go:37`)、`repository.Container`(`internal/repository/repository.go:11`)、`service.Container`(`internal/service/service.go:19`)。
|
||||||
|
|
||||||
|
> **不复用 `StrmDownloadTask`**:那是「远端元数据 → 本地」的文件搬运队列(`model/strm.go`),语义完全不同,混用会让 `/admin/strm/downloads` 列表出现云下载任务。
|
||||||
|
|
||||||
|
### 4.2 状态机
|
||||||
|
|
||||||
|
**Status(115 下载侧)**
|
||||||
|
|
||||||
|
| 值 | 进入条件 | 终态 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `queued` | 本地已建行,尚未提交 115 | 否 |
|
||||||
|
| `submitted` | 115 已受理(`data[].state=true`) | 否 |
|
||||||
|
| `downloading` | `get_task_list` 返回 status 0 或 1 | 否 |
|
||||||
|
| `succeeded` | status 2 | **是**,进入 Pipeline |
|
||||||
|
| `failed` | status -1 / 提交被拒 / 超时(默认 72h) | **是** |
|
||||||
|
| `canceled` | 用户取消 | **是** |
|
||||||
|
|
||||||
|
**Stage(后处理侧)**
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `pending` | 尚未开始(未成功) |
|
||||||
|
| `waiting_sync` | 已成功,等待去抖窗口结束或等待同一目录的同步结束 |
|
||||||
|
| `syncing` | STRM 增量同步执行中 |
|
||||||
|
| `scanning` | 媒体库扫描中 |
|
||||||
|
| `scraping` | 刮削已入队 |
|
||||||
|
| `done` | 全链路完成 |
|
||||||
|
| `na` | 同步目录已删除/禁用,跳过后续(不算失败) |
|
||||||
|
| `failed` | 阶段失败,`StageError` 记录原因 |
|
||||||
|
|
||||||
|
> Stage 失败**不回滚** Status;用户可对 Stage 失败的任务点「重做后处理」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 115 开放平台对接
|
||||||
|
|
||||||
|
### 5.1 接口清单
|
||||||
|
|
||||||
|
| 用途 | 方法 | 路径 | 关键参数 | 关键返回 |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| 添加链接任务 | POST | `/open/offline/add_task_urls` | `urls`(换行分隔,≤115 条)、`wp_path_id` | `data[]{state,code,message,info_hash,url}` |
|
||||||
|
| 解析 BT 种子 | POST | `/open/offline/torrent` | `torrent_sha1`、`pick_code` | `data{file_size,torrent_name,file_count,info_hash,torrent_filelist[]{size,path,wanted}}` |
|
||||||
|
| 添加 BT 任务 | POST | `/open/offline/add_task_bt` | `info_hash`、`wanted`、`save_path`、`torrent_sha1`、`pick_code`、`wp_path_id` | `data[]`(成功为空数组) |
|
||||||
|
| 任务列表 | GET | `/open/offline/get_task_list` | `page` | `data{page,page_count,count,tasks[]{...}}` |
|
||||||
|
| 配额信息 | GET | `/open/offline/get_quota_info` | 无 | `data{package[]{surplus,used,count,name,expire_info[]},count,surplus,used}` |
|
||||||
|
| 删除任务(P2) | POST | `/open/offline/del_task` | `info_hash`、`del_source_file` | `data[]` |
|
||||||
|
| 清空任务(P2) | POST | `/open/offline/clear_task` | `flag`(0~5) | `data[]` |
|
||||||
|
|
||||||
|
请求形态:`add_task_*` / `del_task` / `clear_task` / `torrent` 均为 **multipart/form-data**;`get_*` 为 GET query。全部走 `Authorization: Bearer <access_token>`,即现有 `doAuthJSON` 通道。
|
||||||
|
|
||||||
|
### 5.2 任务列表字段(轮询用)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 用途 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `info_hash` | string | 首选匹配键 |
|
||||||
|
| `url` | string | 回退匹配键(链接任务) |
|
||||||
|
| `status` | int | -1 失败 / 0 分配中 / 1 下载中 / 2 成功 |
|
||||||
|
| `percentDone` | int | 进度(**注意驼峰**,见 5.4) |
|
||||||
|
| `size` | int | 任务总字节 |
|
||||||
|
| `name` | string | 任务名,用于 UI |
|
||||||
|
| `file_id` / `delete_file_id` | string | 完成后源文件/文件夹 ID(P2 裁剪用) |
|
||||||
|
| `wp_path_id` | string | 落盘父目录,可用于校验目录一致性 |
|
||||||
|
| `add_time` / `last_update` | int | 超时判断 |
|
||||||
|
|
||||||
|
### 5.3 错误码映射(要落到用户可读文案)
|
||||||
|
|
||||||
|
| 码 | 115 文案 | MeBox 提示 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `1000012` | 云下载配额已用完 | 「115 云下载配额已用尽,请在 115 端购买/领取配额后重试」 |
|
||||||
|
| `91006` | 存储空间不足 | 「115 存储空间不足」 |
|
||||||
|
| `20018` | 文件不存在或已删除 | 「种子文件找不到(可能已被清理),请重新上传 .torrent」 |
|
||||||
|
| `1000011` | 链接数量超过 115 个 | 本地已按 115 条切批,正常不应触发;触发则记 warning 并重切 |
|
||||||
|
| `990002` | 参数错误 | 「提交参数不被 115 接受」,附原始 message |
|
||||||
|
| `980004` | 操作失败 | 可重试,走退避 |
|
||||||
|
| 无权限/未开通 | —— | 「当前 115 应用未开通云下载权限」,并提示到开放平台申请(见风险 R1) |
|
||||||
|
|
||||||
|
### 5.4 实现陷阱(必须按此实现)
|
||||||
|
|
||||||
|
1. **`percentDone` 是驼峰**,`get_task_list` 其余字段是 snake_case。Go struct tag 必须写 `json:"percentDone"`,写错会永远 0 进度。
|
||||||
|
2. **`data[].info_hash` 仅任务成功受理时返回**,可能为空 → 必须同时保存提交时的原始 `url` 做回退匹配,并对 url 做归一化(去首尾空白、统一小写协议头、去掉 `&` 后跟踪参数?**不做过度归一化**,仅 trim,避免误合并)。
|
||||||
|
3. **`wanted` 索引基准未确认**(见 11 节实测项)。本期不做选文件,实现统一传 `wanted="0"`(官方文档唯一明确允许的取值),待真机确认后再决定是否改为全索引拼接。
|
||||||
|
4. **`add_task_urls` 单批 ≤115**,超了服务端报 `1000011`;本地必须切批。
|
||||||
|
5. **`wp_path_id=0` 表示根目录**,绝不能把「未选择目录」当 0 提交,否则文件落到 115 根目录,STRM 永远扫不到 → 提交前强校验 `WpPathID != "" && != "0"`。
|
||||||
|
6. **`get_task_list` 只支持 page,无 page_size**,需按 `page_count` 翻页;轮询设页数上限(默认 10 页)防止账号任务堆积把一轮轮询拖爆。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 为什么磁力不能选文件(结论留档)
|
||||||
|
|
||||||
|
| 接口 | 能否选择文件 | 依据 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `add_task_urls`(磁力/ed2k/http) | ❌ 无 wanted 参数 | `115doc/.../添加云下载链接任务.md` 请求参数仅 `urls`、`wp_path_id` |
|
||||||
|
| `torrent`(解析种子) | ✅ 返回 `torrent_filelist[].wanted` | `解析BT种子.md` 返回字段表 |
|
||||||
|
| `add_task_bt`(提交 BT) | ✅ `wanted` 必填 | `添加云下载BT任务.md` 请求参数表 |
|
||||||
|
|
||||||
|
`torrent` 的入参是 **`torrent_sha1` + `pick_code`**,只能解析「已上传到本人 115 空间的 .torrent 文件」,不接受 magnet / info_hash。开放平台没有「解析磁力 → 文件列表」这一步,因此磁力链路从入口就没有选文件能力。
|
||||||
|
|
||||||
|
**若要支持磁力选文件**,唯一路径是服务端自行把 magnet 抓成 .torrent(DHT metadata),属新增外部依赖与常驻网络行为,本期不做,记录为 P3 候选。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 后端实现
|
||||||
|
|
||||||
|
### 7.1 文件清单
|
||||||
|
|
||||||
|
**新增**
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `internal/model/cloud_download.go` | `CloudDownloadTask` 模型 + 状态常量 |
|
||||||
|
| `internal/repository/cloud_download_repository.go` | CRUD、按状态取活跃任务、按阶段取待推进任务、统计 |
|
||||||
|
| `internal/service/cloud115/offline.go` | 4+2 个 API 封装、响应结构体、错误码常量与 `OfflineError` 类型 |
|
||||||
|
| `internal/service/cloud_download.go` | `CloudDownloadService` 主体:构造、提交、取消、重试、列表、配额 |
|
||||||
|
| `internal/service/cloud_download_input.go` | 输入解析(多行/类型判定/去重/切批 ≤115)、匹配键归一化 |
|
||||||
|
| `internal/service/cloud_download_submit.go` | 链接提交 + `.torrent` 上传解析 → `add_task_bt`、暂存目录查找/创建 |
|
||||||
|
| `internal/service/cloud_download_poll.go` | 轮询、状态映射、超时熔断、远程任务与本地记录对账 |
|
||||||
|
| `internal/service/cloud_download_pipeline.go` | Stage 编排、去抖、等待同步、扫描、刮削、失败隔离 |
|
||||||
|
| `internal/service/scheduler_cloud_download.go` | `jobCloudDownloadPoll` |
|
||||||
|
| `internal/handler/cloud_download.go` | REST handler + multipart 种子上传 |
|
||||||
|
| `internal/handler/routes_cloud_download.go` | 路由注册(在 `registerAdminStrmRoutes` 同级新增 `registerAdminCloudDownloadRoutes`) |
|
||||||
|
| `web/src/types/cloudDownload.ts` | 前端类型 |
|
||||||
|
| `web/src/api/cloudDownload.ts` | 前端 API |
|
||||||
|
| `web/src/pages/CloudDownloadPage.tsx` | 页面(提交面板 + 任务列表 + 配额) |
|
||||||
|
|
||||||
|
**修改**
|
||||||
|
|
||||||
|
| 文件 | 改动 | 风险 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `internal/model/model.go` | `AllModels()` 追加 `&CloudDownloadTask{}` | 低(AutoMigrate 新增表) |
|
||||||
|
| `internal/repository/repository.go` | `Container` 增加 `CloudDownload *CloudDownloadRepository` | 低 |
|
||||||
|
| `internal/service/service.go` | `Container` 增加 `CloudDownload *CloudDownloadService`,`Start/Stop` 联动 | 低 |
|
||||||
|
| `internal/service/strm_service.go` | 新增 `SetSyncFinishHook`、`Open115Client` | 中(新增导出方法,不改现有逻辑) |
|
||||||
|
| `internal/service/strm_sync.go` | `finishSync` 末尾触发 hook(异步) | **中**:hook 里必须自带 recover + 超时,绝不能阻塞或 panic 影响同步协程 |
|
||||||
|
| `internal/service/service_builder.go` | 构造 `CloudDownload`(在 `Strm` 之后),注入 Strm/Scan/Scraper/Tasks/Hub;回调 hook | 低 |
|
||||||
|
| `internal/service/scheduler.go` | `Start()` 里 append `cloud_download_poll` job(仅在 `CloudDownload != nil` 时注册) | 低 |
|
||||||
|
| `internal/repository/library_repository.go` | 新增 `FindEnabledRootsByLocalPath(ctx, localPath)`(归一化 + 前缀匹配) | 低 |
|
||||||
|
| `web/src/appRoutes.tsx`、`web/src/components/layoutNavigation.ts` | 路由 + 侧边栏入口 | 低 |
|
||||||
|
|
||||||
|
### 7.2 服务对外 API
|
||||||
|
|
||||||
|
```go
|
||||||
|
type CloudDownloadService struct{ /* cfg, log, repos, strm, scanner, scraper, tasks, hub */ }
|
||||||
|
|
||||||
|
func (s *CloudDownloadService) Submit(ctx context.Context, req CloudDownloadSubmit) (*CloudDownloadSubmitResult, error)
|
||||||
|
func (s *CloudDownloadService) SubmitTorrent(ctx context.Context, req CloudDownloadTorrentSubmit) (*CloudDownloadSubmitResult, error)
|
||||||
|
func (s *CloudDownloadService) List(ctx context.Context, q CloudDownloadQuery) (*CloudDownloadPage, error)
|
||||||
|
func (s *CloudDownloadService) Cancel(ctx context.Context, id string) error
|
||||||
|
func (s *CloudDownloadService) Retry(ctx context.Context, id string) error // 失败任务重新提交
|
||||||
|
func (s *CloudDownloadService) RetryPipeline(ctx context.Context, id string) error // 仅重做后处理
|
||||||
|
func (s *CloudDownloadService) Delete(ctx context.Context, id string) error
|
||||||
|
func (s *CloudDownloadService) ClearFinished(ctx context.Context) (int64, error)
|
||||||
|
func (s *CloudDownloadService) Quota(ctx context.Context, accountID string) (*OfflineQuota, error)
|
||||||
|
func (s *CloudDownloadService) Start(ctx context.Context) // 恢复中断任务
|
||||||
|
func (s *CloudDownloadService) Stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
`CloudDownloadSubmit`:`AccountID`、`SyncPathID`、`URLs []string`(一次提交多条)。
|
||||||
|
`CloudDownloadSubmitResult`:逐条 `{source, task_id, ok, error}`,**部分成功必须返回 200 + 明细**。
|
||||||
|
|
||||||
|
### 7.3 提交校验(顺序不可乱)
|
||||||
|
|
||||||
|
1. `SyncPathID` 存在、`Enabled`、`Provider == cloud115`、`AccountID` 非空、`RemotePath` 为有效目录 ID 且 ≠ `"0"`;
|
||||||
|
2. 账号存在且 `Enabled`,`Open115Client` 可取得;
|
||||||
|
3. 配额预检:`get_quota_info` → `surplus <= 0` 时直接拒绝(批量多链接时按条数预估,无法精确预估大小,只做「配额是否耗尽」闸门);
|
||||||
|
4. 输入解析:按行拆分 → 类型判定 `magnet:?xt=urn:btih:` / `ed2k://` / `http(s)://` / `ftp://` → trim → 去重(同批内 + 与库内 `submitted/downloading` 任务去重);
|
||||||
|
5. 切批:每批 ≤115 条,逐批调用 `add_task_urls`;
|
||||||
|
6. 逐条落库:`data[].state=true` → `submitted`;失败 → `failed` + 错误码映射文案。
|
||||||
|
|
||||||
|
### 7.4 轮询实现要点
|
||||||
|
|
||||||
|
```
|
||||||
|
每 tick(默认 60s):
|
||||||
|
1. 取所有 status IN (submitted, downloading) 的任务,按 account_id 分组
|
||||||
|
2. 无活跃任务 → 直接返回(不打 115 接口)
|
||||||
|
3. 对每个账号:
|
||||||
|
翻页取 get_task_list(page=1..min(page_count, 10))
|
||||||
|
构建索引:info_hash(大写) → task,url(trim) → task
|
||||||
|
逐条匹配本地记录:
|
||||||
|
- info_hash 命中优先
|
||||||
|
- 否则 url 命中
|
||||||
|
- 都未命中 → 记 LastPollAt,未超时前不判失败(115 受理有延迟)
|
||||||
|
状态映射 + 更新 Percent/SizeBytes/Name
|
||||||
|
status=2 → succeeded + CompletedAt + Stage=pending
|
||||||
|
status=-1 → failed(Error 用 115 文案)
|
||||||
|
4. 超时:submitted/downloading 且 add_time 早于 now-72h → failed「下载超时」
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 Pipeline 实现要点
|
||||||
|
|
||||||
|
```
|
||||||
|
推进循环(可挂在同一 tick 之后,也可独立每 15s):
|
||||||
|
A. 取 status=succeeded AND stage IN (pending, waiting_sync, failed) 的任务,按 sync_path_id 分组
|
||||||
|
- 每组内若有任务 completed_at 在去抖窗口内(默认 30s)→ 本轮跳过(等凑批)
|
||||||
|
B. 对每个 syncPath:
|
||||||
|
- 若 IsSyncRunning(pathID) → 全部置 waiting_sync,下一轮再看
|
||||||
|
- 否则 StartSync(pathID, incremental),置 syncing,记录 StrmRecordID(取该 path 最新记录)
|
||||||
|
C. 同步完成后(hook):
|
||||||
|
- 置 scanning → 反查 library(LocalPath 与 LibraryRoot.Path 归一化后相等或为其子目录)
|
||||||
|
- 无匹配库 → Stage=na + StageError「本地目录未加入任何媒体库」
|
||||||
|
- ScanLibrary(libraryID) → ScanAdded
|
||||||
|
- 置 scraping → EnqueueLibrary(libraryID, ScrapeOptions{}) → ScrapeQueued
|
||||||
|
- 置 done
|
||||||
|
D. 任一步失败:Stage=failed + StageError,不重试(等待用户点「重做后处理」)
|
||||||
|
E. 进程重启:Start() 时把 syncing/scanning/scraping 的僵尸阶段重置为 pending(参考 StrmService.recoverInterruptedSyncs 的写法)
|
||||||
|
```
|
||||||
|
|
||||||
|
去抖细节:以 `syncPathID` 为单位,服务内存里维护 `lastTrigger[pathID] = time`;窗口内到达的任务全部 `waiting_sync`,窗口结束时由首个触发者发起同步。
|
||||||
|
|
||||||
|
### 7.6 STRM 完成回调
|
||||||
|
|
||||||
|
```go
|
||||||
|
// strm_service.go
|
||||||
|
func (s *StrmService) SetSyncFinishHook(fn func(ctx context.Context, syncPathID, status string)) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `finishSync`(`strm_sync.go:323`)**落库完成之后**调用:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if hook := s.syncFinishHook(); hook != nil {
|
||||||
|
helper.Go(s.log, "strm.syncFinishHook", func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
hook(ctx, p.ID, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:**异步 + 自带 recover + 超时**,绝不能让 hook 的 panic 或阻塞影响同步协程;也不要在 `finishSync` 里同步调用扫描(同步耗时长,会拖住同步收尾与 Pending 记录)。
|
||||||
|
|
||||||
|
### 7.7 调度与配置项
|
||||||
|
|
||||||
|
设置项(沿用 `repo.Setting`,键名与现有 `scrape.*` / `strm.*` 风格一致):
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `cloud_download.poll_interval_sec` | `60` | 轮询间隔(30~600) |
|
||||||
|
| `cloud_download.sync_debounce_sec` | `30` | 同一同步目录的去抖窗口(0~300) |
|
||||||
|
| `cloud_download.auto_scrape` | `true` | 同步后是否自动刮削 |
|
||||||
|
| `cloud_download.task_timeout_hours` | `72` | 下载超时判定 |
|
||||||
|
| `cloud_download.torrent_max_mb` | `20` | `.torrent` 上传大小上限 |
|
||||||
|
| `cloud_download.max_pages` | `10` | 单轮轮询最大翻页数 |
|
||||||
|
|
||||||
|
`auto_scrape=true` 时**不受** `scrape.auto_on_scan` 全局开关约束(用户显式提交了下载,期望链路闭环);但若 `Scraper.AnyEnabled()==false`(无任何刮削源),Stage 直接置 `done` 并在 `StageError` 记「未配置刮削源」。
|
||||||
|
|
||||||
|
### 7.8 HTTP 路由与鉴权
|
||||||
|
|
||||||
|
挂在 `registerAdminStrmRoutes` 同组(`admin`,已带 `AuthRequired` + `AdminRequired`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
admin.GET("/cloud-downloads", listCloudDownloadsHandler(svc))
|
||||||
|
admin.POST("/cloud-downloads", createCloudDownloadsHandler(svc))
|
||||||
|
admin.POST("/cloud-downloads/torrent", uploadCloudDownloadTorrentHandler(svc)) // multipart
|
||||||
|
admin.GET("/cloud-downloads/quota", cloudDownloadQuotaHandler(svc))
|
||||||
|
admin.POST("/cloud-downloads/:id/retry", retryCloudDownloadHandler(svc))
|
||||||
|
admin.POST("/cloud-downloads/:id/retry-pipeline", retryCloudDownloadPipelineHandler(svc))
|
||||||
|
admin.POST("/cloud-downloads/:id/cancel", cancelCloudDownloadHandler(svc))
|
||||||
|
admin.DELETE("/cloud-downloads/:id", deleteCloudDownloadHandler(svc))
|
||||||
|
admin.POST("/cloud-downloads/clear-finished", clearFinishedCloudDownloadsHandler(svc))
|
||||||
|
```
|
||||||
|
|
||||||
|
multipart 端点:`file`(.torrent)+ `account_id` + `sync_path_id`;实现需 `c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxMB<<20)`,并校验文件头(BT 种子是 bencode,以 `d` 开头,含 `announce` / `info`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 前端实现
|
||||||
|
|
||||||
|
### 8.1 页面结构(`CloudDownloadPage.tsx`)
|
||||||
|
|
||||||
|
**顶部:提交面板**
|
||||||
|
- 目标同步目录下拉:复用 `GET /admin/strm/paths`,只列 `provider=cloud115 && enabled`,展示 `name + remote_display_path + local_path`;
|
||||||
|
- 账号显示:由所选同步目录自动带出(`account_id`),无需二次选择;
|
||||||
|
- 链接文本框:多行粘贴,右下角实时显示「识别到 N 条:磁力 x / ed2k y / http z」;
|
||||||
|
- `.torrent` 拖拽/选择:单文件,提交时走 multipart 端点;
|
||||||
|
- 配额条:`surplus/count`,耗尽时提交按钮禁用并提示;
|
||||||
|
- 提交结果:逐条成功/失败明细(不合并成一句「提交失败」)。
|
||||||
|
|
||||||
|
**下方:任务列表**
|
||||||
|
- 列:来源(截断显示 + tooltip 全量)、目标目录、Size、下载进度、**阶段徽标**(下载中 / 等待同步 / 同步中 / 扫描中 / 刮削中 / 完成 / 失败)、时间、操作;
|
||||||
|
- 操作:取消(仅下载中)、重试(失败)、重做后处理(Stage 失败)、删除(终态);
|
||||||
|
- 批量:清空已完成;
|
||||||
|
- 刷新:复用现有 WS Hub(`hub.Publish("cloud_download", …)`),断线时降级为 5s 轮询。
|
||||||
|
|
||||||
|
### 8.2 路由与导航
|
||||||
|
|
||||||
|
- `appRoutes.tsx`:`{ path: 'cloud-downloads', element: <CloudDownloadPage />, adminOnly: true }`
|
||||||
|
- `layoutNavigation.ts`:`LAYOUT_NAV_ITEMS` 中 `STRM 管理` 之后加 `{ to: '/cloud-downloads', label: '云下载', icon: CloudDownload, adminOnly: true }`
|
||||||
|
- `StrmManagePage` 顶部工具区加一个「云下载」跳转按钮,形成入口闭环。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 权限与安全
|
||||||
|
|
||||||
|
### 9.1 鉴权
|
||||||
|
|
||||||
|
首期仅管理员:路由挂在 `admin` 组,`AdminRequired` 中间件已覆盖。
|
||||||
|
|
||||||
|
### 9.2 输入安全
|
||||||
|
|
||||||
|
- 链接只做**字符串拼接转发**,服务端绝不主动发起对这些 URL 的请求(无 SSRF 面);
|
||||||
|
- `.torrent` 文件仅落临时目录 → 上传 115 → 删除,不本地解析 bencode(P1 若要预览文件名需加 bencode 解析,届时必须限大小与深度);
|
||||||
|
- 上传文件名禁用 `..` / 路径分隔符;
|
||||||
|
- `save_path` 本期固定为空(留字段),避免路径穿越争议。
|
||||||
|
|
||||||
|
### 9.3 后续(P2)
|
||||||
|
|
||||||
|
`model.UserPermission` 增加 `CanUseCloudDownload bool`,配套 handler 从 `AdminRequired` 换成权限中间件,并给普通用户做配额上限(否则一个用户可以把账号的 115 云下载配额打空)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 测试计划
|
||||||
|
|
||||||
|
### 10.1 单元测试(必须)
|
||||||
|
|
||||||
|
| 目标 | 用例 |
|
||||||
|
| :--- | :--- |
|
||||||
|
| 输入解析 | 多行混合、空行、重复链接、首尾空白、大小写协议头、>115 条切批、非法行跳过并计数 |
|
||||||
|
| 状态映射 | status -1/0/1/2 → failed/downloading/downloading/succeeded;缺失 info_hash 时 url 回退命中;大小写 info_hash |
|
||||||
|
| 错误码映射 | 1000012 / 91006 / 20018 / 990002 文案与是否可重试 |
|
||||||
|
| 提交校验 | `RemotePath="0"`、账号禁用、同步目录禁用、配额耗尽 → 全部拒绝且不产生 115 请求 |
|
||||||
|
| 去抖 | 30s 内 3 个任务完成 → 只触发 1 次 `StartSync` |
|
||||||
|
| 等待同步 | `IsSyncRunning=true` → 全部 waiting_sync;结束后成功发起 |
|
||||||
|
| 阶段隔离 | 同步失败不改变 Status;扫描失败不改变 syncing 记录 |
|
||||||
|
| 僵尸恢复 | 重启后 syncing/scanning/scraping → pending |
|
||||||
|
|
||||||
|
测试风格沿用仓库现状:同包 `*_test.go`、`t.Context()`、httptest 模拟 115(参考 `internal/service/cloud115/cloud115_test.go` 的 mock 模式)。
|
||||||
|
|
||||||
|
### 10.2 集成测试
|
||||||
|
|
||||||
|
- `httptest` 起伪 115(`/open/offline/*`):验证提交 → 轮询 → 状态流转 → pipeline 触发(Strm 用 fake hook,Scanner/Scraper 用桩)。
|
||||||
|
- 现有测试回归:`go test ./internal/service/... ./internal/repository/... ./internal/handler/...`。
|
||||||
|
|
||||||
|
### 10.3 真机验收(不可省)
|
||||||
|
|
||||||
|
| # | 步骤 | 期望 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| 1 | 提交一条小体积磁力(<200MB)绑定某 115 同步目录 | 任务 5s 内变 `submitted` |
|
||||||
|
| 2 | 等 115 完成 | 任务变 `succeeded`,随后 `syncing` |
|
||||||
|
| 3 | 同步完成 | 本地目录出现新 `.strm` 文件 |
|
||||||
|
| 4 | 自动扫描 | 媒体库新出现对应条目 |
|
||||||
|
| 5 | 自动刮削 | 条目 `scrape_status` 变 `matched`,海报正确 |
|
||||||
|
| 6 | 提交 `.torrent` 文件 | 同上,且 115 端任务名与种子内任务名一致 |
|
||||||
|
| 7 | 提交 ed2k 链接 | 同上(ed2k 无 info_hash,验证 url 回退匹配) |
|
||||||
|
| 8 | 提交 120 条链接 | 自动切 2 批,逐条结果完整 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 待实测确认项
|
||||||
|
|
||||||
|
| # | 项 | 影响 | 处理 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| 1 | `add_task_bt` 的 `wanted` 是 0-based 还是 1-based | 后续若开放选文件,索引拼接依赖此语义 | 真机用一个 3 文件种子验证;当前实现统一传 `"0"`,不受此影响 |
|
||||||
|
| 2 | `wanted="0"` 是「全部」还是「第 0 个」 | 决定 `.torrent` 任务是否只下一个文件 | **必测**:当前实现依赖它表示「全部」 |
|
||||||
|
| 3 | `MkDir` 对已存在目录的返回 | 暂存目录重复创建 | 先 `FindNamedContentInParent` 查找,未命中再 `MkDir`;两者都失败则回退到目标同步目录 |
|
||||||
|
| 4 | 当前 115 应用是否已开通云下载权限 | 全功能可用性 | 提交前 `get_quota_info` 探测;无权限时给出「去开放平台申请」指引 |
|
||||||
|
| 5 | 磁力整体提交时 115 默认勾选哪些文件 | 是否漏下样片/字幕 | 不影响链路,只在文档说明 |
|
||||||
|
| 6 | `get_task_list` 单页实际返回条数 | 翻页策略 | 首轮实测后调整 `max_pages` 默认值 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11.5 实现状态(P0 已落地)
|
||||||
|
|
||||||
|
已完成(后端 + 前端最小可用):
|
||||||
|
|
||||||
|
| 层 | 文件 | 说明 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| 模型 | `internal/model/cloud_download.go` | `CloudDownloadTask` + 状态/阶段常量,已注册 `AllModels()` |
|
||||||
|
| 仓储 | `internal/repository/cloud_download_repository.go` | 分页查询、活跃任务去重、阶段推进、僵尸阶段重置 |
|
||||||
|
| 115 协议 | `internal/service/cloud115/offline.go` | 7 个接口封装;`client.go` 新增 multipart 请求构造并复用限流/刷新/错误封装 |
|
||||||
|
| 服务 | `internal/service/cloud_download*.go` | 输入解析、提交、轮询、去抖、Pipeline(同步→扫描→刮削) |
|
||||||
|
| 调度 | `internal/service/scheduler_cloud_download.go` | `cloud_download_poll` 周期任务 |
|
||||||
|
| 打通 | `internal/service/strm_service.go` / `strm_sync.go` | `Open115Client` 复用缓存客户端;`SetSyncFinishHook` 异步回调 |
|
||||||
|
| HTTP | `internal/handler/cloud_download.go` | 9 个管理员接口 + multipart 种子上传 |
|
||||||
|
| 前端 | `web/src/pages/CloudDownloadPage.tsx` 等 | 提交面板、配额、任务列表、重试/取消/删除、5s 自动刷新 |
|
||||||
|
|
||||||
|
P0 范围外(保留为 P1/P2):
|
||||||
|
|
||||||
|
- 设置项 UI(当前通过 `Setting` 表键值生效,默认轮询 60s / 去抖 30s / 超时 72h);
|
||||||
|
- WebSocket 实时进度(当前为 5s 轮询);
|
||||||
|
- 普通用户权限位与单用户配额上限;
|
||||||
|
- 远端任务清理(`del_task` / `clear_task`)在 UI 上无入口(取消任务时会尽力调用 `del_task`)。
|
||||||
|
|
||||||
|
验证证据:
|
||||||
|
|
||||||
|
- `go build ./...`、`go vet` 通过;
|
||||||
|
- 新增单测:`internal/service/cloud115/offline_test.go`(7 项,含 multipart 格式与 `percentDone` 解析)、`internal/service/cloud_download_test.go`(9 项,含解析/去抖/目录匹配/校验);
|
||||||
|
- 前端 `tsc --noEmit`、`eslint`、`vite build` 通过;
|
||||||
|
- 3 个既有测试在本机 HEAD 上同样失败(Windows 文件占用与 SQLite `VACUUM INTO` 环境问题),已用独立 worktree 复核,与本次改动无关。
|
||||||
|
|
||||||
|
## 12. 分期与验收
|
||||||
|
|
||||||
|
### P0(可跑通,约 1 个迭代)
|
||||||
|
|
||||||
|
- 模型 + 仓储 + 迁移注册
|
||||||
|
- `cloud115/offline.go`(4 个接口:add_task_urls / get_task_list / get_quota_info / torrent + add_task_bt)
|
||||||
|
- 提交(磁力/ed2k/http)+ 轮询 + 状态机
|
||||||
|
- STRM finish hook + Pipeline(同步 → 扫描 → 刮削)
|
||||||
|
- 调度 job + 设置项
|
||||||
|
- 前端最小页:提交面板 + 任务列表(阶段徽标、重试、删除)
|
||||||
|
- **验收**:10.3 的步骤 1~5、7、8 通过
|
||||||
|
|
||||||
|
### P1(体验完善)
|
||||||
|
|
||||||
|
- `.torrent` 上传端点 + 全选提交(10.3 步骤 6)
|
||||||
|
- 配额条、提交结果逐条明细
|
||||||
|
- 去抖、僵尸恢复、超时熔断
|
||||||
|
- WS 实时进度
|
||||||
|
- 分页列表 + 筛选(状态/目录)
|
||||||
|
|
||||||
|
### P2(可选)
|
||||||
|
|
||||||
|
- 远端任务清理(`del_task` / `clear_task`)
|
||||||
|
- 完成后删除暂存 `.torrent`
|
||||||
|
- 普通用户权限位 + 单用户配额上限
|
||||||
|
- Telegram 完成通知
|
||||||
|
|
||||||
|
### P3(本期不做,记录候选)
|
||||||
|
|
||||||
|
- 磁力选文件(服务端 DHT 抓 .torrent)
|
||||||
|
- 完成后按 file_id 裁剪不需要的文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 风险与回滚
|
||||||
|
|
||||||
|
| # | 风险 | 影响 | 缓解 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| R1 | 115 应用未开通云下载权限 | 功能不可用 | 提交前探测 + 明确文案;不影响现有 STRM 功能 |
|
||||||
|
| R2 | 令牌双实例互相作废 | 账号被标「授权已失效」 | **强约束 K1**:只复用 StrmService 缓存的客户端 |
|
||||||
|
| R3 | 频繁同步打爆 CPU/115 限流 | 服务卡顿、风控 | 去抖 30s + 最小间隔 + 同步运行中等待 + throttle_manager |
|
||||||
|
| R4 | 目录错配(`wp_path_id` 不在同步目录) | 下了但永远扫不到 | 提交时强制 `RemotePath` 非空非 0;UI 只允许选同步目录,不放开自由选目录 |
|
||||||
|
| R5 | finish hook panic 拖垮同步 | 同步记录永远 running | hook 异步 + `helper.Go` + recover + 独立超时 |
|
||||||
|
| R6 | 新增表迁移失败 | 启动失败 | `AutoMigrate` 加表无破坏性;出问题回滚只需删表 + 移除注册 |
|
||||||
|
| R7 | 单页页数过多导致轮询耗时 | 轮询堆积 | `max_pages` 上限;超限记 warning 并下轮继续 |
|
||||||
|
|
||||||
|
**回滚方式**:移除路由与侧边栏入口即可(功能面隐藏);表可保留(不影响其它子系统),或手动 `DROP TABLE cloud_download_tasks`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 附录:核对依据
|
||||||
|
|
||||||
|
| 结论 | 依据位置 |
|
||||||
|
| :--- | :--- |
|
||||||
|
| 115 开放平台离线接口形态与参数 | `115doc/115开放平台/API列表/云下载/*.md` |
|
||||||
|
| STRM 同步入口与运行互斥 | `internal/service/strm_sync.go:82-138` |
|
||||||
|
| 同步收尾无回调 | `internal/service/strm_sync.go:323`(`finishSync`) |
|
||||||
|
| 115 目录整树拉取成本 | `internal/service/strm_sync.go:682`(`walk115Flat`) |
|
||||||
|
| 令牌刷新落库与单实例要求 | `internal/service/strm_service.go:524-556`、`strm_115_oauth.go:438` |
|
||||||
|
| 115 provider 断言复用范式 | `internal/service/strm_sync.go:378`(`cloud.OpenAPI115Provider`) |
|
||||||
|
| 上传种子可拿 PickCode | `internal/service/cloud115/upload.go:95-102,217` |
|
||||||
|
| 刮削候选只挑 pending | `internal/service/scraper_library.go:183-207` |
|
||||||
|
| 扫描自动刮削开关 | `internal/service/scanner_prune.go:279`、`scanner_post_scan.go:19` |
|
||||||
|
| 周期任务注册范式 | `internal/service/scheduler.go:118-210` |
|
||||||
|
| 模型迁移注册 | `internal/model/model.go:37`、`internal/database/schema_migration.go:34` |
|
||||||
|
| 服务装配顺序 | `internal/service/service_builder.go:148` |
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// 云下载(115 离线下载)HTTP 接口。
|
||||||
|
//
|
||||||
|
// 全部挂在 /api/admin 下(管理员专用):首期不开放普通用户,否则单个用户
|
||||||
|
// 可以把账号的 115 云下载配额打空(见 docs/cloud-download-plan.md 第 9 节)。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cloudDownloadUploadHardLimit 是 .torrent 上传的硬上限(字节)。
|
||||||
|
// 真实上限由设置项 cloud_download.torrent_max_mb 控制,这里只防内存打爆。
|
||||||
|
const cloudDownloadUploadHardLimit = 256 << 20
|
||||||
|
|
||||||
|
func registerAdminCloudDownloadRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||||
|
admin.GET("/cloud-downloads", listCloudDownloadsHandler(svc))
|
||||||
|
admin.POST("/cloud-downloads", createCloudDownloadsHandler(svc))
|
||||||
|
admin.POST("/cloud-downloads/torrent", uploadCloudDownloadTorrentHandler(svc))
|
||||||
|
admin.GET("/cloud-downloads/quota", cloudDownloadQuotaHandler(svc))
|
||||||
|
admin.POST("/cloud-downloads/:id/retry", retryCloudDownloadHandler(svc))
|
||||||
|
admin.POST("/cloud-downloads/:id/retry-pipeline", retryCloudDownloadPipelineHandler(svc))
|
||||||
|
admin.POST("/cloud-downloads/:id/cancel", cancelCloudDownloadHandler(svc))
|
||||||
|
admin.DELETE("/cloud-downloads/:id", deleteCloudDownloadHandler(svc))
|
||||||
|
admin.POST("/cloud-downloads/clear-finished", clearFinishedCloudDownloadsHandler(svc))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listCloudDownloadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.Query("page"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||||
|
res, err := svc.CloudDownload.List(c.Request.Context(), repository.CloudDownloadQuery{
|
||||||
|
Status: c.Query("status"),
|
||||||
|
SyncPathID: c.Query("sync_path_id"),
|
||||||
|
AccountID: c.Query("account_id"),
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCloudDownloadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req service.CloudDownloadSubmit
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 逐条结果里已经包含失败原因,因此部分失败仍返回 200,前端按 items 展示。
|
||||||
|
res, err := svc.CloudDownload.Submit(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadCloudDownloadTorrentHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, cloudDownloadUploadHardLimit)
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少种子文件(form 字段名 file):" + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "打开种子文件失败:" + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "读取种子文件失败:" + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := svc.CloudDownload.SubmitTorrent(c.Request.Context(), service.CloudDownloadTorrentRequest{
|
||||||
|
AccountID: c.PostForm("account_id"),
|
||||||
|
SyncPathID: c.PostForm("sync_path_id"),
|
||||||
|
FileName: fileHeader.Filename,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloudDownloadQuotaHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
quota, err := svc.CloudDownload.Quota(c.Request.Context(), c.Query("account_id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, quota)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryCloudDownloadHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if err := svc.CloudDownload.Retry(c.Request.Context(), c.Param("id")); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryCloudDownloadPipelineHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if err := svc.CloudDownload.RetryPipeline(c.Request.Context(), c.Param("id")); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelCloudDownloadHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if err := svc.CloudDownload.Cancel(c.Request.Context(), c.Param("id")); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCloudDownloadHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if err := svc.CloudDownload.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearFinishedCloudDownloadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
n, err := svc.CloudDownload.ClearFinished(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
|
|||||||
registerAdminAPIConfigRoutes(admin, svc)
|
registerAdminAPIConfigRoutes(admin, svc)
|
||||||
registerAdminRecognitionWordRoutes(admin, svc)
|
registerAdminRecognitionWordRoutes(admin, svc)
|
||||||
registerAdminStrmRoutes(admin, svc)
|
registerAdminStrmRoutes(admin, svc)
|
||||||
|
registerAdminCloudDownloadRoutes(admin, svc)
|
||||||
registerAdminScraperRoutes(admin, svc)
|
registerAdminScraperRoutes(admin, svc)
|
||||||
registerAdminDatabaseRoutes(admin, svc)
|
registerAdminDatabaseRoutes(admin, svc)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// 115 云下载(离线下载)任务数据模型。
|
||||||
|
//
|
||||||
|
// 一条 CloudDownloadTask 表示用户提交的一个离线下载来源(磁力 / BT 种子 /
|
||||||
|
// ed2k / HTTP),并记录它在「115 下载 → STRM 同步 → 媒体库扫描 → 刮削」
|
||||||
|
// 链路上的位置。下载状态(Status)与后处理阶段(Stage)刻意分成两组字段:
|
||||||
|
// STRM 同步或刮削失败不能把 115 侧的「已下载成功」改写掉,用户重做后处理时
|
||||||
|
// 也不需要重新下载。
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 云下载任务状态(115 离线下载侧)。
|
||||||
|
const (
|
||||||
|
CloudDownloadQueued = "queued" // 本地已建行,尚未提交 115
|
||||||
|
CloudDownloadSubmitted = "submitted" // 115 已受理
|
||||||
|
CloudDownloadDownloading = "downloading" // 115 正在下载(status 0/1)
|
||||||
|
CloudDownloadSucceeded = "succeeded" // 115 下载完成(status 2)
|
||||||
|
CloudDownloadFailed = "failed" // 提交被拒 / status -1 / 超时
|
||||||
|
CloudDownloadCanceled = "canceled" // 用户取消
|
||||||
|
)
|
||||||
|
|
||||||
|
// 云下载后处理阶段(STRM 同步 → 扫描 → 刮削)。
|
||||||
|
const (
|
||||||
|
CloudDownloadStagePending = "pending" // 尚未开始(下载未成功)
|
||||||
|
CloudDownloadStageWaitingSync = "waiting_sync" // 已成功,等待去抖窗口或等待目录空闲
|
||||||
|
CloudDownloadStageSyncing = "syncing" // STRM 增量同步中
|
||||||
|
CloudDownloadStageScanning = "scanning" // 媒体库扫描中
|
||||||
|
CloudDownloadStageScraping = "scraping" // 刮削已入队
|
||||||
|
CloudDownloadStageDone = "done" // 全链路完成
|
||||||
|
CloudDownloadStageNA = "na" // 无法继续(同步目录/媒体库不存在),不算失败
|
||||||
|
CloudDownloadStageFailed = "failed" // 阶段失败,可用「重做后处理」重试
|
||||||
|
)
|
||||||
|
|
||||||
|
// 云下载来源类型。
|
||||||
|
const (
|
||||||
|
CloudDownloadSourceMagnet = "magnet"
|
||||||
|
CloudDownloadSourceTorrent = "torrent"
|
||||||
|
CloudDownloadSourceED2K = "ed2k"
|
||||||
|
CloudDownloadSourceHTTP = "http"
|
||||||
|
CloudDownloadSourceFTP = "ftp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CloudDownloadTask 是一条云下载任务。
|
||||||
|
type CloudDownloadTask struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
AccountID string `gorm:"size:36;index" json:"account_id"` // 115 网盘账号(model.StrmAccount)
|
||||||
|
SyncPathID string `gorm:"size:36;index" json:"sync_path_id"` // 绑定的 STRM 同步目录
|
||||||
|
LibraryID string `gorm:"size:36;index" json:"library_id"` // 由本地目录反查得到,可为空
|
||||||
|
Provider string `gorm:"size:32" json:"provider"` // 固定 cloud115
|
||||||
|
|
||||||
|
SourceType string `gorm:"size:16;index" json:"source_type"` // CloudDownloadSource*
|
||||||
|
SourceURL string `gorm:"type:text" json:"source_url"` // 原始链接;.torrent 时存上传的文件名
|
||||||
|
// SourceKey 是轮询匹配用的归一化键:BT 类任务存大写 info_hash,链接类任务
|
||||||
|
// 存 trim 后的 url。提交时即写入,避免每轮重新计算。
|
||||||
|
SourceKey string `gorm:"size:512;index" json:"source_key"`
|
||||||
|
InfoHash string `gorm:"size:64;index" json:"info_hash,omitempty"`
|
||||||
|
|
||||||
|
TorrentName string `gorm:"size:512" json:"torrent_name,omitempty"`
|
||||||
|
TorrentSha1 string `gorm:"size:64" json:"torrent_sha1,omitempty"` // .torrent 提交路径用
|
||||||
|
PickCode string `gorm:"size:128" json:"pick_code,omitempty"` // .torrent 提交路径用
|
||||||
|
|
||||||
|
WpPathID string `gorm:"size:64" json:"wp_path_id"` // 115 目标目录 ID(= syncPath.RemotePath)
|
||||||
|
SavePath string `gorm:"size:512" json:"save_path,omitempty"` // 相对 wp_path_id 的子路径(预留)
|
||||||
|
|
||||||
|
Status string `gorm:"size:16;index" json:"status"` // CloudDownload*
|
||||||
|
Percent int `json:"percent"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
Error string `gorm:"size:1024" json:"error"`
|
||||||
|
|
||||||
|
Stage string `gorm:"size:16;index" json:"stage"` // CloudDownloadStage*
|
||||||
|
StageError string `gorm:"size:1024" json:"stage_error"`
|
||||||
|
StrmRecordID string `gorm:"size:36" json:"strm_record_id,omitempty"`
|
||||||
|
ScanAdded int `json:"scan_added"`
|
||||||
|
ScrapeQueued int `json:"scrape_queued"`
|
||||||
|
|
||||||
|
RetryCount int `json:"retry_count"`
|
||||||
|
LastPollAt *time.Time `json:"last_poll_at,omitempty"`
|
||||||
|
NextPollAt *time.Time `gorm:"index" json:"next_poll_at,omitempty"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ func AllModels() []interface{} {
|
|||||||
&StrmDownloadTask{},
|
&StrmDownloadTask{},
|
||||||
&StrmUploadTask{},
|
&StrmUploadTask{},
|
||||||
&StrmDirCache{},
|
&StrmDirCache{},
|
||||||
|
&CloudDownloadTask{},
|
||||||
&ScrapeTask{},
|
&ScrapeTask{},
|
||||||
&BackgroundTaskRecord{},
|
&BackgroundTaskRecord{},
|
||||||
&EmbyMount{},
|
&EmbyMount{},
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CloudDownloadRepository persists model.CloudDownloadTask(115 云下载任务)。
|
||||||
|
type CloudDownloadRepository struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
// CloudDownloadQuery 是任务列表的过滤条件。
|
||||||
|
type CloudDownloadQuery struct {
|
||||||
|
Status string // 精确匹配;空表示全部
|
||||||
|
SyncPathID string
|
||||||
|
AccountID string
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 插入一条云下载任务。
|
||||||
|
func (r *CloudDownloadRepository) Create(ctx context.Context, t *model.CloudDownloadTask) error {
|
||||||
|
return withSQLiteBusyRetry(ctx, func() error {
|
||||||
|
return r.db.WithContext(ctx).Create(t).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新任务的可变字段(不含主键与创建时间)。
|
||||||
|
func (r *CloudDownloadRepository) Update(ctx context.Context, t *model.CloudDownloadTask) error {
|
||||||
|
return r.UpdateFields(ctx, t.ID, map[string]any{
|
||||||
|
"account_id": t.AccountID,
|
||||||
|
"sync_path_id": t.SyncPathID,
|
||||||
|
"library_id": t.LibraryID,
|
||||||
|
"provider": t.Provider,
|
||||||
|
"source_type": t.SourceType,
|
||||||
|
"source_url": t.SourceURL,
|
||||||
|
"source_key": t.SourceKey,
|
||||||
|
"info_hash": t.InfoHash,
|
||||||
|
"torrent_name": t.TorrentName,
|
||||||
|
"torrent_sha1": t.TorrentSha1,
|
||||||
|
"pick_code": t.PickCode,
|
||||||
|
"wp_path_id": t.WpPathID,
|
||||||
|
"save_path": t.SavePath,
|
||||||
|
"status": t.Status,
|
||||||
|
"percent": t.Percent,
|
||||||
|
"size_bytes": t.SizeBytes,
|
||||||
|
"error": t.Error,
|
||||||
|
"stage": t.Stage,
|
||||||
|
"stage_error": t.StageError,
|
||||||
|
"strm_record_id": t.StrmRecordID,
|
||||||
|
"scan_added": t.ScanAdded,
|
||||||
|
"scrape_queued": t.ScrapeQueued,
|
||||||
|
"retry_count": t.RetryCount,
|
||||||
|
"last_poll_at": t.LastPollAt,
|
||||||
|
"next_poll_at": t.NextPollAt,
|
||||||
|
"completed_at": t.CompletedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFields 局部更新:调用方只写自己关心的列,避免覆盖并发写入的其它阶段字段
|
||||||
|
// (轮询线程写 Status,后处理线程写 Stage,两者必须互不覆盖)。
|
||||||
|
func (r *CloudDownloadRepository) UpdateFields(ctx context.Context, id string, fields map[string]any) error {
|
||||||
|
if id == "" || len(fields) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fields["updated_at"] = time.Now()
|
||||||
|
return withSQLiteBusyRetry(ctx, func() error {
|
||||||
|
return r.db.WithContext(ctx).Model(&model.CloudDownloadTask{}).Where("id = ?", id).Updates(fields).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID 按主键查询,未找到返回 (nil, nil)。
|
||||||
|
func (r *CloudDownloadRepository) FindByID(ctx context.Context, id string) (*model.CloudDownloadTask, error) {
|
||||||
|
var t model.CloudDownloadTask
|
||||||
|
err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询任务列表,同时返回总数。
|
||||||
|
func (r *CloudDownloadRepository) List(ctx context.Context, q CloudDownloadQuery) ([]model.CloudDownloadTask, int64, error) {
|
||||||
|
db := r.db.WithContext(ctx).Model(&model.CloudDownloadTask{})
|
||||||
|
if q.Status != "" {
|
||||||
|
db = db.Where("status = ?", q.Status)
|
||||||
|
}
|
||||||
|
if q.SyncPathID != "" {
|
||||||
|
db = db.Where("sync_path_id = ?", q.SyncPathID)
|
||||||
|
}
|
||||||
|
if q.AccountID != "" {
|
||||||
|
db = db.Where("account_id = ?", q.AccountID)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
page := q.Page
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
size := q.PageSize
|
||||||
|
if size <= 0 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
if size > 200 {
|
||||||
|
size = 200
|
||||||
|
}
|
||||||
|
var rows []model.CloudDownloadTask
|
||||||
|
err := db.Order("created_at desc").
|
||||||
|
Offset((page - 1) * size).
|
||||||
|
Limit(size).
|
||||||
|
Find(&rows).Error
|
||||||
|
return rows, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByStatuses 取指定状态的任务(轮询用)。
|
||||||
|
func (r *CloudDownloadRepository) ListByStatuses(ctx context.Context, statuses []string) ([]model.CloudDownloadTask, error) {
|
||||||
|
if len(statuses) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var rows []model.CloudDownloadTask
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Where("status IN ?", statuses).
|
||||||
|
Order("created_at asc").
|
||||||
|
Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBySyncPathAndStage 取某个同步目录下处于指定阶段的任务(后处理推进用)。
|
||||||
|
func (r *CloudDownloadRepository) ListBySyncPathAndStage(ctx context.Context, syncPathID, stage string) ([]model.CloudDownloadTask, error) {
|
||||||
|
var rows []model.CloudDownloadTask
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Where("sync_path_id = ? AND stage = ?", syncPathID, stage).
|
||||||
|
Order("created_at asc").
|
||||||
|
Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPipelineCandidates 取「下载已成功、后处理尚未完成」的任务。
|
||||||
|
func (r *CloudDownloadRepository) ListPipelineCandidates(ctx context.Context) ([]model.CloudDownloadTask, error) {
|
||||||
|
var rows []model.CloudDownloadTask
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Where("status = ? AND stage IN ?", model.CloudDownloadSucceeded,
|
||||||
|
[]string{model.CloudDownloadStagePending, model.CloudDownloadStageWaitingSync}).
|
||||||
|
Order("created_at asc").
|
||||||
|
Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetStaleStages 把上次进程退出时停在中间阶段的任务重置为待推进。
|
||||||
|
// STRM 同步/扫描/刮削都是幂等操作,重启后重新推进是安全的。
|
||||||
|
func (r *CloudDownloadRepository) ResetStaleStages(ctx context.Context) (int64, error) {
|
||||||
|
var affected int64
|
||||||
|
err := withSQLiteBusyRetry(ctx, func() error {
|
||||||
|
res := r.db.WithContext(ctx).Model(&model.CloudDownloadTask{}).
|
||||||
|
Where("stage IN ?", []string{
|
||||||
|
model.CloudDownloadStageSyncing,
|
||||||
|
model.CloudDownloadStageScanning,
|
||||||
|
model.CloudDownloadStageScraping,
|
||||||
|
}).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"stage": model.CloudDownloadStagePending,
|
||||||
|
"stage_error": "",
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
})
|
||||||
|
affected = res.RowsAffected
|
||||||
|
return res.Error
|
||||||
|
})
|
||||||
|
return affected, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindActiveBySourceKeys 返回给定匹配键中仍处于活跃状态的任务键集合。
|
||||||
|
// 用于提交时去重:同一个磁力重复提交只会浪费 115 配额。
|
||||||
|
func (r *CloudDownloadRepository) FindActiveBySourceKeys(ctx context.Context, keys []string) (map[string]bool, error) {
|
||||||
|
out := make(map[string]bool, len(keys))
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var found []string
|
||||||
|
err := r.db.WithContext(ctx).Model(&model.CloudDownloadTask{}).
|
||||||
|
Where("source_key IN ?", keys).
|
||||||
|
Where("status IN ?", []string{
|
||||||
|
model.CloudDownloadQueued,
|
||||||
|
model.CloudDownloadSubmitted,
|
||||||
|
model.CloudDownloadDownloading,
|
||||||
|
}).
|
||||||
|
Pluck("source_key", &found).Error
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for _, k := range found {
|
||||||
|
out[k] = true
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 硬删除一条任务记录。
|
||||||
|
func (r *CloudDownloadRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
return withSQLiteBusyRetry(ctx, func() error {
|
||||||
|
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.CloudDownloadTask{}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearFinished 删除所有「无后续价值」的终态任务(失败/已取消)。
|
||||||
|
// 成功的任务保留:它们承载「本地已生成 strm / 已刮削」的证据,供用户回查。
|
||||||
|
func (r *CloudDownloadRepository) ClearFinished(ctx context.Context) (int64, error) {
|
||||||
|
var affected int64
|
||||||
|
err := withSQLiteBusyRetry(ctx, func() error {
|
||||||
|
res := r.db.WithContext(ctx).Unscoped().
|
||||||
|
Where("status IN ?", []string{
|
||||||
|
model.CloudDownloadFailed,
|
||||||
|
model.CloudDownloadCanceled,
|
||||||
|
}).
|
||||||
|
Delete(&model.CloudDownloadTask{})
|
||||||
|
affected = res.RowsAffected
|
||||||
|
return res.Error
|
||||||
|
})
|
||||||
|
return affected, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountBySyncPath 统计某同步目录下仍在活跃的任务数(前端展示用)。
|
||||||
|
func (r *CloudDownloadRepository) CountBySyncPath(ctx context.Context, syncPathID string) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := r.db.WithContext(ctx).Model(&model.CloudDownloadTask{}).
|
||||||
|
Where("sync_path_id = ?", syncPathID).
|
||||||
|
Where("status IN ?", []string{
|
||||||
|
model.CloudDownloadQueued,
|
||||||
|
model.CloudDownloadSubmitted,
|
||||||
|
model.CloudDownloadDownloading,
|
||||||
|
}).
|
||||||
|
Count(&n).Error
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -123,6 +123,20 @@ func (r *LibraryRepository) ListRoots(ctx context.Context, libraryID string) ([]
|
|||||||
return roots, err
|
return roots, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListEnabledRoots 返回全部已启用媒体库的全部启用路径。
|
||||||
|
// 用于「本地目录 → 媒体库」的反查(云下载完成后只扫相关库,而不是全库重扫)。
|
||||||
|
func (r *LibraryRepository) ListEnabledRoots(ctx context.Context) ([]model.LibraryRoot, error) {
|
||||||
|
if !r.hasLibraryRootsTable() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var roots []model.LibraryRoot
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Where("enabled = ?", true).
|
||||||
|
Order("sort_order asc, created_at asc").
|
||||||
|
Find(&roots).Error
|
||||||
|
return roots, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *LibraryRepository) FindRootByID(ctx context.Context, libraryID, rootID string) (*model.LibraryRoot, error) {
|
func (r *LibraryRepository) FindRootByID(ctx context.Context, libraryID, rootID string) (*model.LibraryRoot, error) {
|
||||||
if !r.hasLibraryRootsTable() {
|
if !r.hasLibraryRootsTable() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type Container struct {
|
|||||||
StrmDownload *StrmDownloadTaskRepository
|
StrmDownload *StrmDownloadTaskRepository
|
||||||
StrmUpload *StrmUploadTaskRepository
|
StrmUpload *StrmUploadTaskRepository
|
||||||
StrmDirCache *StrmDirCacheRepository
|
StrmDirCache *StrmDirCacheRepository
|
||||||
|
CloudDownload *CloudDownloadRepository
|
||||||
ScrapeTask *ScrapeTaskRepository
|
ScrapeTask *ScrapeTaskRepository
|
||||||
EmbyMount *EmbyMountRepository
|
EmbyMount *EmbyMountRepository
|
||||||
Reader *ReaderRepository
|
Reader *ReaderRepository
|
||||||
@@ -69,6 +70,7 @@ func New(db *gorm.DB) *Container {
|
|||||||
StrmDownload: &StrmDownloadTaskRepository{db: db},
|
StrmDownload: &StrmDownloadTaskRepository{db: db},
|
||||||
StrmUpload: &StrmUploadTaskRepository{db: db},
|
StrmUpload: &StrmUploadTaskRepository{db: db},
|
||||||
StrmDirCache: &StrmDirCacheRepository{db: db},
|
StrmDirCache: &StrmDirCacheRepository{db: db},
|
||||||
|
CloudDownload: &CloudDownloadRepository{db: db},
|
||||||
ScrapeTask: &ScrapeTaskRepository{db: db},
|
ScrapeTask: &ScrapeTaskRepository{db: db},
|
||||||
EmbyMount: &EmbyMountRepository{db: db},
|
EmbyMount: &EmbyMountRepository{db: db},
|
||||||
Reader: &ReaderRepository{db: db},
|
Reader: &ReaderRepository{db: db},
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -130,14 +132,24 @@ type RespBase struct {
|
|||||||
|
|
||||||
// doJSON 执行 HTTP 请求并解析为统一响应;带 AccessToken(access=true 时)。
|
// doJSON 执行 HTTP 请求并解析为统一响应;带 AccessToken(access=true 时)。
|
||||||
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int, uas ...string) (*RespBase, error) {
|
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int, uas ...string) (*RespBase, error) {
|
||||||
executor := c.executor
|
|
||||||
if executor == nil {
|
|
||||||
executor = GetGlobalExecutor()
|
|
||||||
}
|
|
||||||
ua := ""
|
ua := ""
|
||||||
if len(uas) > 0 {
|
if len(uas) > 0 {
|
||||||
ua = uas[0]
|
ua = uas[0]
|
||||||
}
|
}
|
||||||
|
return c.doJSONWithBuilder(ctx, func() (*http.Request, error) {
|
||||||
|
return c.buildRequestWithUA(ctx, method, rawURL, form, access, ua)
|
||||||
|
}, access, retries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doJSONWithBuilder 与 doJSON 共用同一套限流、重试、令牌刷新与错误封装逻辑,
|
||||||
|
// 差别只在请求构造方式:urlencoded(doJSON)、multipart(doAuthMultipart)。
|
||||||
|
// 云下载(offline)系列接口要求 multipart/form-data,若各写一套请求循环,
|
||||||
|
// 限流熔断与 token 轮转的处理迟早会漂移。
|
||||||
|
func (c *OpenClient) doJSONWithBuilder(ctx context.Context, build func() (*http.Request, error), access bool, retries int) (*RespBase, error) {
|
||||||
|
executor := c.executor
|
||||||
|
if executor == nil {
|
||||||
|
executor = GetGlobalExecutor()
|
||||||
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt <= retries; attempt++ {
|
for attempt := 0; attempt <= retries; attempt++ {
|
||||||
@@ -146,7 +158,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := c.buildRequestWithUA(ctx, method, rawURL, form, access, ua)
|
req, err := build()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -293,6 +305,45 @@ func (c *OpenClient) buildRequestWithUA(ctx context.Context, method, rawURL stri
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildMultipartRequest 构造 multipart/form-data 请求。
|
||||||
|
// 115 云下载(offline)系列写接口要求 multipart/form-data,与其它接口的
|
||||||
|
// urlencoded 表单不同;这里只支持纯文本字段(不含文件),种子文件走上传接口。
|
||||||
|
func (c *OpenClient) buildMultipartRequest(ctx context.Context, method, rawURL string, fields map[string]string) (*http.Request, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
// 固定字段顺序,便于抓包比对与排障(map 遍历顺序随机)。
|
||||||
|
keys := make([]string, 0, len(fields))
|
||||||
|
for k := range fields {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
if err := w.WriteField(k, fields[k]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, strings.ToUpper(method), rawURL, &buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", DefaultUA)
|
||||||
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
|
if accessToken := c.currentAccessToken(); accessToken != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doAuthMultipart 以 multipart/form-data 发起需要 access_token 的请求。
|
||||||
|
func (c *OpenClient) doAuthMultipart(ctx context.Context, method, rawURL string, fields map[string]string, retries int) (*RespBase, error) {
|
||||||
|
return c.doJSONWithBuilder(ctx, func() (*http.Request, error) {
|
||||||
|
return c.buildMultipartRequest(ctx, method, rawURL, fields)
|
||||||
|
}, true, retries)
|
||||||
|
}
|
||||||
|
|
||||||
// doAuthJSON 带 AccessToken 的业务请求。
|
// doAuthJSON 带 AccessToken 的业务请求。
|
||||||
func (c *OpenClient) doAuthJSON(ctx context.Context, method, rawURL string, form map[string]string, retries int) (*RespBase, error) {
|
func (c *OpenClient) doAuthJSON(ctx context.Context, method, rawURL string, form map[string]string, retries int) (*RespBase, error) {
|
||||||
return c.doJSON(ctx, method, rawURL, form, true, retries)
|
return c.doJSON(ctx, method, rawURL, form, true, retries)
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
// 115 开放平台「云下载」(离线下载)API 封装。
|
||||||
|
//
|
||||||
|
// 文档(仓库内离线副本):115doc/115开放平台/API列表/云下载/
|
||||||
|
//
|
||||||
|
// 能力边界(已核对官方文档,勿凭客户端印象假设):
|
||||||
|
// - add_task_urls 支持 HTTP(S)/FTP/磁力/电驴,一次最多 115 条,无「选择文件」参数;
|
||||||
|
// - 选择文件只能走 BT 路径:torrent(解析已上传种子)→ add_task_bt(wanted 传索引);
|
||||||
|
// - torrent 的入参是「种子文件在 115 的 SHA1 + 提取码」,不接受 magnet。
|
||||||
|
//
|
||||||
|
// 本文件只做协议封装:不碰数据库、不做编排、不判断业务状态,方便单测。
|
||||||
|
package cloud115
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 云下载接口路径。
|
||||||
|
const (
|
||||||
|
OfflinePathAddTaskURLs = "/open/offline/add_task_urls"
|
||||||
|
OfflinePathTorrent = "/open/offline/torrent"
|
||||||
|
OfflinePathAddTaskBT = "/open/offline/add_task_bt"
|
||||||
|
OfflinePathTaskList = "/open/offline/get_task_list"
|
||||||
|
OfflinePathQuotaInfo = "/open/offline/get_quota_info"
|
||||||
|
OfflinePathDelTask = "/open/offline/del_task"
|
||||||
|
OfflinePathClearTask = "/open/offline/clear_task"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OfflineMaxURLsPerBatch 是 add_task_urls 单批链接上限(官方文档:1000011 超 115 个)。
|
||||||
|
const OfflineMaxURLsPerBatch = 115
|
||||||
|
|
||||||
|
// 云下载业务错误码。
|
||||||
|
const (
|
||||||
|
OfflineCodeOK = 0
|
||||||
|
OfflineCodeQuotaUsedUp = 1000012 // 云下载配额已用完
|
||||||
|
OfflineCodeStorageFull = 91006 // 存储空间不足
|
||||||
|
OfflineCodeTorrentMissing = 20018 // 文件不存在或已删除(种子无效)
|
||||||
|
OfflineCodeTooManyURLs = 1000011 // 链接数量超过 115 个
|
||||||
|
OfflineCodeParamError = 990002 // 参数错误
|
||||||
|
OfflineCodeOperationFailed = 980004 // 操作失败,可重试
|
||||||
|
)
|
||||||
|
|
||||||
|
// 云下载任务状态(get_task_list → data.tasks[].status)。
|
||||||
|
const (
|
||||||
|
OfflineTaskStatusFailed = -1 // 下载失败
|
||||||
|
OfflineTaskStatusAllocating = 0 // 分配中
|
||||||
|
OfflineTaskStatusDownloading = 1 // 下载中
|
||||||
|
OfflineTaskStatusDone = 2 // 下载成功
|
||||||
|
)
|
||||||
|
|
||||||
|
// 清空任务的类型(clear_task → flag)。
|
||||||
|
const (
|
||||||
|
OfflineClearDone = 0
|
||||||
|
OfflineClearAll = 1
|
||||||
|
OfflineClearFailed = 2
|
||||||
|
OfflineClearRunning = 3
|
||||||
|
OfflineClearDoneWithFile = 4
|
||||||
|
OfflineClearAllWithFile = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// OfflineTaskURLResult 是 add_task_urls 返回的逐条结果。
|
||||||
|
type OfflineTaskURLResult struct {
|
||||||
|
State bool `json:"state"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
InfoHash string `json:"info_hash"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineTorrentFile 是种子解析结果中的单个文件。
|
||||||
|
type OfflineTorrentFile struct {
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Wanted int `json:"wanted"` // 115 认为该文件是否默认选中
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineTorrentInfo 是 /offline/torrent 的解析结果。
|
||||||
|
type OfflineTorrentInfo struct {
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
Name string `json:"torrent_name"`
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
InfoHash string `json:"info_hash"`
|
||||||
|
Files []OfflineTorrentFile `json:"torrent_filelist"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineTask 是云下载任务列表中的一条任务。
|
||||||
|
type OfflineTask struct {
|
||||||
|
InfoHash string `json:"info_hash"`
|
||||||
|
AddTime int64 `json:"add_time"`
|
||||||
|
PercentDone int `json:"percentDone"` // 注意:官方字段是驼峰,其余为 snake_case
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
LastUpdate int64 `json:"last_update"`
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
DeleteFileID string `json:"delete_file_id"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
WpPathID string `json:"wp_path_id"`
|
||||||
|
Def2 int `json:"def2"`
|
||||||
|
PlayLong int `json:"play_long"`
|
||||||
|
CanAppeal int `json:"can_appeal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineTaskPage 是分页任务列表。
|
||||||
|
type OfflineTaskPage struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageCount int `json:"page_count"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Tasks []OfflineTask `json:"tasks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineQuotaPackage 是某一类配额的使用情况。
|
||||||
|
type OfflineQuotaPackage struct {
|
||||||
|
Surplus int `json:"surplus"`
|
||||||
|
Used int `json:"used"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ExpireInfo []OfflineQuotaExpire `json:"expire_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineQuotaExpire 是配额过期明细。
|
||||||
|
type OfflineQuotaExpire struct {
|
||||||
|
Surplus int `json:"surplus"`
|
||||||
|
ExpireTime int64 `json:"expire_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineQuota 是云下载配额总览。
|
||||||
|
type OfflineQuota struct {
|
||||||
|
Package []OfflineQuotaPackage `json:"package"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Surplus int `json:"surplus"`
|
||||||
|
Used int `json:"used"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineBTRequest 是 add_task_bt 的请求参数。
|
||||||
|
type OfflineBTRequest struct {
|
||||||
|
InfoHash string
|
||||||
|
Wanted string // 半角逗号分隔的文件索引;官方文档:「可以为 0,但不能为空」
|
||||||
|
SavePath string // 相对 wp_path_id 的保存路径,可为空
|
||||||
|
TorrentSha1 string
|
||||||
|
PickCode string
|
||||||
|
WpPathID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTaskURLs 批量添加链接任务(HTTP/FTP/磁力/电驴),单批 ≤115 条。
|
||||||
|
// 返回逐条结果:整体请求成功但个别链接失败是常态,调用方必须逐条判断 State。
|
||||||
|
// 注意:data[].info_hash 只在受理成功时才有值,链接类任务可能为空。
|
||||||
|
func (c *OpenClient) AddTaskURLs(ctx context.Context, urls []string, wpPathID string) ([]OfflineTaskURLResult, error) {
|
||||||
|
cleaned := make([]string, 0, len(urls))
|
||||||
|
for _, u := range urls {
|
||||||
|
if u = strings.TrimSpace(u); u != "" {
|
||||||
|
cleaned = append(cleaned, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cleaned) == 0 {
|
||||||
|
return nil, fmt.Errorf("115: 云下载链接为空")
|
||||||
|
}
|
||||||
|
if len(cleaned) > OfflineMaxURLsPerBatch {
|
||||||
|
return nil, fmt.Errorf("115: 单批云下载链接最多 %d 条,当前 %d 条", OfflineMaxURLsPerBatch, len(cleaned))
|
||||||
|
}
|
||||||
|
if wpPathID = strings.TrimSpace(wpPathID); wpPathID == "" {
|
||||||
|
wpPathID = "0"
|
||||||
|
}
|
||||||
|
fields := map[string]string{
|
||||||
|
"urls": strings.Join(cleaned, "\n"),
|
||||||
|
"wp_path_id": wpPathID,
|
||||||
|
}
|
||||||
|
resp, err := c.doAuthMultipart(ctx, "POST", ProAPIBase+OfflinePathAddTaskURLs, fields, 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var results []OfflineTaskURLResult
|
||||||
|
if err := unmarshalOfflineData(resp, &results); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTorrent 解析已上传到 115 的 BT 种子文件。
|
||||||
|
// torrentSha1/pickCode 必须来自该账号空间内的种子文件(上传接口返回值)。
|
||||||
|
func (c *OpenClient) ParseTorrent(ctx context.Context, torrentSha1, pickCode string) (*OfflineTorrentInfo, error) {
|
||||||
|
torrentSha1 = strings.TrimSpace(torrentSha1)
|
||||||
|
pickCode = strings.TrimSpace(pickCode)
|
||||||
|
if torrentSha1 == "" || pickCode == "" {
|
||||||
|
return nil, fmt.Errorf("115: 解析种子需要 torrent_sha1 与 pick_code")
|
||||||
|
}
|
||||||
|
fields := map[string]string{
|
||||||
|
"torrent_sha1": torrentSha1,
|
||||||
|
"pick_code": pickCode,
|
||||||
|
}
|
||||||
|
resp, err := c.doAuthMultipart(ctx, "POST", ProAPIBase+OfflinePathTorrent, fields, 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var info OfflineTorrentInfo
|
||||||
|
if err := unmarshalOfflineData(resp, &info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTaskBT 根据已解析的种子添加云下载 BT 任务。
|
||||||
|
// wanted 传文件索引(半角逗号分隔);官方文档允许传 "0",但语义未在文档中写明,
|
||||||
|
// 因此调用方应优先传解析结果推导出的索引列表(见 service 层)。
|
||||||
|
func (c *OpenClient) AddTaskBT(ctx context.Context, req OfflineBTRequest) error {
|
||||||
|
infoHash := strings.TrimSpace(req.InfoHash)
|
||||||
|
torrentSha1 := strings.TrimSpace(req.TorrentSha1)
|
||||||
|
pickCode := strings.TrimSpace(req.PickCode)
|
||||||
|
if infoHash == "" || torrentSha1 == "" || pickCode == "" {
|
||||||
|
return fmt.Errorf("115: 添加 BT 任务缺少 info_hash/torrent_sha1/pick_code")
|
||||||
|
}
|
||||||
|
wanted := strings.TrimSpace(req.Wanted)
|
||||||
|
if wanted == "" {
|
||||||
|
wanted = "0"
|
||||||
|
}
|
||||||
|
wpPathID := strings.TrimSpace(req.WpPathID)
|
||||||
|
if wpPathID == "" {
|
||||||
|
wpPathID = "0"
|
||||||
|
}
|
||||||
|
fields := map[string]string{
|
||||||
|
"info_hash": infoHash,
|
||||||
|
"wanted": wanted,
|
||||||
|
"save_path": strings.TrimSpace(req.SavePath),
|
||||||
|
"torrent_sha1": torrentSha1,
|
||||||
|
"pick_code": pickCode,
|
||||||
|
"wp_path_id": wpPathID,
|
||||||
|
}
|
||||||
|
if _, err := c.doAuthMultipart(ctx, "POST", ProAPIBase+OfflinePathAddTaskBT, fields, 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOfflineTaskList 分页获取云下载任务列表(page 从 1 开始)。
|
||||||
|
func (c *OpenClient) GetOfflineTaskList(ctx context.Context, page int) (*OfflineTaskPage, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+OfflinePathTaskList, map[string]string{
|
||||||
|
"page": fmt.Sprint(page),
|
||||||
|
}, 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var list OfflineTaskPage
|
||||||
|
if err := unmarshalOfflineData(resp, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOfflineQuota 获取云下载配额信息。
|
||||||
|
func (c *OpenClient) GetOfflineQuota(ctx context.Context) (*OfflineQuota, error) {
|
||||||
|
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+OfflinePathQuotaInfo, nil, 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var quota OfflineQuota
|
||||||
|
if err := unmarshalOfflineData(resp, "a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return "a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOfflineTask 删除指定云下载任务(按 info_hash)。
|
||||||
|
// delSourceFile=true 时同时删除已下载的源文件。
|
||||||
|
func (c *OpenClient) DeleteOfflineTask(ctx context.Context, infoHash string, delSourceFile bool) error {
|
||||||
|
infoHash = strings.TrimSpace(infoHash)
|
||||||
|
if infoHash == "" {
|
||||||
|
return fmt.Errorf("115: 删除云下载任务需要 info_hash")
|
||||||
|
}
|
||||||
|
del := "0"
|
||||||
|
if delSourceFile {
|
||||||
|
del = "1"
|
||||||
|
}
|
||||||
|
fields := map[string]string{
|
||||||
|
"info_hash": infoHash,
|
||||||
|
"del_source_file": del,
|
||||||
|
}
|
||||||
|
_, err := c.doAuthMultipart(ctx, "POST", ProAPIBase+OfflinePathDelTask, fields, 1)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearOfflineTasks 按类型清空云下载任务。
|
||||||
|
func (c *OpenClient) ClearOfflineTasks(ctx context.Context, flag int) error {
|
||||||
|
fields := map[string]string{"flag": fmt.Sprint(flag)}
|
||||||
|
_, err := c.doAuthMultipart(ctx, "POST", ProAPIBase+OfflinePathClearTask, fields, 1)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmarshalOfflineData 解析 data 字段。115 的 /offline/* 接口在 state=true 时
|
||||||
|
// 仍可能给出空 data(例如 add_task_bt 成功时 data 为空数组),调用方需容忍零值。
|
||||||
|
func unmarshalOfflineData(resp *RespBase, out any) error {
|
||||||
|
if resp == nil || len(resp.Data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Data, out); err != nil {
|
||||||
|
return fmt.Errorf("115: 解析云下载响应失败:%w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package cloud115
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newOfflineTestClient 构造一个指向 mock server 的客户端。
|
||||||
|
// 令牌直接写入内存字段,避免走文件/数据库。
|
||||||
|
func newOfflineTestClient(accessToken string) *OpenClient {
|
||||||
|
return NewOpenClient("test-app", accessToken, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddTaskURLsUsesMultipartAndBatches(t *testing.T) {
|
||||||
|
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != OfflinePathAddTaskURLs {
|
||||||
|
t.Errorf("unexpected path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("unexpected method %s", r.Method)
|
||||||
|
}
|
||||||
|
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data") {
|
||||||
|
t.Errorf("expected multipart content-type, got %q", ct)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") || len(got) <= len("Bearer ") {
|
||||||
|
t.Errorf("unexpected authorization %q", got)
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
|
t.Fatalf("parse multipart: %v", err)
|
||||||
|
}
|
||||||
|
if got := r.FormValue("wp_path_id"); got != "12345" {
|
||||||
|
t.Errorf("wp_path_id = %q, want 12345", got)
|
||||||
|
}
|
||||||
|
urls := r.FormValue("urls")
|
||||||
|
if want := "magnet:?xt=urn:btih:AAA\ned2k://|file|x|1|H|/"; urls != want {
|
||||||
|
t.Errorf("urls = %q, want %q", urls, want)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"state":true,"code":0,"data":[{"state":true,"code":0,"info_hash":"ABC","url":"magnet:?xt=urn:btih:AAA"}]}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
client := newOfflineTestClient("token-abc")
|
||||||
|
res, err := client.AddTaskURLs(context.Background(), []string{
|
||||||
|
" magnet:?xt=urn:btih:AAA ",
|
||||||
|
"ed2k://|file|x|1|H|/",
|
||||||
|
}, "12345")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddTaskURLs: %v", err)
|
||||||
|
}
|
||||||
|
if len(res) != 1 || !res[0].State || res[0].InfoHash != "ABC" {
|
||||||
|
t.Fatalf("unexpected result %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddTaskURLsRejectsOverLimitWithoutRequest(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
})
|
||||||
|
urls := make([]string, OfflineMaxURLsPerBatch+1)
|
||||||
|
for i := range urls {
|
||||||
|
urls[i] = "https://example.com/a"
|
||||||
|
}
|
||||||
|
if _, err := newOfflineTestClient("t").AddTaskURLs(context.Background(), urls, "1"); err == nil {
|
||||||
|
t.Fatal("expected error for over-limit batch")
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("must not call 115 when batch exceeds the documented limit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddTaskURLsSurfacesBusinessError(t *testing.T) {
|
||||||
|
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"state":false,"code":1000012,"message":"云下载配额已用完"}`))
|
||||||
|
})
|
||||||
|
_, err := newOfflineTestClient("t").AddTaskURLs(context.Background(), []string{"magnet:?xt=urn:btih:AAA"}, "1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected business error")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*OpenAPIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *OpenAPIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.Code != OfflineCodeQuotaUsedUp {
|
||||||
|
t.Fatalf("code = %d, want %d", apiErr.Code, OfflineCodeQuotaUsedUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTorrentDecodesFileList(t *testing.T) {
|
||||||
|
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != OfflinePathTorrent {
|
||||||
|
t.Errorf("unexpected path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
|
t.Fatalf("parse multipart: %v", err)
|
||||||
|
}
|
||||||
|
if r.FormValue("torrent_sha1") != "SHA1" || r.FormValue("pick_code") != "PICK" {
|
||||||
|
t.Errorf("unexpected form: %v", r.MultipartForm.Value)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"state":true,"code":0,"data":{
|
||||||
|
"file_size":1024,"torrent_name":"Demo","file_count":2,"info_hash":"HASH",
|
||||||
|
"torrent_filelist":[{"size":512,"path":"a.mkv","wanted":1},{"size":512,"path":"b.srt","wanted":0}]}}`))
|
||||||
|
})
|
||||||
|
info, err := newOfflineTestClient("t").ParseTorrent(context.Background(), "SHA1", "PICK")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTorrent: %v", err)
|
||||||
|
}
|
||||||
|
if info.InfoHash != "HASH" || info.Name != "Demo" || len(info.Files) != 2 {
|
||||||
|
t.Fatalf("unexpected info %+v", info)
|
||||||
|
}
|
||||||
|
if info.Files[0].Path != "a.mkv" || info.Files[0].Size != 512 {
|
||||||
|
t.Fatalf("unexpected file %+v", info.Files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddTaskBTRequiresSeedFields(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
mockAPI(t, func(w http.ResponseWriter, r *http.Request) { called = true })
|
||||||
|
client := newOfflineTestClient("t")
|
||||||
|
if err := client.AddTaskBT(context.Background(), OfflineBTRequest{InfoHash: "H"}); err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("must validate locally before calling 115")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOfflineTaskListParsesCamelCasePercentDone(t *testing.T) {
|
||||||
|
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != OfflinePathTaskList {
|
||||||
|
t.Errorf("unexpected path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("page") != "2" {
|
||||||
|
t.Errorf("page = %q, want 2", r.URL.Query().Get("page"))
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"state":true,"code":0,"data":{"page":2,"page_count":3,"count":30,
|
||||||
|
"tasks":[{"info_hash":"H","percentDone":42,"size":100,"name":"n","status":1,"url":"u","wp_path_id":"9"}]}}`))
|
||||||
|
})
|
||||||
|
page, err := newOfflineTestClient("t").GetOfflineTaskList(context.Background(), 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOfflineTaskList: %v", err)
|
||||||
|
}
|
||||||
|
if page.Page != 2 || page.PageCount != 3 || len(page.Tasks) != 1 {
|
||||||
|
t.Fatalf("unexpected page %+v", page)
|
||||||
|
}
|
||||||
|
// 官方字段是驼峰 percentDone:写成 snake_case 会永远 0,这里必须锁死。
|
||||||
|
if page.Tasks[0].PercentDone != 42 {
|
||||||
|
t.Fatalf("percentDone = %d, want 42", page.Tasks[0].PercentDone)
|
||||||
|
}
|
||||||
|
if page.Tasks[0].Status != OfflineTaskStatusDownloading {
|
||||||
|
t.Fatalf("status = %d", page.Tasks[0].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOfflineQuota(t *testing.T) {
|
||||||
|
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != OfflinePathQuotaInfo {
|
||||||
|
t.Errorf("unexpected path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"state":true,"code":0,"data":{"package":[{"surplus":3,"used":1,"count":4,"name":"p"}],"count":4,"surplus":3,"used":1}}`))
|
||||||
|
})
|
||||||
|
q, err := newOfflineTestClient("t").GetOfflineQuota(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOfflineQuota: %v", err)
|
||||||
|
}
|
||||||
|
if q.Surplus != 3 || q.Count != 4 || len(q.Package) != 1 {
|
||||||
|
t.Fatalf("unexpected quota %+v", q)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
// 115 云下载(离线下载)服务:提交链接、轮询 115 任务、驱动后处理管线
|
||||||
|
// (STRM 增量同步 → 媒体库扫描 → 自动刮削)。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// - 115 协议细节在 internal/service/cloud115/offline.go;
|
||||||
|
// - 本服务只做编排与状态机,不直接写 .strm 文件、不直接解析媒体;
|
||||||
|
// - 115 客户端必须经 StrmService.Open115Client 取得,禁止自建(见该方法注释)。
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 云下载设置键(存于 Setting 表,cloud_download.* 前缀)。
|
||||||
|
const (
|
||||||
|
CloudDownloadSettingPollInterval = "cloud_download.poll_interval_sec"
|
||||||
|
CloudDownloadSettingSyncDebounce = "cloud_download.sync_debounce_sec"
|
||||||
|
CloudDownloadSettingAutoScrape = "cloud_download.auto_scrape"
|
||||||
|
CloudDownloadSettingTimeoutHours = "cloud_download.task_timeout_hours"
|
||||||
|
CloudDownloadSettingTorrentMaxMB = "cloud_download.torrent_max_mb"
|
||||||
|
CloudDownloadSettingMaxPages = "cloud_download.max_pages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 默认值与取值范围。
|
||||||
|
const (
|
||||||
|
cloudDownloadDefaultPollInterval = 60
|
||||||
|
cloudDownloadDefaultDebounce = 30
|
||||||
|
cloudDownloadDefaultTimeoutHours = 72
|
||||||
|
cloudDownloadDefaultTorrentMaxMB = 20
|
||||||
|
cloudDownloadDefaultMaxPages = 10
|
||||||
|
|
||||||
|
cloudDownloadMinPollInterval = 30
|
||||||
|
cloudDownloadMaxPollInterval = 600
|
||||||
|
cloudDownloadMaxDebounce = 300
|
||||||
|
|
||||||
|
// cloudDownloadPipelineTick 是后处理推进的扫描周期(去抖窗口以秒计,
|
||||||
|
// 15s 足够把「等窗口结束」的延迟控制在可接受范围)。
|
||||||
|
cloudDownloadPipelineTick = 15 * time.Second
|
||||||
|
|
||||||
|
// cloudDownloadTorrentTempPrefix 是种子临时文件前缀(上传后立即删除)。
|
||||||
|
cloudDownloadTorrentTempPrefix = "mebox-clouddl-"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CloudDownloadService 提供云下载任务的全生命周期管理。
|
||||||
|
type CloudDownloadService struct {
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
strm *StrmService
|
||||||
|
scanner *ScannerService
|
||||||
|
scraper *ScraperService
|
||||||
|
|
||||||
|
stopOnce sync.Once
|
||||||
|
stopCh chan struct{}
|
||||||
|
baseCtx context.Context
|
||||||
|
|
||||||
|
// 去抖状态:同一同步目录在窗口内的多次完成合并成一次同步。
|
||||||
|
debounceMu sync.Mutex
|
||||||
|
lastTrigger map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCloudDownloadService 构造云下载服务。
|
||||||
|
// strm/scanner/scraper 必须已构造完成(见 service_builder 的装配顺序)。
|
||||||
|
func NewCloudDownloadService(
|
||||||
|
log *zap.Logger,
|
||||||
|
repo *repository.Container,
|
||||||
|
strm *StrmService,
|
||||||
|
scanner *ScannerService,
|
||||||
|
scraper *ScraperService,
|
||||||
|
) *CloudDownloadService {
|
||||||
|
return &CloudDownloadService{
|
||||||
|
log: log,
|
||||||
|
repo: repo,
|
||||||
|
strm: strm,
|
||||||
|
scanner: scanner,
|
||||||
|
scraper: scraper,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
baseCtx: context.Background(),
|
||||||
|
lastTrigger: map[string]time.Time{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start 启动后处理循环,并把上次进程退出时停在中间阶段的任务重置为待推进。
|
||||||
|
func (s *CloudDownloadService) Start(ctx context.Context) {
|
||||||
|
s.baseCtx = ctx
|
||||||
|
if s.repo != nil && s.repo.CloudDownload != nil {
|
||||||
|
if n, err := s.repo.CloudDownload.ResetStaleStages(ctx); err != nil {
|
||||||
|
s.log.Warn("reset stale cloud download stages failed", zap.Error(err))
|
||||||
|
} else if n > 0 {
|
||||||
|
s.log.Info("cloud download stages reset after restart", zap.Int64("count", n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
helper.Go(s.log, "cloudDownload.pipelineLoop", func() {
|
||||||
|
ticker := time.NewTicker(cloudDownloadPipelineTick)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.runPipelineOnce(s.baseCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop 停止后处理循环。
|
||||||
|
func (s *CloudDownloadService) Stop() {
|
||||||
|
s.stopOnce.Do(func() { close(s.stopCh) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 设置读取 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *CloudDownloadService) settingInt(key string, fallback, min, max int) int {
|
||||||
|
if s.repo == nil || s.repo.Setting == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
raw, err := s.repo.Setting.Get(context.Background(), key)
|
||||||
|
if err != nil || strings.TrimSpace(raw) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if v < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if v > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CloudDownloadService) settingBool(key string, fallback bool) bool {
|
||||||
|
if s.repo == nil || s.repo.Setting == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
raw, err := s.repo.Setting.Get(context.Background(), key)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case "":
|
||||||
|
return fallback
|
||||||
|
case "1", "true", "yes", "on", "enabled":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollInterval 轮询 115 任务列表的间隔。
|
||||||
|
func (s *CloudDownloadService) pollInterval() time.Duration {
|
||||||
|
sec := s.settingInt(CloudDownloadSettingPollInterval, cloudDownloadDefaultPollInterval,
|
||||||
|
cloudDownloadMinPollInterval, cloudDownloadMaxPollInterval)
|
||||||
|
return time.Duration(sec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// debounceWindow 同一同步目录的完成去抖窗口。
|
||||||
|
func (s *CloudDownloadService) debounceWindow() time.Duration {
|
||||||
|
sec := s.settingInt(CloudDownloadSettingSyncDebounce, cloudDownloadDefaultDebounce, 0, cloudDownloadMaxDebounce)
|
||||||
|
return time.Duration(sec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoScrapeEnabled 是否在同步完成后自动刮削。
|
||||||
|
func (s *CloudDownloadService) autoScrape() bool {
|
||||||
|
return s.settingBool(CloudDownloadSettingAutoScrape, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskTimeout 下载超时判定。
|
||||||
|
func (s *CloudDownloadService) taskTimeout() time.Duration {
|
||||||
|
hours := s.settingInt(CloudDownloadSettingTimeoutHours, cloudDownloadDefaultTimeoutHours, 1, 24*30)
|
||||||
|
return time.Duration(hours) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// torrentMaxBytes 种子上传大小上限。
|
||||||
|
func (s *CloudDownloadService) torrentMaxBytes() int64 {
|
||||||
|
mb := s.settingInt(CloudDownloadSettingTorrentMaxMB, cloudDownloadDefaultTorrentMaxMB, 1, 200)
|
||||||
|
return int64(mb) << 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxPollPages 单轮轮询最多翻页数。
|
||||||
|
func (s *CloudDownloadService) maxPollPages() int {
|
||||||
|
return s.settingInt(CloudDownloadSettingMaxPages, cloudDownloadDefaultMaxPages, 1, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 查询与管理 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// CloudDownloadTaskView 是列表返回的任务视图(附带展示用的派生字段)。
|
||||||
|
type CloudDownloadTaskView struct {
|
||||||
|
model.CloudDownloadTask
|
||||||
|
SyncPathName string `json:"sync_path_name,omitempty"`
|
||||||
|
RemoteDisplayPath string `json:"remote_display_path,omitempty"`
|
||||||
|
LocalPath string `json:"local_path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloudDownloadList 是分页列表结果。
|
||||||
|
type CloudDownloadList struct {
|
||||||
|
Items []CloudDownloadTaskView `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询云下载任务,并补充同步目录的展示信息。
|
||||||
|
func (s *CloudDownloadService) List(ctx context.Context, q repository.CloudDownloadQuery) (*CloudDownloadList, error) {
|
||||||
|
rows, total, err := s.repo.CloudDownload.List(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paths := map[string]*model.StrmSyncPath{}
|
||||||
|
items := make([]CloudDownloadTaskView, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
v := CloudDownloadTaskView{CloudDownloadTask: rows[i]}
|
||||||
|
if p, ok := paths[rows[i].SyncPathID]; ok {
|
||||||
|
if p != nil {
|
||||||
|
v.SyncPathName, v.RemoteDisplayPath, v.LocalPath = p.Name, p.RemoteDisplayPath, p.LocalPath
|
||||||
|
}
|
||||||
|
} else if s.repo.StrmSyncPath != nil {
|
||||||
|
p, err := s.repo.StrmSyncPath.FindByID(ctx, rows[i].SyncPathID)
|
||||||
|
if err == nil {
|
||||||
|
paths[rows[i].SyncPathID] = p
|
||||||
|
if p != nil {
|
||||||
|
v.SyncPathName, v.RemoteDisplayPath, v.LocalPath = p.Name, p.RemoteDisplayPath, p.LocalPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, v)
|
||||||
|
}
|
||||||
|
page := q.Page
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
pageSize := q.PageSize
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
return &CloudDownloadList{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 按 ID 取单条任务。
|
||||||
|
func (s *CloudDownloadService) Get(ctx context.Context, id string) (*model.CloudDownloadTask, error) {
|
||||||
|
t, err := s.repo.CloudDownload.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if t == nil {
|
||||||
|
return nil, errors.New("云下载任务不存在")
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel 取消任务:先尽力删除 115 侧任务,再把本地状态置为已取消。
|
||||||
|
// 115 已成功下载的任务无法取消(文件已入库),只能删除记录。
|
||||||
|
func (s *CloudDownloadService) Cancel(ctx context.Context, id string) error {
|
||||||
|
t, err := s.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if isCloudDownloadTerminal(t.Status) {
|
||||||
|
return errors.New("任务已结束,无法取消")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.InfoHash) != "" {
|
||||||
|
client, cerr := s.strm.Open115Client(ctx, t.AccountID)
|
||||||
|
if cerr != nil {
|
||||||
|
s.log.Warn("cloud download cancel: open 115 client failed",
|
||||||
|
zap.String("task_id", t.ID), zap.Error(cerr))
|
||||||
|
} else if derr := client.DeleteOfflineTask(ctx, t.InfoHash, false); derr != nil {
|
||||||
|
// 远端删除失败不阻断本地取消:否则用户永远清不掉一条僵尸记录。
|
||||||
|
s.log.Warn("cloud download cancel: delete remote task failed",
|
||||||
|
zap.String("task_id", t.ID), zap.String("info_hash", t.InfoHash), zap.Error(derr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
|
||||||
|
"status": model.CloudDownloadCanceled,
|
||||||
|
"stage": model.CloudDownloadStageNA,
|
||||||
|
"stage_error": "任务已取消",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry 重新提交失败任务:链接类重新走提交接口,BT 类用已保存的种子参数重提。
|
||||||
|
func (s *CloudDownloadService) Retry(ctx context.Context, id string) error {
|
||||||
|
t, err := s.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if t.Status != model.CloudDownloadFailed {
|
||||||
|
return errors.New("只有失败的任务可以重试")
|
||||||
|
}
|
||||||
|
if t.SourceType == model.CloudDownloadSourceTorrent {
|
||||||
|
if strings.TrimSpace(t.InfoHash) == "" || strings.TrimSpace(t.TorrentSha1) == "" || strings.TrimSpace(t.PickCode) == "" {
|
||||||
|
return errors.New("该种子任务的提交参数不完整,请重新上传 .torrent 文件")
|
||||||
|
}
|
||||||
|
client, err := s.strm.Open115Client(ctx, t.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := client.AddTaskBT(ctx, cloud115.OfflineBTRequest{
|
||||||
|
InfoHash: t.InfoHash,
|
||||||
|
Wanted: "0",
|
||||||
|
TorrentSha1: t.TorrentSha1,
|
||||||
|
PickCode: t.PickCode,
|
||||||
|
WpPathID: t.WpPathID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
|
||||||
|
"status": model.CloudDownloadSubmitted,
|
||||||
|
"error": "",
|
||||||
|
"retry_count": t.RetryCount + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.SourceURL) == "" {
|
||||||
|
return errors.New("该任务缺少原始链接,无法重试")
|
||||||
|
}
|
||||||
|
// 复用提交链路:新建一条记录会让历史重复,这里直接把原记录复位再提交。
|
||||||
|
if err := s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
|
||||||
|
"status": model.CloudDownloadQueued,
|
||||||
|
"error": "",
|
||||||
|
"percent": 0,
|
||||||
|
"retry_count": t.RetryCount + 1,
|
||||||
|
"stage": model.CloudDownloadStagePending,
|
||||||
|
"stage_error": "",
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res := s.submitLinks(ctx, t.AccountID, t.SyncPathID, t.WpPathID, []cloudDownloadParsedLink{{
|
||||||
|
SourceType: t.SourceType,
|
||||||
|
URL: t.SourceURL,
|
||||||
|
Key: t.SourceKey,
|
||||||
|
}}, t.ID)
|
||||||
|
if len(res.Items) == 1 && !res.Items[0].OK {
|
||||||
|
return errors.New(res.Items[0].Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryPipeline 重做失败的后处理阶段(不重新下载)。
|
||||||
|
func (s *CloudDownloadService) RetryPipeline(ctx context.Context, id string) error {
|
||||||
|
t, err := s.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if t.Status != model.CloudDownloadSucceeded {
|
||||||
|
return errors.New("只有下载成功的任务可以重做后处理")
|
||||||
|
}
|
||||||
|
if t.Stage != model.CloudDownloadStageFailed && t.Stage != model.CloudDownloadStageNA {
|
||||||
|
return errors.New("该任务的后处理未处于失败状态")
|
||||||
|
}
|
||||||
|
if err := s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
|
||||||
|
"stage": model.CloudDownloadStagePending,
|
||||||
|
"stage_error": "",
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
helper.Go(s.log, "cloudDownload.retryPipeline", func() {
|
||||||
|
s.runPipelineOnce(s.baseCtx)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除任务记录。活跃任务必须先取消,避免留下无人追问的 115 任务。
|
||||||
|
func (s *CloudDownloadService) Delete(ctx context.Context, id string) error {
|
||||||
|
t, err := s.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isCloudDownloadTerminal(t.Status) {
|
||||||
|
return errors.New("任务进行中,请先取消再删除")
|
||||||
|
}
|
||||||
|
return s.repo.CloudDownload.Delete(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearFinished 清空失败/已取消的任务。
|
||||||
|
func (s *CloudDownloadService) ClearFinished(ctx context.Context) (int64, error) {
|
||||||
|
return s.repo.CloudDownload.ClearFinished(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quota 查询当前账号的云下载配额。
|
||||||
|
func (s *CloudDownloadService) Quota(ctx context.Context, accountID string) (*cloud115.OfflineQuota, error) {
|
||||||
|
client, err := s.strm.Open115Client(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client.GetOfflineQuota(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 工具 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// isCloudDownloadTerminal 判断任务是否已到终态。
|
||||||
|
func isCloudDownloadTerminal(status string) bool {
|
||||||
|
switch status {
|
||||||
|
case model.CloudDownloadSucceeded, model.CloudDownloadFailed, model.CloudDownloadCanceled:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// offlineErrorMessage 把 115 错误码翻译成可操作的中文提示。
|
||||||
|
func offlineErrorMessage(code int, message string) string {
|
||||||
|
switch code {
|
||||||
|
case cloud115.OfflineCodeQuotaUsedUp:
|
||||||
|
return "115 云下载配额已用尽,请在 115 端获取更多配额后重试"
|
||||||
|
case cloud115.OfflineCodeStorageFull:
|
||||||
|
return "115 存储空间不足"
|
||||||
|
case cloud115.OfflineCodeTorrentMissing:
|
||||||
|
return "115 找不到该种子文件(可能已被清理),请重新上传 .torrent"
|
||||||
|
case cloud115.OfflineCodeTooManyURLs:
|
||||||
|
return "单批链接超过 115 条上限"
|
||||||
|
case cloud115.OfflineCodeParamError:
|
||||||
|
return "115 拒绝了提交参数:" + message
|
||||||
|
case cloud115.OfflineCodeOperationFailed:
|
||||||
|
return "115 操作失败,请稍后重试"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(message) != "" {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("115 返回错误码 %d", code)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// 云下载输入解析:把用户粘贴的多行文本拆成可提交的链接,并生成轮询匹配键。
|
||||||
|
//
|
||||||
|
// 支持的来源(与 115 /open/offline/add_task_urls 能力一致):
|
||||||
|
// - 磁力:magnet:?xt=urn:btih:<hex40 或 base32>
|
||||||
|
// - 电驴:ed2k://|file|...
|
||||||
|
// - HTTP(S) / FTP 直链
|
||||||
|
//
|
||||||
|
// 只做「字符串层面」的识别,绝不主动请求这些 URL(无 SSRF 面)。
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cloudDownloadParsedLink 是一条解析后的待提交链接。
|
||||||
|
type cloudDownloadParsedLink struct {
|
||||||
|
SourceType string // model.CloudDownloadSource*
|
||||||
|
URL string // 原始(trim 后)链接
|
||||||
|
Key string // 去重与轮询匹配键:BT 类为大写 info_hash,其余为链接本身
|
||||||
|
InfoHash string // 仅 BT 类有值
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloudDownloadSkipped 是一条被跳过的原始输入。
|
||||||
|
type cloudDownloadSkipped struct {
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCloudDownloadLinks 解析多行输入。
|
||||||
|
// 返回去重后的有效链接与被跳过的行(用于前端逐条反馈,而不是笼统报错)。
|
||||||
|
func parseCloudDownloadLinks(raw []string) ([]cloudDownloadParsedLink, []cloudDownloadSkipped) {
|
||||||
|
out := make([]cloudDownloadParsedLink, 0, len(raw))
|
||||||
|
skipped := make([]cloudDownloadSkipped, 0)
|
||||||
|
seen := make(map[string]bool, len(raw))
|
||||||
|
|
||||||
|
for _, line := range raw {
|
||||||
|
// 允许多行文本整体粘贴:按换行再拆一次。
|
||||||
|
for _, part := range strings.Split(line, "\n") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
link, reason := parseCloudDownloadLink(part)
|
||||||
|
if reason != "" {
|
||||||
|
skipped = append(skipped, cloudDownloadSkipped{Raw: part, Reason: reason})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[link.Key] {
|
||||||
|
skipped = append(skipped, cloudDownloadSkipped{Raw: part, Reason: "重复链接(本批已存在)"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[link.Key] = true
|
||||||
|
out = append(out, link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCloudDownloadLink 识别单条链接;reason 非空表示不支持。
|
||||||
|
func parseCloudDownloadLink(raw string) (cloudDownloadParsedLink, string) {
|
||||||
|
lower := strings.ToLower(raw)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, "magnet:"):
|
||||||
|
hash := magnetInfoHash(raw)
|
||||||
|
if hash == "" {
|
||||||
|
return cloudDownloadParsedLink{}, "磁力链接缺少有效的 btih 哈希"
|
||||||
|
}
|
||||||
|
return cloudDownloadParsedLink{
|
||||||
|
SourceType: "magnet",
|
||||||
|
URL: raw,
|
||||||
|
Key: hash,
|
||||||
|
InfoHash: hash,
|
||||||
|
}, ""
|
||||||
|
case strings.HasPrefix(lower, "ed2k://"):
|
||||||
|
return cloudDownloadParsedLink{
|
||||||
|
SourceType: "ed2k",
|
||||||
|
URL: raw,
|
||||||
|
Key: raw,
|
||||||
|
}, ""
|
||||||
|
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
|
||||||
|
return cloudDownloadParsedLink{
|
||||||
|
SourceType: "http",
|
||||||
|
URL: raw,
|
||||||
|
Key: raw,
|
||||||
|
}, ""
|
||||||
|
case strings.HasPrefix(lower, "ftp://"):
|
||||||
|
return cloudDownloadParsedLink{
|
||||||
|
SourceType: "ftp",
|
||||||
|
URL: raw,
|
||||||
|
Key: raw,
|
||||||
|
}, ""
|
||||||
|
default:
|
||||||
|
return cloudDownloadParsedLink{}, "不支持的链接类型(仅支持磁力 / ed2k / http(s) / ftp)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// magnetInfoHash 从磁力链接中提取大写化的 btih 哈希。
|
||||||
|
// 兼容 hex(40 字符)与 base32(32 字符)两种写法;无法识别时返回空串。
|
||||||
|
func magnetInfoHash(link string) string {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(link))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, xt := range u.Query()["xt"] {
|
||||||
|
const prefix = "urn:btih:"
|
||||||
|
if len(xt) <= len(prefix) || !strings.EqualFold(xt[:len(prefix)], prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hash := strings.TrimSpace(xt[len(prefix):])
|
||||||
|
// 去掉可能存在的 &dn= 之类残留(少数客户端拼接不规范)。
|
||||||
|
if idx := strings.IndexAny(hash, "&?#"); idx >= 0 {
|
||||||
|
hash = hash[:idx]
|
||||||
|
}
|
||||||
|
if len(hash) != 40 && len(hash) != 32 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return strings.ToUpper(hash)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// 云下载后处理管线:下载成功 → STRM 增量同步 → 媒体库扫描 → 自动刮削。
|
||||||
|
//
|
||||||
|
// 与 STRM 同步的耦合方式:
|
||||||
|
// - 这里只负责「发起同步」,不知道同步内部怎么走;
|
||||||
|
// - StrmService.finishSync 通过 SetSyncFinishHook 回调 onStrmSyncFinished,
|
||||||
|
// 由后者把 syncing 状态推进到 scanning/done。
|
||||||
|
//
|
||||||
|
// 去抖:bench 上常见一场「一次提交 20 条链接」,如果每条完成都触发一次全树
|
||||||
|
// 同步(walk115Flat 是整树自适应拉取),115 限流与本地 IO 都会被打满。
|
||||||
|
// 因此同一同步目录在 debounce 窗口内只发起一次同步,其余任务停在 waiting_sync。
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runPipelineOnce 推进所有待处理任务。由 Start 里的 ticker 周期调用,也可手动触发。
|
||||||
|
func (s *CloudDownloadService) runPipelineOnce(ctx context.Context) {
|
||||||
|
if s.repo == nil || s.repo.CloudDownload == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidates, err := s.repo.CloudDownload.ListPipelineCandidates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("cloud download pipeline: list candidates failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groups := map[string][]model.CloudDownloadTask{}
|
||||||
|
for _, t := range candidates {
|
||||||
|
groups[t.SyncPathID] = append(groups[t.SyncPathID], t)
|
||||||
|
}
|
||||||
|
for syncPathID, tasks := range groups {
|
||||||
|
s.advanceSyncPath(ctx, syncPathID, tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceSyncPath 推进一个同步目录下的待处理任务。
|
||||||
|
func (s *CloudDownloadService) advanceSyncPath(ctx context.Context, syncPathID string, tasks []model.CloudDownloadTask) {
|
||||||
|
path, err := s.repo.StrmSyncPath.FindByID(ctx, syncPathID)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("cloud download pipeline: load sync path failed",
|
||||||
|
zap.String("path_id", syncPathID), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if path == nil || !path.Enabled || path.Provider != model.StrmProvider115 {
|
||||||
|
s.failStage(ctx, tasks, "同步目录不存在、已禁用或不是 115 网盘目录", model.CloudDownloadStageNA)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步进行中:本轮只标记等待,避免把 StartSync 的「正在同步中」错误打成失败。
|
||||||
|
if s.strm != nil && s.strm.IsSyncRunning(syncPathID) {
|
||||||
|
s.markWaiting(ctx, tasks)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去抖窗口内不重复发起同步。
|
||||||
|
if !s.debounceElapsed(syncPathID) {
|
||||||
|
s.markWaiting(ctx, tasks)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.strm == nil {
|
||||||
|
s.failStage(ctx, tasks, "STRM 同步服务不可用", model.CloudDownloadStageFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.strm.StartSync(ctx, syncPathID); err != nil {
|
||||||
|
// 最常见的竞态:刚判断完不忙,另一个人(或 cron)抢先启动。
|
||||||
|
if strings.Contains(err.Error(), "正在同步中") {
|
||||||
|
s.markWaiting(ctx, tasks)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.failStage(ctx, tasks, "触发 STRM 同步失败:"+err.Error(), model.CloudDownloadStageFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.noteTrigger(syncPathID)
|
||||||
|
for _, t := range tasks {
|
||||||
|
_ = s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
|
||||||
|
"stage": model.CloudDownloadStageSyncing,
|
||||||
|
"stage_error": "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onStrmSyncFinished 是 StrmService 的同步完成回调。
|
||||||
|
// 只处理「因云下载而发起且当前处于 syncing」的任务:手动/Cron 同步也会触发
|
||||||
|
// 这个回调,没有云下载任务时直接返回。
|
||||||
|
func (s *CloudDownloadService) onStrmSyncFinished(ctx context.Context, syncPathID, status string) {
|
||||||
|
if s.repo == nil || s.repo.CloudDownload == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tasks, err := s.repo.CloudDownload.ListBySyncPathAndStage(ctx, syncPathID, model.CloudDownloadStageSyncing)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("cloud download pipeline: list syncing tasks failed",
|
||||||
|
zap.String("path_id", syncPathID), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status != model.StrmSyncRecordDone {
|
||||||
|
for _, t := range tasks {
|
||||||
|
_ = s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
|
||||||
|
"stage": model.CloudDownloadStageFailed,
|
||||||
|
"stage_error": "STRM 同步" + strmStatusLabel(status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 同步失败也要记一次触发时间,给用户一个冷静期,避免立即重试形成惊吓式循环。
|
||||||
|
s.noteTrigger(syncPathID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := s.repo.StrmSyncPath.FindByID(ctx, syncPathID)
|
||||||
|
if err != nil || path == nil {
|
||||||
|
s.failStage(ctx, tasks, "同步目录已被删除", model.CloudDownloadStageNA)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root := s.matchLibraryRoot(ctx, path.LocalPath)
|
||||||
|
if root == nil {
|
||||||
|
s.failStage(ctx, tasks, "STRM 输出目录未加入任何媒体库(本地目录:"+path.LocalPath+")", model.CloudDownloadStageNA)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range tasks {
|
||||||
|
_ = s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
|
||||||
|
"stage": model.CloudDownloadStageScanning,
|
||||||
|
"library_id": root.LibraryID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
scanAdded, scanErr := s.scanLibrary(ctx, root.LibraryID)
|
||||||
|
if scanErr != nil {
|
||||||
|
s.failStage(ctx, tasks, "扫描媒体库失败:"+scanErr.Error(), model.CloudDownloadStageFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrapeQueued := 0
|
||||||
|
if s.autoScrape() && s.scraper != nil && s.scraper.AnyEnabled() {
|
||||||
|
// EnqueueLibrary 默认只挑 scrape_status 为空的 pending 行,
|
||||||
|
// 因此全库入队不会重刷已刮好的条目。
|
||||||
|
if n, err := s.scraper.EnqueueLibrary(ctx, root.LibraryID, ScrapeOptions{}); err != nil {
|
||||||
|
s.log.Warn("cloud download pipeline: enqueue scrape failed",
|
||||||
|
zap.String("library_id", root.LibraryID), zap.Error(err))
|
||||||
|
} else {
|
||||||
|
scrapeQueued = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stageError := ""
|
||||||
|
if s.autoScrape() && (s.scraper == nil || !s.scraper.AnyEnabled()) {
|
||||||
|
stageError = "未配置任何刮削源,已跳过自动刮削"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range tasks {
|
||||||
|
_ = s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
|
||||||
|
"stage": model.CloudDownloadStageDone,
|
||||||
|
"stage_error": stageError,
|
||||||
|
"library_id": root.LibraryID,
|
||||||
|
"scan_added": scanAdded,
|
||||||
|
"scrape_queued": scrapeQueued,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
s.log.Info("cloud download pipeline finished",
|
||||||
|
zap.String("path_id", syncPathID),
|
||||||
|
zap.String("library_id", root.LibraryID),
|
||||||
|
zap.Int("tasks", len(tasks)),
|
||||||
|
zap.Int("scan_added", scanAdded),
|
||||||
|
zap.Int("scrape_queued", scrapeQueued))
|
||||||
|
}
|
||||||
|
|
||||||
|
// markWaiting 把任务置为等待同步。
|
||||||
|
func (s *CloudDownloadService) markWaiting(ctx context.Context, tasks []model.CloudDownloadTask) {
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.Stage == model.CloudDownloadStageWaitingSync {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
|
||||||
|
"stage": model.CloudDownloadStageWaitingSync,
|
||||||
|
"stage_error": "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failStage 把任务置为阶段失败/跳过。
|
||||||
|
func (s *CloudDownloadService) failStage(ctx context.Context, tasks []model.CloudDownloadTask, msg, stage string) {
|
||||||
|
for _, t := range tasks {
|
||||||
|
_ = s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
|
||||||
|
"stage": stage,
|
||||||
|
"stage_error": msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchLibraryRoot 按 STRM 本地输出目录反查媒体库根路径。
|
||||||
|
// 命中条件:路径相同,或 localPath 位于 root.Path 之下(同一块盘的子目录也算)。
|
||||||
|
func (s *CloudDownloadService) matchLibraryRoot(ctx context.Context, localPath string) *model.LibraryRoot {
|
||||||
|
localPath = strings.TrimSpace(localPath)
|
||||||
|
if localPath == "" || s.repo.Library == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
roots, err := s.repo.Library.ListEnabledRoots(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("cloud download pipeline: list library roots failed", zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
local := filepath.Clean(localPath)
|
||||||
|
for i := range roots {
|
||||||
|
root := roots[i]
|
||||||
|
candidate := filepath.Clean(strings.TrimSpace(root.Path))
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sameLibraryPath(candidate, local) {
|
||||||
|
return &root
|
||||||
|
}
|
||||||
|
// 子目录判断也走同一套路径归一化(Windows 盘符映射/容器前缀)。
|
||||||
|
withSep := candidate
|
||||||
|
if !strings.HasSuffix(withSep, string(filepath.Separator)) {
|
||||||
|
withSep += string(filepath.Separator)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(local, withSep) || strings.HasPrefix(cleanPathForVolumeMapping(local), cleanPathForVolumeMapping(withSep)) {
|
||||||
|
return &root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanLibrary 扫描媒体库并返回新增条目数。
|
||||||
|
func (s *CloudDownloadService) scanLibrary(ctx context.Context, libraryID string) (int, error) {
|
||||||
|
if s.scanner == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
res, err := s.scanner.ScanLibrary(ctx, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return res.Added, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// debounceElapsed 判断距上次触发是否已过去一个去抖窗口。
|
||||||
|
func (s *CloudDownloadService) debounceElapsed(syncPathID string) bool {
|
||||||
|
window := s.debounceWindow()
|
||||||
|
if window <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
s.debounceMu.Lock()
|
||||||
|
defer s.debounceMu.Unlock()
|
||||||
|
last, ok := s.lastTrigger[syncPathID]
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return time.Since(last) >= window
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteTrigger 记录本次触发时间。
|
||||||
|
func (s *CloudDownloadService) noteTrigger(syncPathID string) {
|
||||||
|
s.debounceMu.Lock()
|
||||||
|
s.lastTrigger[syncPathID] = time.Now()
|
||||||
|
s.debounceMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// strmStatusLabel 把同步记录状态翻译成中文。
|
||||||
|
func strmStatusLabel(status string) string {
|
||||||
|
switch status {
|
||||||
|
case model.StrmSyncRecordFailed:
|
||||||
|
return "失败"
|
||||||
|
case model.StrmSyncRecordCanceled:
|
||||||
|
return "被取消"
|
||||||
|
case model.StrmSyncRecordDone:
|
||||||
|
return "完成"
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// 云下载轮询:把 115 任务列表的状态映射回本地任务表。
|
||||||
|
//
|
||||||
|
// 设计约束:
|
||||||
|
// - 没有活跃任务时不发起任何 115 请求(避免无意义配额与限流风险);
|
||||||
|
// - 按账号分组,一个账号一轮只翻页一次,供该账号下所有任务复用;
|
||||||
|
// - 只做状态映射,不触发后处理(后处理由 pipeline 负责,两者解耦)。
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PollOnce 执行一轮 115 任务状态同步。导出以便测试与手动触发。
|
||||||
|
func (s *CloudDownloadService) PollOnce(ctx context.Context) error {
|
||||||
|
active, err := s.repo.CloudDownload.ListByStatuses(ctx, []string{
|
||||||
|
model.CloudDownloadSubmitted,
|
||||||
|
model.CloudDownloadDownloading,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(active) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按账号分组:同一账号只拉一次任务列表。
|
||||||
|
byAccount := map[string][]model.CloudDownloadTask{}
|
||||||
|
for _, t := range active {
|
||||||
|
byAccount[t.AccountID] = append(byAccount[t.AccountID], t)
|
||||||
|
}
|
||||||
|
|
||||||
|
for accountID, tasks := range byAccount {
|
||||||
|
s.pollAccount(ctx, accountID, tasks)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollAccount 拉取单个账号的任务列表并回写本地任务状态。
|
||||||
|
func (s *CloudDownloadService) pollAccount(ctx context.Context, accountID string, tasks []model.CloudDownloadTask) {
|
||||||
|
client, err := s.strm.Open115Client(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
// 账号被禁用/删除时不改任务状态:等用户处理账号,任务保留可重试。
|
||||||
|
s.log.Warn("cloud download poll: open 115 client failed",
|
||||||
|
zap.String("account_id", accountID), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
byHash := map[string]cloud115.OfflineTask{}
|
||||||
|
byURL := map[string]cloud115.OfflineTask{}
|
||||||
|
pages := s.maxPollPages()
|
||||||
|
for page := 1; page <= pages; page++ {
|
||||||
|
list, err := client.GetOfflineTaskList(ctx, page)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("cloud download poll: list tasks failed",
|
||||||
|
zap.String("account_id", accountID), zap.Int("page", page), zap.Error(err))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if list == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, t := range list.Tasks {
|
||||||
|
if h := strings.ToUpper(strings.TrimSpace(t.InfoHash)); h != "" {
|
||||||
|
byHash[h] = t
|
||||||
|
}
|
||||||
|
if u := strings.TrimSpace(t.URL); u != "" {
|
||||||
|
byURL[u] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if list.PageCount <= page || len(list.Tasks) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
timeout := s.taskTimeout()
|
||||||
|
for _, t := range tasks {
|
||||||
|
offline := matchOfflineTask(t, byHash, byURL)
|
||||||
|
fields := map[string]any{"last_poll_at": now}
|
||||||
|
|
||||||
|
if offline == nil {
|
||||||
|
// 115 侧暂未出现(受理有延迟)或已不在列表(被清理)。
|
||||||
|
// 只在超时后才判失败,避免把「刚提交」误判成失败。
|
||||||
|
if !t.CreatedAt.IsZero() && now.Sub(t.CreatedAt) > timeout {
|
||||||
|
fields["status"] = model.CloudDownloadFailed
|
||||||
|
fields["error"] = "下载超时:115 侧长时间未完成(任务可能已被清理)"
|
||||||
|
}
|
||||||
|
s.applyPollFields(ctx, t.ID, fields)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch offline.Status {
|
||||||
|
case cloud115.OfflineTaskStatusDone:
|
||||||
|
fields["status"] = model.CloudDownloadSucceeded
|
||||||
|
fields["percent"] = 100
|
||||||
|
if offline.Size > 0 {
|
||||||
|
fields["size_bytes"] = offline.Size
|
||||||
|
}
|
||||||
|
if t.CompletedAt == nil {
|
||||||
|
fields["completed_at"] = now
|
||||||
|
}
|
||||||
|
fields["error"] = ""
|
||||||
|
case cloud115.OfflineTaskStatusFailed:
|
||||||
|
fields["status"] = model.CloudDownloadFailed
|
||||||
|
fields["error"] = "115 云下载失败(可能资源失效或无人做种)"
|
||||||
|
default: // 0 分配中 / 1 下载中
|
||||||
|
fields["status"] = model.CloudDownloadDownloading
|
||||||
|
fields["percent"] = offline.PercentDone
|
||||||
|
if offline.Size > 0 {
|
||||||
|
fields["size_bytes"] = offline.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.applyPollFields(ctx, t.ID, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyPollFields 只写轮询关心的列,绝不能整行 Update:
|
||||||
|
// 后处理线程可能在并发写 stage/scan_added 等字段。
|
||||||
|
func (s *CloudDownloadService) applyPollFields(ctx context.Context, taskID string, fields map[string]any) {
|
||||||
|
if err := s.repo.CloudDownload.UpdateFields(ctx, taskID, fields); err != nil {
|
||||||
|
s.log.Warn("cloud download poll: update task failed",
|
||||||
|
zap.String("task_id", taskID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchOfflineTask 匹配 115 任务:info_hash 优先,其次原始链接。
|
||||||
|
func matchOfflineTask(t model.CloudDownloadTask, byHash, byURL map[string]cloud115.OfflineTask) *cloud115.OfflineTask {
|
||||||
|
if h := strings.ToUpper(strings.TrimSpace(t.InfoHash)); h != "" {
|
||||||
|
if item, ok := byHash[h]; ok {
|
||||||
|
return &item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{t.SourceKey, t.SourceURL} {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item, ok := byURL[key]; ok {
|
||||||
|
return &item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
// 云下载提交:校验目标、配额预检、批量提交链接、.torrent 上传解析后提交。
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cloudDownloadSeedDirName 是 115 上用于暂存 .torrent 的目录(两级)。
|
||||||
|
// 官方文档建议把种子放进「云下载/种子文件」,但不是硬性要求;不存在时自动创建。
|
||||||
|
var cloudDownloadSeedDirPath = []string{"云下载", "种子文件"}
|
||||||
|
|
||||||
|
// CloudDownloadSubmit 是链接提交请求。
|
||||||
|
// AccountID 允许为空:始终以同步目录绑定的账号为准,避免前端传错账号导致
|
||||||
|
// 「任务挂在 A 账号、文件落到 B 目录」这种无法自动恢复的错配。
|
||||||
|
type CloudDownloadSubmit struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
SyncPathID string `json:"sync_path_id"`
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloudDownloadTorrentRequest 是 .torrent 提交请求(文件内容由 handler 读入)。
|
||||||
|
type CloudDownloadTorrentRequest struct {
|
||||||
|
AccountID string
|
||||||
|
SyncPathID string
|
||||||
|
FileName string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloudDownloadTaskResult 是单条来源的提交结果。
|
||||||
|
type CloudDownloadTaskResult struct {
|
||||||
|
TaskID string `json:"task_id,omitempty"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloudDownloadSubmitResult 是批量提交结果:部分成功也返回 200 + 明细。
|
||||||
|
type CloudDownloadSubmitResult struct {
|
||||||
|
Accepted int `json:"accepted"`
|
||||||
|
Rejected int `json:"rejected"`
|
||||||
|
Items []CloudDownloadTaskResult `json:"items"`
|
||||||
|
Skipped []cloudDownloadSkipped `json:"skipped,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit 提交一批链接任务。
|
||||||
|
func (s *CloudDownloadService) Submit(ctx context.Context, req CloudDownloadSubmit) (*CloudDownloadSubmitResult, error) {
|
||||||
|
p, err := s.resolveTarget(ctx, req.SyncPathID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
links, skipped := parseCloudDownloadLinks(req.URLs)
|
||||||
|
res := &CloudDownloadSubmitResult{Skipped: skipped}
|
||||||
|
if len(links) == 0 {
|
||||||
|
for _, sk := range skipped {
|
||||||
|
res.Items = append(res.Items, CloudDownloadTaskResult{Source: sk.Raw, Error: sk.Reason})
|
||||||
|
res.Rejected++
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
if err := s.ensureQuota(ctx, p.AccountID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
submitRes := s.submitLinks(ctx, p.AccountID, p.ID, p.RemotePath, links, "")
|
||||||
|
submitRes.Skipped = skipped
|
||||||
|
return submitRes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitTorrent 上传 .torrent 到 115、解析后提交为 BT 任务。
|
||||||
|
//
|
||||||
|
// 本期不做「选择部分文件」:115 开放平台的磁力链路没有文件选择能力,
|
||||||
|
// .torrent 链路虽然支持 wanted,但产品上暂不提供勾选 UI(见 docs/cloud-download-plan.md)。
|
||||||
|
// 因此 wanted 传 "0"(官方文档唯一明确允许的取值),语义待真机验证。
|
||||||
|
func (s *CloudDownloadService) SubmitTorrent(ctx context.Context, req CloudDownloadTorrentRequest) (*CloudDownloadSubmitResult, error) {
|
||||||
|
p, err := s.resolveTarget(ctx, req.SyncPathID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(req.Data) == 0 {
|
||||||
|
return nil, errors.New("种子文件为空")
|
||||||
|
}
|
||||||
|
if int64(len(req.Data)) > s.torrentMaxBytes() {
|
||||||
|
return nil, fmt.Errorf("种子文件超过大小上限(%d MB)", s.torrentMaxBytes()>>20)
|
||||||
|
}
|
||||||
|
if err := s.ensureQuota(ctx, p.AccountID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client, err := s.strm.Open115Client(ctx, p.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 写入临时文件:115 上传接口只接受本地路径。
|
||||||
|
tmp, err := os.CreateTemp("", cloudDownloadTorrentTempPrefix+"*.torrent")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建种子临时文件失败:%w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
defer func() { _ = os.Remove(tmpPath) }()
|
||||||
|
if _, err := tmp.Write(req.Data); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return nil, fmt.Errorf("写入种子临时文件失败:%w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("关闭种子临时文件失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 上传到「云下载/种子文件」;建目录失败时回落到同步目录,避免整条链路失败。
|
||||||
|
parentCID, derr := s.ensureSeedDir(ctx, client)
|
||||||
|
if derr != nil {
|
||||||
|
s.log.Warn("cloud download: ensure seed dir failed, fallback to sync path",
|
||||||
|
zap.String("path_id", p.ID), zap.Error(derr))
|
||||||
|
parentCID = p.RemotePath
|
||||||
|
}
|
||||||
|
uploaded, err := client.Upload(ctx, tmpPath, parentCID, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("上传种子到 115 失败:%w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(uploaded.Sha1) == "" || strings.TrimSpace(uploaded.PickCode) == "" {
|
||||||
|
return nil, errors.New("115 上传种子未返回 sha1/pick_code")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 解析种子(拿 info_hash 与任务名)。
|
||||||
|
info, err := client.ParseTorrent(ctx, uploaded.Sha1, uploaded.PickCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 BT 种子失败:%w", err)
|
||||||
|
}
|
||||||
|
infoHash := strings.ToUpper(strings.TrimSpace(info.InfoHash))
|
||||||
|
if infoHash == "" {
|
||||||
|
return nil, errors.New("115 解析种子未返回 info_hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 提交 BT 任务。
|
||||||
|
if err := client.AddTaskBT(ctx, cloud115.OfflineBTRequest{
|
||||||
|
InfoHash: infoHash,
|
||||||
|
Wanted: "0",
|
||||||
|
TorrentSha1: uploaded.Sha1,
|
||||||
|
PickCode: uploaded.PickCode,
|
||||||
|
WpPathID: p.RemotePath,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("提交 BT 任务失败:%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 落库。
|
||||||
|
task := &model.CloudDownloadTask{
|
||||||
|
AccountID: p.AccountID,
|
||||||
|
SyncPathID: p.ID,
|
||||||
|
Provider: model.StrmProvider115,
|
||||||
|
SourceType: model.CloudDownloadSourceTorrent,
|
||||||
|
SourceURL: req.FileName,
|
||||||
|
SourceKey: infoHash,
|
||||||
|
InfoHash: infoHash,
|
||||||
|
TorrentName: info.Name,
|
||||||
|
TorrentSha1: uploaded.Sha1,
|
||||||
|
PickCode: uploaded.PickCode,
|
||||||
|
WpPathID: p.RemotePath,
|
||||||
|
Status: model.CloudDownloadSubmitted,
|
||||||
|
SizeBytes: info.FileSize,
|
||||||
|
Stage: model.CloudDownloadStagePending,
|
||||||
|
}
|
||||||
|
if err := s.repo.CloudDownload.Create(ctx, task); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CloudDownloadSubmitResult{
|
||||||
|
Accepted: 1,
|
||||||
|
Items: []CloudDownloadTaskResult{{
|
||||||
|
TaskID: task.ID,
|
||||||
|
Source: req.FileName,
|
||||||
|
OK: true,
|
||||||
|
}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTarget 校验同步目录并返回它。云下载只支持 115 同步目录,
|
||||||
|
// 且远端必须是有效目录 ID(0 表示网盘根目录,落到根目录的离线文件永远扫不到)。
|
||||||
|
func (s *CloudDownloadService) resolveTarget(ctx context.Context, syncPathID string) (*model.StrmSyncPath, error) {
|
||||||
|
syncPathID = strings.TrimSpace(syncPathID)
|
||||||
|
if syncPathID == "" {
|
||||||
|
return nil, errors.New("请选择目标 STRM 同步目录")
|
||||||
|
}
|
||||||
|
p, err := s.repo.StrmSyncPath.FindByID(ctx, syncPathID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p == nil {
|
||||||
|
return nil, errors.New("STRM 同步目录不存在")
|
||||||
|
}
|
||||||
|
if !p.Enabled {
|
||||||
|
return nil, errors.New("STRM 同步目录已禁用")
|
||||||
|
}
|
||||||
|
if p.Provider != model.StrmProvider115 {
|
||||||
|
return nil, errors.New("只有 115 网盘同步目录支持云下载")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.AccountID) == "" {
|
||||||
|
return nil, errors.New("该同步目录未关联 115 账号")
|
||||||
|
}
|
||||||
|
wp := strings.TrimSpace(p.RemotePath)
|
||||||
|
if wp == "" || wp == "0" {
|
||||||
|
return nil, errors.New("同步目录的远端目录无效(不能是网盘根目录)")
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureQuota 提交前的配额闸门。配额接口失败不阻断提交:
|
||||||
|
// 部分应用可能未开放该接口权限,真正的失败由 add_task_* 的错误码兜底。
|
||||||
|
func (s *CloudDownloadService) ensureQuota(ctx context.Context, accountID string) error {
|
||||||
|
client, err := s.strm.Open115Client(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
quota, err := client.GetOfflineQuota(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("cloud download: quota probe failed, continue submit",
|
||||||
|
zap.String("account_id", accountID), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if quota != nil && quota.Surplus <= 0 {
|
||||||
|
return errors.New("115 云下载配额已用尽,请在 115 端获取更多配额后重试")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// submitLinks 分批提交链接,并逐条落库。
|
||||||
|
// existingTaskID 非空时表示「重试单条历史任务」,此时更新原记录而不是新建。
|
||||||
|
func (s *CloudDownloadService) submitLinks(
|
||||||
|
ctx context.Context,
|
||||||
|
accountID, syncPathID, wpPathID string,
|
||||||
|
links []cloudDownloadParsedLink,
|
||||||
|
existingTaskID string,
|
||||||
|
) *CloudDownloadSubmitResult {
|
||||||
|
res := &CloudDownloadSubmitResult{}
|
||||||
|
active := map[string]bool{}
|
||||||
|
|
||||||
|
if existingTaskID == "" {
|
||||||
|
keys := make([]string, 0, len(links))
|
||||||
|
for _, l := range links {
|
||||||
|
keys = append(keys, l.Key)
|
||||||
|
}
|
||||||
|
if dedup, err := s.repo.CloudDownload.FindActiveBySourceKeys(ctx, keys); err == nil {
|
||||||
|
active = dedup
|
||||||
|
}
|
||||||
|
filtered := make([]cloudDownloadParsedLink, 0, len(links))
|
||||||
|
for _, l := range links {
|
||||||
|
if active[l.Key] {
|
||||||
|
res.Items = append(res.Items, CloudDownloadTaskResult{
|
||||||
|
Source: l.URL,
|
||||||
|
Error: "该链接已有进行中的任务",
|
||||||
|
})
|
||||||
|
res.Rejected++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, l)
|
||||||
|
}
|
||||||
|
links = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(links) == 0 {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := s.strm.Open115Client(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
for _, l := range links {
|
||||||
|
res.Items = append(res.Items, CloudDownloadTaskResult{Source: l.URL, Error: err.Error()})
|
||||||
|
res.Rejected++
|
||||||
|
s.persistSubmitFailure(ctx, accountID, syncPathID, wpPathID, l, err.Error(), existingTaskID)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
for start := 0; start < len(links); start += cloud115.OfflineMaxURLsPerBatch {
|
||||||
|
end := start + cloud115.OfflineMaxURLsPerBatch
|
||||||
|
if end > len(links) {
|
||||||
|
end = len(links)
|
||||||
|
}
|
||||||
|
batch := links[start:end]
|
||||||
|
urls := make([]string, 0, len(batch))
|
||||||
|
for _, l := range batch {
|
||||||
|
urls = append(urls, l.URL)
|
||||||
|
}
|
||||||
|
results, reqErr := client.AddTaskURLs(ctx, urls, wpPathID)
|
||||||
|
if reqErr != nil {
|
||||||
|
msg := reqErr.Error()
|
||||||
|
for _, l := range batch {
|
||||||
|
res.Items = append(res.Items, CloudDownloadTaskResult{Source: l.URL, Error: msg})
|
||||||
|
res.Rejected++
|
||||||
|
s.persistSubmitFailure(ctx, accountID, syncPathID, wpPathID, l, msg, existingTaskID)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 115 不保证结果顺序与入参一致:先按 url 建索引,缺失项再按顺序兜底。
|
||||||
|
byURL := make(map[string]cloud115.OfflineTaskURLResult, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
if u := strings.TrimSpace(r.URL); u != "" {
|
||||||
|
byURL[u] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, l := range batch {
|
||||||
|
r, ok := byURL[l.URL]
|
||||||
|
if !ok && i < len(results) {
|
||||||
|
r = results[i]
|
||||||
|
}
|
||||||
|
if !r.State {
|
||||||
|
msg := offlineErrorMessage(r.Code, r.Message)
|
||||||
|
res.Items = append(res.Items, CloudDownloadTaskResult{Source: l.URL, Error: msg})
|
||||||
|
res.Rejected++
|
||||||
|
s.persistSubmitFailure(ctx, accountID, syncPathID, wpPathID, l, msg, existingTaskID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
infoHash := strings.ToUpper(strings.TrimSpace(r.InfoHash))
|
||||||
|
if infoHash == "" {
|
||||||
|
infoHash = l.InfoHash
|
||||||
|
}
|
||||||
|
key := l.Key
|
||||||
|
if infoHash != "" {
|
||||||
|
key = infoHash
|
||||||
|
}
|
||||||
|
taskID, perr := s.persistSubmitSuccess(ctx, accountID, syncPathID, wpPathID, l, key, infoHash, existingTaskID)
|
||||||
|
if perr != nil {
|
||||||
|
res.Items = append(res.Items, CloudDownloadTaskResult{Source: l.URL, Error: "115 已受理,但本地记录失败:" + perr.Error()})
|
||||||
|
res.Rejected++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Items = append(res.Items, CloudDownloadTaskResult{TaskID: taskID, Source: l.URL, OK: true})
|
||||||
|
res.Accepted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistSubmitSuccess 写入一条已受理的任务,返回任务 ID。
|
||||||
|
func (s *CloudDownloadService) persistSubmitSuccess(
|
||||||
|
ctx context.Context, accountID, syncPathID, wpPathID string,
|
||||||
|
link cloudDownloadParsedLink, key, infoHash, existingTaskID string,
|
||||||
|
) (string, error) {
|
||||||
|
if existingTaskID != "" {
|
||||||
|
return existingTaskID, s.repo.CloudDownload.UpdateFields(ctx, existingTaskID, map[string]any{
|
||||||
|
"status": model.CloudDownloadSubmitted,
|
||||||
|
"error": "",
|
||||||
|
"percent": 0,
|
||||||
|
"info_hash": infoHash,
|
||||||
|
"source_key": key,
|
||||||
|
"last_poll_at": nil,
|
||||||
|
"stage": model.CloudDownloadStagePending,
|
||||||
|
"stage_error": "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
task := &model.CloudDownloadTask{
|
||||||
|
AccountID: accountID,
|
||||||
|
SyncPathID: syncPathID,
|
||||||
|
Provider: model.StrmProvider115,
|
||||||
|
SourceType: link.SourceType,
|
||||||
|
SourceURL: link.URL,
|
||||||
|
SourceKey: key,
|
||||||
|
InfoHash: infoHash,
|
||||||
|
WpPathID: wpPathID,
|
||||||
|
Status: model.CloudDownloadSubmitted,
|
||||||
|
Stage: model.CloudDownloadStagePending,
|
||||||
|
}
|
||||||
|
if err := s.repo.CloudDownload.Create(ctx, task); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return task.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistSubmitFailure 记录一条提交失败的任务,供用户在列表里看到原因并重试。
|
||||||
|
func (s *CloudDownloadService) persistSubmitFailure(
|
||||||
|
ctx context.Context, accountID, syncPathID, wpPathID string,
|
||||||
|
link cloudDownloadParsedLink, msg, existingTaskID string,
|
||||||
|
) {
|
||||||
|
if existingTaskID != "" {
|
||||||
|
_ = s.repo.CloudDownload.UpdateFields(ctx, existingTaskID, map[string]any{
|
||||||
|
"status": model.CloudDownloadFailed,
|
||||||
|
"error": msg,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task := &model.CloudDownloadTask{
|
||||||
|
AccountID: accountID,
|
||||||
|
SyncPathID: syncPathID,
|
||||||
|
Provider: model.StrmProvider115,
|
||||||
|
SourceType: link.SourceType,
|
||||||
|
SourceURL: link.URL,
|
||||||
|
SourceKey: link.Key,
|
||||||
|
InfoHash: link.InfoHash,
|
||||||
|
WpPathID: wpPathID,
|
||||||
|
Status: model.CloudDownloadFailed,
|
||||||
|
Error: msg,
|
||||||
|
Stage: model.CloudDownloadStagePending,
|
||||||
|
}
|
||||||
|
if err := s.repo.CloudDownload.Create(ctx, task); err != nil {
|
||||||
|
s.log.Warn("cloud download: persist failed task failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSeedDir 返回 115 上「云下载/种子文件」目录的 cid,不存在则创建。
|
||||||
|
func (s *CloudDownloadService) ensureSeedDir(ctx context.Context, client *cloud115.OpenClient) (string, error) {
|
||||||
|
parent := "0"
|
||||||
|
for _, name := range cloudDownloadSeedDirPath {
|
||||||
|
cid, err := s.findOrCreateDir(ctx, client, parent, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
parent = cid
|
||||||
|
}
|
||||||
|
return parent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findOrCreateDir 在 parentCID 下按名称查找子目录,未命中则创建。
|
||||||
|
// 先查后建的原因:115 的 folder/add 对已存在同名目录的行为文档未定义,
|
||||||
|
// 直接创建可能得到重复目录或错误。
|
||||||
|
func (s *CloudDownloadService) findOrCreateDir(ctx context.Context, client *cloud115.OpenClient, parentCID, name string) (string, error) {
|
||||||
|
const pageSize = 1150
|
||||||
|
offset := 0
|
||||||
|
for {
|
||||||
|
files, _, err := client.GetFsList(ctx, parentCID, offset, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
if f.Category == cloud115.TypeDir && strings.EqualFold(strings.TrimSpace(f.FileName), name) {
|
||||||
|
return f.FileId, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(files) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += len(files)
|
||||||
|
}
|
||||||
|
return client.MkDir(ctx, parentCID, name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCloudDownloadLinks(t *testing.T) {
|
||||||
|
links, skipped := parseCloudDownloadLinks([]string{
|
||||||
|
" magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=x ",
|
||||||
|
"ed2k://|file|a.mkv|100|0123456789ABCDEF0123456789ABCDEF|/",
|
||||||
|
"https://example.com/a.mkv",
|
||||||
|
"ftp://example.com/b.mkv",
|
||||||
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", // 与第一条重复
|
||||||
|
"not-a-link",
|
||||||
|
"",
|
||||||
|
"magnet:?xt=urn:btih:tooshort", // 非法哈希
|
||||||
|
})
|
||||||
|
if len(links) != 4 {
|
||||||
|
t.Fatalf("links = %d, want 4: %+v", len(links), links)
|
||||||
|
}
|
||||||
|
if links[0].SourceType != model.CloudDownloadSourceMagnet {
|
||||||
|
t.Errorf("first source type = %q", links[0].SourceType)
|
||||||
|
}
|
||||||
|
if links[0].Key != "0123456789ABCDEF0123456789ABCDEF01234567" {
|
||||||
|
t.Errorf("magnet key = %q, want uppercase info hash", links[0].Key)
|
||||||
|
}
|
||||||
|
if links[1].SourceType != model.CloudDownloadSourceED2K {
|
||||||
|
t.Errorf("second source type = %q", links[1].SourceType)
|
||||||
|
}
|
||||||
|
if links[2].SourceType != model.CloudDownloadSourceHTTP {
|
||||||
|
t.Errorf("third source type = %q", links[2].SourceType)
|
||||||
|
}
|
||||||
|
if links[3].SourceType != model.CloudDownloadSourceFTP {
|
||||||
|
t.Errorf("fourth source type = %q", links[3].SourceType)
|
||||||
|
}
|
||||||
|
// 空行不算错误,只有重复链接/不支持的类型/非法哈希会被记为跳过。
|
||||||
|
if len(skipped) != 3 {
|
||||||
|
t.Fatalf("skipped = %d, want 3: %+v", len(skipped), skipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCloudDownloadLinksAcceptsMultilinePaste(t *testing.T) {
|
||||||
|
links, _ := parseCloudDownloadLinks([]string{
|
||||||
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\nhttps://example.com/a.mkv\n",
|
||||||
|
})
|
||||||
|
if len(links) != 2 {
|
||||||
|
t.Fatalf("links = %d, want 2", len(links))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMagnetInfoHashSupportsBase32(t *testing.T) {
|
||||||
|
got := magnetInfoHash("magnet:?xt=urn:btih:ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
|
||||||
|
if got != "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" {
|
||||||
|
t.Fatalf("base32 hash = %q", got)
|
||||||
|
}
|
||||||
|
if got := magnetInfoHash("magnet:?dn=x"); got != "" {
|
||||||
|
t.Fatalf("missing btih should yield empty, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOfflineErrorMessage(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
code int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{1000012, "115 云下载配额已用尽,请在 115 端获取更多配额后重试"},
|
||||||
|
{91006, "115 存储空间不足"},
|
||||||
|
{20018, "115 找不到该种子文件(可能已被清理),请重新上传 .torrent"},
|
||||||
|
{1000011, "单批链接超过 115 条上限"},
|
||||||
|
{990002, "115 拒绝了提交参数:bad"},
|
||||||
|
{980004, "115 操作失败,请稍后重试"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := offlineErrorMessage(c.code, "bad"); got != c.want {
|
||||||
|
t.Errorf("code %d => %q, want %q", c.code, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := offlineErrorMessage(1234, "原始信息"); got != "原始信息" {
|
||||||
|
t.Errorf("unknown code should keep 115 message, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloudDownloadDebounce(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := NewCloudDownloadService(zap.NewNop(), repos, nil, nil, nil)
|
||||||
|
|
||||||
|
if !svc.debounceElapsed("p1") {
|
||||||
|
t.Fatal("first trigger must be allowed")
|
||||||
|
}
|
||||||
|
svc.noteTrigger("p1")
|
||||||
|
if svc.debounceElapsed("p1") {
|
||||||
|
t.Fatal("second trigger inside window must be suppressed")
|
||||||
|
}
|
||||||
|
// 另一个同步目录互不影响。
|
||||||
|
if !svc.debounceElapsed("p2") {
|
||||||
|
t.Fatal("debounce must be per sync path")
|
||||||
|
}
|
||||||
|
// 窗口置 0 表示不合并。
|
||||||
|
if err := repos.Setting.Set(context.Background(), CloudDownloadSettingSyncDebounce, "0"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !svc.debounceElapsed("p1") {
|
||||||
|
t.Fatal("zero window should disable debounce")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchLibraryRoot(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
repos := repository.New(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
lib := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||||
|
root := &model.LibraryRoot{LibraryID: "tmp", Path: "/media/movies", Enabled: true}
|
||||||
|
if err := repos.Library.CreateWithRoots(ctx, lib, []model.LibraryRoot{*root}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewCloudDownloadService(zap.NewNop(), repos, nil, nil, nil)
|
||||||
|
if got := svc.matchLibraryRoot(ctx, "/media/movies"); got == nil || got.LibraryID != lib.ID {
|
||||||
|
t.Fatalf("exact path should match library, got %+v", got)
|
||||||
|
}
|
||||||
|
if got := svc.matchLibraryRoot(ctx, "/media/movies/2024"); got == nil || got.LibraryID != lib.ID {
|
||||||
|
t.Fatalf("sub directory should match library, got %+v", got)
|
||||||
|
}
|
||||||
|
if got := svc.matchLibraryRoot(ctx, "/media/tv"); got != nil {
|
||||||
|
t.Fatalf("unrelated path must not match, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsCloudDownloadTerminal(t *testing.T) {
|
||||||
|
terminal := []string{model.CloudDownloadSucceeded, model.CloudDownloadFailed, model.CloudDownloadCanceled}
|
||||||
|
for _, s := range terminal {
|
||||||
|
if !isCloudDownloadTerminal(s) {
|
||||||
|
t.Errorf("%s should be terminal", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range []string{model.CloudDownloadQueued, model.CloudDownloadSubmitted, model.CloudDownloadDownloading} {
|
||||||
|
if isCloudDownloadTerminal(s) {
|
||||||
|
t.Errorf("%s should not be terminal", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTargetRejectsInvalidSyncPath(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
repos := repository.New(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := NewCloudDownloadService(zap.NewNop(), repos, nil, nil, nil)
|
||||||
|
|
||||||
|
if _, err := svc.resolveTarget(ctx, ""); err == nil {
|
||||||
|
t.Fatal("empty sync path should fail")
|
||||||
|
}
|
||||||
|
if _, err := svc.resolveTarget(ctx, "missing"); err == nil {
|
||||||
|
t.Fatal("unknown sync path should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远端目录为 0(网盘根目录):离线文件永远扫不到,必须拒绝。
|
||||||
|
bad := &model.StrmSyncPath{
|
||||||
|
Name: "根目录", AccountID: "acc", Provider: model.StrmProvider115,
|
||||||
|
RemotePath: "0", LocalPath: "/media/root", Enabled: true,
|
||||||
|
}
|
||||||
|
if err := repos.StrmSyncPath.Create(ctx, bad); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.resolveTarget(ctx, bad.ID); err == nil {
|
||||||
|
t.Fatal("remote root path should be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 115 同步目录不支持云下载。
|
||||||
|
cd2 := &model.StrmSyncPath{
|
||||||
|
Name: "CD2", AccountID: "acc", Provider: model.StrmProviderCloudDrive,
|
||||||
|
RemotePath: "/movies", LocalPath: "/media/cd2", Enabled: true,
|
||||||
|
}
|
||||||
|
if err := repos.StrmSyncPath.Create(ctx, cd2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.resolveTarget(ctx, cd2.ID); err == nil {
|
||||||
|
t.Fatal("non-115 provider should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloudDownloadSettingsDefaults(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
svc := NewCloudDownloadService(zap.NewNop(), repository.New(db), nil, nil, nil)
|
||||||
|
|
||||||
|
if got := svc.pollInterval(); got != 60*time.Second {
|
||||||
|
t.Errorf("default poll interval = %v", got)
|
||||||
|
}
|
||||||
|
if got := svc.debounceWindow(); got != 30*time.Second {
|
||||||
|
t.Errorf("default debounce = %v", got)
|
||||||
|
}
|
||||||
|
if !svc.autoScrape() {
|
||||||
|
t.Error("auto scrape should default to true")
|
||||||
|
}
|
||||||
|
if got := svc.taskTimeout(); got != 72*time.Hour {
|
||||||
|
t.Errorf("default timeout = %v", got)
|
||||||
|
}
|
||||||
|
if got := svc.torrentMaxBytes(); got != 20<<20 {
|
||||||
|
t.Errorf("default torrent max = %d", got)
|
||||||
|
}
|
||||||
|
if got := svc.maxPollPages(); got != 10 {
|
||||||
|
t.Errorf("default max pages = %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 越界值必须被夹紧,避免把轮询间隔配成 1 秒打爆 115 限流。
|
||||||
|
if err := repository.New(db).Setting.Set(context.Background(), CloudDownloadSettingPollInterval, "1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := svc.pollInterval(); got != 30*time.Second {
|
||||||
|
t.Errorf("clamped poll interval = %v, want 30s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,9 @@ type SchedulerService struct {
|
|||||||
segments *MediaSegmentService
|
segments *MediaSegmentService
|
||||||
backup *BackupService
|
backup *BackupService
|
||||||
|
|
||||||
|
// cloudDownload 用于周期轮询 115 云下载任务状态(nil 表示不启用)。
|
||||||
|
cloudDownload *CloudDownloadService
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
jobs []*scheduledJob
|
jobs []*scheduledJob
|
||||||
@@ -73,6 +76,11 @@ func (s *SchedulerService) SetBackupService(backup *BackupService) {
|
|||||||
s.backup = backup
|
s.backup = backup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCloudDownload 注入云下载服务:注入后才注册轮询任务。
|
||||||
|
func (s *SchedulerService) SetCloudDownload(cd *CloudDownloadService) {
|
||||||
|
s.cloudDownload = cd
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
|
func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
|
||||||
s.organizePipeline = pipeline
|
s.organizePipeline = pipeline
|
||||||
}
|
}
|
||||||
@@ -213,6 +221,15 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
|||||||
run: s.jobDatabaseBackup,
|
run: s.jobDatabaseBackup,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 云下载轮询:只有注入了云下载服务才注册,避免旧部署跑空转任务。
|
||||||
|
// 间隔可配(cloud_download.poll_interval_sec),由服务自己读取。
|
||||||
|
if s.cloudDownload != nil {
|
||||||
|
s.jobs = append(s.jobs, &scheduledJob{
|
||||||
|
name: "cloud_download_poll",
|
||||||
|
interval: s.cloudDownload.pollInterval(),
|
||||||
|
run: s.jobCloudDownloadPoll,
|
||||||
|
})
|
||||||
|
}
|
||||||
for _, j := range s.jobs {
|
for _, j := range s.jobs {
|
||||||
initialDelay := 15 * time.Second
|
initialDelay := 15 * time.Second
|
||||||
if j.name == "library_scan" || j.name == "organize_source" || j.name == "segment_prewarm" {
|
if j.name == "library_scan" || j.name == "organize_source" || j.name == "segment_prewarm" {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// 调度器:云下载任务轮询。
|
||||||
|
//
|
||||||
|
// 单独成文件而不是塞进 scheduler_local_jobs.go:那个文件是本地媒体相关的
|
||||||
|
// 周期任务,云下载是外部网盘任务,两者的失败处理与限流策略不同。
|
||||||
|
package service
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// jobCloudDownloadPoll 轮询 115 云下载任务状态。
|
||||||
|
// 没有活跃任务时 PollOnce 直接返回,不发任何 115 请求。
|
||||||
|
func (s *SchedulerService) jobCloudDownloadPoll(ctx context.Context) error {
|
||||||
|
if s.cloudDownload == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.cloudDownload.PollOnce(ctx)
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ type Container struct {
|
|||||||
RecognitionWords *RecognitionWordsService
|
RecognitionWords *RecognitionWordsService
|
||||||
Danmaku *DanmakuService
|
Danmaku *DanmakuService
|
||||||
Strm *StrmService
|
Strm *StrmService
|
||||||
|
CloudDownload *CloudDownloadService
|
||||||
Cloud115 *Cloud115PlaybackService
|
Cloud115 *Cloud115PlaybackService
|
||||||
Reader *reader.ReaderService
|
Reader *reader.ReaderService
|
||||||
Database *DatabaseAdminService
|
Database *DatabaseAdminService
|
||||||
@@ -128,6 +129,11 @@ func (c *Container) Boot() {
|
|||||||
c.Strm.Start(c.stopCtx)
|
c.Strm.Start(c.stopCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 115 云下载:恢复中断的后处理阶段 + 启动后处理推进循环。
|
||||||
|
if c.CloudDownload != nil {
|
||||||
|
c.CloudDownload.Start(c.stopCtx)
|
||||||
|
}
|
||||||
|
|
||||||
// 启动刮削队列后台消费者
|
// 启动刮削队列后台消费者
|
||||||
if c.Scraper != nil {
|
if c.Scraper != nil {
|
||||||
c.Scraper.Start(c.stopCtx)
|
c.Scraper.Start(c.stopCtx)
|
||||||
@@ -178,6 +184,9 @@ func (c *Container) Close() {
|
|||||||
if c.Strm != nil {
|
if c.Strm != nil {
|
||||||
c.Strm.Stop()
|
c.Strm.Stop()
|
||||||
}
|
}
|
||||||
|
if c.CloudDownload != nil {
|
||||||
|
c.CloudDownload.Stop()
|
||||||
|
}
|
||||||
if c.Watcher != nil {
|
if c.Watcher != nil {
|
||||||
c.Watcher.Stop()
|
c.Watcher.Stop()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,12 @@ func (b *serviceContainerBuilder) initContentServices() {
|
|||||||
b.c.Profile = NewProfileService(b.log, b.repos)
|
b.c.Profile = NewProfileService(b.log, b.repos)
|
||||||
b.c.Audit = NewAuditService(b.log, b.repos)
|
b.c.Audit = NewAuditService(b.log, b.repos)
|
||||||
b.c.Strm = NewStrmService(b.cfg, b.log, b.repos, b.c.Crypto)
|
b.c.Strm = NewStrmService(b.cfg, b.log, b.repos, b.c.Crypto)
|
||||||
|
// 115 云下载:依赖 Strm(复用其 115 客户端与同步入口)、Scan(媒体库扫描)、
|
||||||
|
// Scraper(自动刮削)。必须在三者都构造完成之后创建。
|
||||||
|
b.c.CloudDownload = NewCloudDownloadService(b.log, b.repos, b.c.Strm, b.c.Scan, b.c.Scraper)
|
||||||
|
// 同步完成 → 云下载后处理(扫描/刮削)。回调里会再调 IsSyncRunning/StartSync,
|
||||||
|
// 所以 hook 必须在 Strm 完全就绪后注册。
|
||||||
|
b.c.Strm.SetSyncFinishHook(b.c.CloudDownload.onStrmSyncFinished)
|
||||||
b.c.Cloud115 = NewCloud115PlaybackService(b.cfg, b.log, b.repos, b.c.Strm)
|
b.c.Cloud115 = NewCloud115PlaybackService(b.cfg, b.log, b.repos, b.c.Strm)
|
||||||
// 本地 HLS 档位的可用性取决于 ffmpeg 是否可用;未注入时按不可用处理。
|
// 本地 HLS 档位的可用性取决于 ffmpeg 是否可用;未注入时按不可用处理。
|
||||||
b.c.Cloud115.SetTranscoder(b.c.Transcoder)
|
b.c.Cloud115.SetTranscoder(b.c.Transcoder)
|
||||||
@@ -216,6 +222,7 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
|||||||
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
|
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
|
||||||
b.c.Scheduler.SetBackupService(b.c.Backup)
|
b.c.Scheduler.SetBackupService(b.c.Backup)
|
||||||
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
|
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
|
||||||
|
b.c.Scheduler.SetCloudDownload(b.c.CloudDownload)
|
||||||
b.c.Scheduler.SetSegments(b.c.Segments)
|
b.c.Scheduler.SetSegments(b.c.Segments)
|
||||||
b.c.Scheduler.SetCacheCleanupPolicyProvider(func() CacheCleanupPolicy {
|
b.c.Scheduler.SetCacheCleanupPolicyProvider(func() CacheCleanupPolicy {
|
||||||
if b.cfg == nil {
|
if b.cfg == nil {
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ type StrmService struct {
|
|||||||
providerMu sync.Mutex
|
providerMu sync.Mutex
|
||||||
provider115Cache map[string]cloud.Provider // account ID -> shared provider/OpenClient
|
provider115Cache map[string]cloud.Provider // account ID -> shared provider/OpenClient
|
||||||
|
|
||||||
|
// syncFinishHook 在每次同步落库后被异步调用(网盘同步完成 → 扫描/刮削等下游)。
|
||||||
|
// 单独一把锁:不能与 mu(保护 running)耦合,否则 hook 回调里再调
|
||||||
|
// IsSyncRunning/StartSync 会自己把自己堵住。
|
||||||
|
hookMu sync.RWMutex
|
||||||
|
syncFinishHook func(ctx context.Context, syncPathID, status string)
|
||||||
|
|
||||||
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
|
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
|
||||||
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
|
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
|
||||||
downloadSemOnce sync.Once
|
downloadSemOnce sync.Once
|
||||||
@@ -503,6 +509,52 @@ func (s *StrmService) ListAccounts(ctx context.Context) ([]model.StrmAccount, er
|
|||||||
return s.repo.StrmAccount.List(ctx)
|
return s.repo.StrmAccount.List(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSyncFinishHook 注册同步完成回调。回调在同步记录落库之后异步执行,
|
||||||
|
// 用于把「网盘文件已就绪」通知给下游(云下载后处理:扫描 → 刮削)。
|
||||||
|
// 回调跑在独立 goroutine 里并自带 recover 与超时,绝不影响同步流程本身。
|
||||||
|
func (s *StrmService) SetSyncFinishHook(fn func(ctx context.Context, syncPathID, status string)) {
|
||||||
|
s.hookMu.Lock()
|
||||||
|
s.syncFinishHook = fn
|
||||||
|
s.hookMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentSyncFinishHook 读取当前回调(无回调时返回 nil)。
|
||||||
|
func (s *StrmService) currentSyncFinishHook() func(ctx context.Context, syncPathID, status string) {
|
||||||
|
s.hookMu.RLock()
|
||||||
|
defer s.hookMu.RUnlock()
|
||||||
|
return s.syncFinishHook
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open115Client 返回账号已缓存的 115 开放平台客户端。
|
||||||
|
//
|
||||||
|
// 必须复用 providerFor 的缓存实例:115 的 refresh_token 是一次性轮转的,
|
||||||
|
// 每刷新一次旧的即作废。若调用方自建 OpenClient,两边会交替刷新、互相
|
||||||
|
// 作废,最终把账号刷成「授权已失效」(见 newProvider 里的 OnTokenRefreshed)。
|
||||||
|
func (s *StrmService) Open115Client(ctx context.Context, accountID string) (*cloud115.OpenClient, error) {
|
||||||
|
acct, err := s.repo.StrmAccount.FindByID(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if acct == nil {
|
||||||
|
return nil, errors.New("网盘账号不存在或已删除")
|
||||||
|
}
|
||||||
|
if !acct.Enabled {
|
||||||
|
return nil, errors.New("网盘账号已禁用")
|
||||||
|
}
|
||||||
|
if acct.Provider != model.StrmProvider115 {
|
||||||
|
return nil, errors.New("该账号不是 115 网盘账号")
|
||||||
|
}
|
||||||
|
provider, err := s.providerFor(ctx, acct)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
oc, ok := provider.(cloud.OpenAPI115Provider)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("115 驱动未实现开放平台接口")
|
||||||
|
}
|
||||||
|
return oc.OpenClient(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// providerFor 依据账号配置构建网盘驱动。
|
// providerFor 依据账号配置构建网盘驱动。
|
||||||
func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
|
func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
|
||||||
if acct != nil && acct.Provider == model.StrmProvider115 {
|
if acct != nil && acct.Provider == model.StrmProvider115 {
|
||||||
|
|||||||
@@ -345,6 +345,18 @@ func (s *StrmService) finishSync(p *model.StrmSyncPath, rec *model.StrmSyncRecor
|
|||||||
zap.String("path_id", p.ID), zap.String("sync_type", rec.SyncType), zap.String("status", status),
|
zap.String("path_id", p.ID), zap.String("sync_type", rec.SyncType), zap.String("status", status),
|
||||||
zap.Int64("new_strm", rec.NewStrm), zap.Int64("skipped", rec.Skipped), zap.Int64("new_meta", rec.NewMeta),
|
zap.Int64("new_strm", rec.NewStrm), zap.Int64("skipped", rec.Skipped), zap.Int64("new_meta", rec.NewMeta),
|
||||||
zap.Int64("uploaded", rec.Uploaded), zap.Int64("pruned", rec.Pruned), zap.String("message", message))
|
zap.Int64("uploaded", rec.Uploaded), zap.Int64("pruned", rec.Pruned), zap.String("message", message))
|
||||||
|
|
||||||
|
// 同步结果落库之后再通知下游(云下载后处理:扫描 → 刮削)。
|
||||||
|
// 异步 + recover + 独立超时:下游扫描可能跑几分钟,绝不能拖住同步协程收尾,
|
||||||
|
// 也不能让它 panic 影响后续同步(helper.Go 已内建 recover)。
|
||||||
|
if hook := s.currentSyncFinishHook(); hook != nil {
|
||||||
|
pathID := p.ID
|
||||||
|
helper.Go(s.log, "strm.syncFinishHook", func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
hook(ctx, pathID, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (st *strmSyncState) run() error {
|
func (st *strmSyncState) run() error {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 115 云下载 API。
|
||||||
|
import { LONG_REQUEST_TIMEOUT, api } from './client'
|
||||||
|
import type {
|
||||||
|
CloudDownloadList,
|
||||||
|
CloudDownloadQuota,
|
||||||
|
CloudDownloadSubmitResult,
|
||||||
|
} from '../types/cloudDownload'
|
||||||
|
|
||||||
|
export interface CloudDownloadListParams {
|
||||||
|
status?: string
|
||||||
|
sync_path_id?: string
|
||||||
|
account_id?: string
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloudDownloadAPI = {
|
||||||
|
list: (params: CloudDownloadListParams = {}) =>
|
||||||
|
api.get<CloudDownloadList>('/admin/cloud-downloads', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
/** 提交一批链接(磁力 / ed2k / http / ftp)。部分失败也在 200 的 items 里返回。 */
|
||||||
|
submit: (payload: { sync_path_id: string; urls: string[] }) =>
|
||||||
|
api
|
||||||
|
.post<CloudDownloadSubmitResult>('/admin/cloud-downloads', payload, { timeout: LONG_REQUEST_TIMEOUT })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** 上传 .torrent 并提交为 BT 任务。 */
|
||||||
|
submitTorrent: (file: File, syncPathID: string) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
form.append('sync_path_id', syncPathID)
|
||||||
|
return api
|
||||||
|
.post<CloudDownloadSubmitResult>('/admin/cloud-downloads/torrent', form, {
|
||||||
|
timeout: LONG_REQUEST_TIMEOUT,
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
.then((r) => r.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
quota: (accountID: string) =>
|
||||||
|
api
|
||||||
|
.get<CloudDownloadQuota>('/admin/cloud-downloads/quota', { params: { account_id: accountID } })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
retry: (id: string) => api.post(`/admin/cloud-downloads/${id}/retry`).then((r) => r.data),
|
||||||
|
|
||||||
|
retryPipeline: (id: string) =>
|
||||||
|
api.post(`/admin/cloud-downloads/${id}/retry-pipeline`).then((r) => r.data),
|
||||||
|
|
||||||
|
cancel: (id: string) => api.post(`/admin/cloud-downloads/${id}/cancel`).then((r) => r.data),
|
||||||
|
|
||||||
|
remove: (id: string) => api.delete(`/admin/cloud-downloads/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
clearFinished: () =>
|
||||||
|
api.post<{ deleted: number }>('/admin/cloud-downloads/clear-finished').then((r) => r.data)
|
||||||
|
}
|
||||||
@@ -48,6 +48,9 @@ const StrmDownloadQueuePage = lazy(() =>
|
|||||||
const StrmUploadQueuePage = lazy(() =>
|
const StrmUploadQueuePage = lazy(() =>
|
||||||
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmUploadQueuePage })),
|
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmUploadQueuePage })),
|
||||||
)
|
)
|
||||||
|
const CloudDownloadPage = lazy(() =>
|
||||||
|
import('./pages/CloudDownloadPage').then((m) => ({ default: m.CloudDownloadPage })),
|
||||||
|
)
|
||||||
const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })))
|
const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })))
|
||||||
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
|
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
|
||||||
const MediaQualityPage = lazy(() => import('./pages/MediaQualityPage').then((m) => ({ default: m.MediaQualityPage })))
|
const MediaQualityPage = lazy(() => import('./pages/MediaQualityPage').then((m) => ({ default: m.MediaQualityPage })))
|
||||||
@@ -125,6 +128,7 @@ export const appRoutes: AppRoute[] = [
|
|||||||
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
|
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
|
||||||
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
|
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
|
||||||
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
|
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
|
||||||
|
{ path: 'cloud-downloads', element: <CloudDownloadPage />, adminOnly: true },
|
||||||
{ path: 'scraper/queue', element: <ScraperQueuePage />, adminOnly: true },
|
{ path: 'scraper/queue', element: <ScraperQueuePage />, adminOnly: true },
|
||||||
{ path: 'queue', element: <TaskQueuePage />, adminOnly: true },
|
{ path: 'queue', element: <TaskQueuePage />, adminOnly: true },
|
||||||
{ path: 'quality', element: <MediaQualityPage />, adminOnly: true },
|
{ path: 'quality', element: <MediaQualityPage />, adminOnly: true },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
CloudDownload,
|
||||||
ListChecks as SetupIcon,
|
ListChecks as SetupIcon,
|
||||||
FileOutput,
|
FileOutput,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -49,6 +50,7 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
|||||||
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
||||||
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
|
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
|
||||||
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
|
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
|
||||||
|
{ to: '/cloud-downloads', label: '云下载', icon: CloudDownload, adminOnly: true },
|
||||||
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
// 115 云下载页面:提交磁力/ed2k/HTTP/.torrent → 115 离线下载 → 自动 STRM 同步 →
|
||||||
|
// 自动扫描 → 自动刮削。链路各阶段在任务列表里可见。
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
CloudDownload,
|
||||||
|
FileUp,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { cloudDownloadAPI } from '../api/cloudDownload'
|
||||||
|
import { strmAPI } from '../api/strm'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import type { StrmSyncPath } from '../types/strm'
|
||||||
|
import type {
|
||||||
|
CloudDownloadQuota,
|
||||||
|
CloudDownloadSubmitResult,
|
||||||
|
CloudDownloadTask,
|
||||||
|
} from '../types/cloudDownload'
|
||||||
|
import { apiErrorMessage, formatBytes, formatTime } from './StrmManagePage'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
// 阶段与状态合并成一句人话,避免用户对着 status/stage 两个字段猜。
|
||||||
|
function taskPhase(task: CloudDownloadTask): { label: string; cls: string; spinner?: boolean } {
|
||||||
|
if (task.status === 'failed') return { label: '下载失败', cls: 'bg-rose-100 text-rose-600' }
|
||||||
|
if (task.status === 'canceled') return { label: '已取消', cls: 'bg-gray-100 text-gray-500' }
|
||||||
|
if (task.status === 'queued' || task.status === 'submitted') {
|
||||||
|
return { label: '等待 115 受理', cls: 'bg-amber-100 text-amber-600', spinner: true }
|
||||||
|
}
|
||||||
|
if (task.status === 'downloading') {
|
||||||
|
return { label: `下载中 ${task.percent ?? 0}%`, cls: 'bg-brand-100 text-brand-600', spinner: true }
|
||||||
|
}
|
||||||
|
// status = succeeded:看后处理阶段
|
||||||
|
switch (task.stage) {
|
||||||
|
case 'waiting_sync':
|
||||||
|
return { label: '等待同步', cls: 'bg-sky-100 text-sky-600', spinner: true }
|
||||||
|
case 'syncing':
|
||||||
|
return { label: 'STRM 同步中', cls: 'bg-sky-100 text-sky-600', spinner: true }
|
||||||
|
case 'scanning':
|
||||||
|
return { label: '扫描媒体库', cls: 'bg-indigo-100 text-indigo-600', spinner: true }
|
||||||
|
case 'scraping':
|
||||||
|
return { label: '刮削中', cls: 'bg-violet-100 text-violet-600', spinner: true }
|
||||||
|
case 'done':
|
||||||
|
return { label: '完成', cls: 'bg-emerald-100 text-emerald-600' }
|
||||||
|
case 'failed':
|
||||||
|
return { label: '后处理失败', cls: 'bg-rose-100 text-rose-600' }
|
||||||
|
case 'na':
|
||||||
|
return { label: '未接入媒体库', cls: 'bg-amber-100 text-amber-600' }
|
||||||
|
default:
|
||||||
|
return { label: '待后处理', cls: 'bg-sky-100 text-sky-600', spinner: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(task: CloudDownloadTask): string {
|
||||||
|
if (task.source_type === 'torrent') return task.torrent_name || task.source_url || '.torrent'
|
||||||
|
return task.source_url
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloudDownloadPage() {
|
||||||
|
const [paths, setPaths] = useState<StrmSyncPath[]>([])
|
||||||
|
const [syncPathID, setSyncPathID] = useState('')
|
||||||
|
const [urls, setUrls] = useState('')
|
||||||
|
const [torrentFile, setTorrentFile] = useState<File | null>(null)
|
||||||
|
const [quota, setQuota] = useState<CloudDownloadQuota | null>(null)
|
||||||
|
const [quotaError, setQuotaError] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [lastResult, setLastResult] = useState<CloudDownloadSubmitResult | null>(null)
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState<CloudDownloadTask[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||||
|
const [busyID, setBusyID] = useState('')
|
||||||
|
const refreshSeq = useRef(0)
|
||||||
|
|
||||||
|
const targets = useMemo(
|
||||||
|
() => paths.filter((p) => p.provider === 'cloud115' && p.enabled),
|
||||||
|
[paths],
|
||||||
|
)
|
||||||
|
const selectedPath = useMemo(
|
||||||
|
() => targets.find((p) => p.id === syncPathID) ?? null,
|
||||||
|
[targets, syncPathID],
|
||||||
|
)
|
||||||
|
|
||||||
|
const loadPaths = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await strmAPI.listPaths()
|
||||||
|
setPaths(list)
|
||||||
|
setSyncPathID((prev) => {
|
||||||
|
if (prev && list.some((p) => p.id === prev)) return prev
|
||||||
|
const first = list.find((p) => p.provider === 'cloud115' && p.enabled)
|
||||||
|
return first?.id ?? ''
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiErrorMessage(err))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadTasks = useCallback(
|
||||||
|
async (quiet = false) => {
|
||||||
|
const seq = ++refreshSeq.current
|
||||||
|
if (!quiet) setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await cloudDownloadAPI.list({
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
page,
|
||||||
|
page_size: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
if (seq !== refreshSeq.current) return
|
||||||
|
setTasks(data.items ?? [])
|
||||||
|
setTotal(data.total ?? 0)
|
||||||
|
} catch (err) {
|
||||||
|
if (!quiet) toast.error(apiErrorMessage(err))
|
||||||
|
} finally {
|
||||||
|
if (seq === refreshSeq.current) setLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[page, statusFilter],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadPaths()
|
||||||
|
}, [loadPaths])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTasks()
|
||||||
|
}, [loadTasks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh) return
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void loadTasks(true)
|
||||||
|
}, 5000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [autoRefresh, loadTasks])
|
||||||
|
|
||||||
|
// 切换目标目录时刷新配额(配额按账号统计)。
|
||||||
|
useEffect(() => {
|
||||||
|
setQuota(null)
|
||||||
|
setQuotaError('')
|
||||||
|
if (!selectedPath?.account_id) return
|
||||||
|
let cancelled = false
|
||||||
|
cloudDownloadAPI
|
||||||
|
.quota(selectedPath.account_id)
|
||||||
|
.then((q) => {
|
||||||
|
if (!cancelled) setQuota(q)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setQuotaError(apiErrorMessage(err))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [selectedPath?.account_id])
|
||||||
|
|
||||||
|
const submitLinks = async () => {
|
||||||
|
if (!syncPathID) {
|
||||||
|
toast.error('请选择目标同步目录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lines = urls
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (lines.length === 0) {
|
||||||
|
toast.error('请粘贴至少一条链接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await cloudDownloadAPI.submit({ sync_path_id: syncPathID, urls: lines })
|
||||||
|
setLastResult(res)
|
||||||
|
if (res.accepted > 0) {
|
||||||
|
toast.success(`已提交 ${res.accepted} 条任务`)
|
||||||
|
setUrls('')
|
||||||
|
} else {
|
||||||
|
toast.error('没有任务被 115 受理,请查看下方明细')
|
||||||
|
}
|
||||||
|
await loadTasks(true)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiErrorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitTorrent = async () => {
|
||||||
|
if (!syncPathID) {
|
||||||
|
toast.error('请选择目标同步目录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!torrentFile) {
|
||||||
|
toast.error('请选择 .torrent 文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await cloudDownloadAPI.submitTorrent(torrentFile, syncPathID)
|
||||||
|
setLastResult(res)
|
||||||
|
toast.success('种子已提交')
|
||||||
|
setTorrentFile(null)
|
||||||
|
await loadTasks(true)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiErrorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const act = async (id: string, fn: () => Promise<unknown>, okText: string) => {
|
||||||
|
setBusyID(id)
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
toast.success(okText)
|
||||||
|
await loadTasks(true)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiErrorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setBusyID('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl space-y-6 px-4 py-6">
|
||||||
|
<PageHeader
|
||||||
|
title="云下载"
|
||||||
|
description="提交磁力 / ed2k / 直链或 .torrent,115 离线完成后自动同步 STRM、扫描媒体库并刮削"
|
||||||
|
backTo="/strm"
|
||||||
|
backLabel="STRM 管理"
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadTasks()}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-ink-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 提交面板 */}
|
||||||
|
<section className="space-y-4 rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-ink-50">目标同步目录(115)</span>
|
||||||
|
<select
|
||||||
|
value={syncPathID}
|
||||||
|
onChange={(e) => setSyncPathID(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">请选择…</option>
|
||||||
|
{targets.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
{p.remote_display_path ? ` · ${p.remote_display_path}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-ink-50">云下载配额</span>
|
||||||
|
<div className="flex h-[38px] items-center gap-2 rounded-lg border border-gray-100 bg-gray-50 px-3 text-sm text-ink-600">
|
||||||
|
{quota ? (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold text-ink-600">{quota.surplus}</span>
|
||||||
|
<span className="text-ink-50">/ {quota.count} 剩余</span>
|
||||||
|
{quota.surplus <= 0 ? (
|
||||||
|
<span className="ml-auto text-xs text-rose-500">配额已用尽</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : quotaError ? (
|
||||||
|
<span className="truncate text-xs text-amber-600">{quotaError}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-ink-50">选择目录后加载</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs font-medium text-ink-50">
|
||||||
|
链接(每行一条,支持磁力 / ed2k / http(s) / ftp,单批最多 115 条)
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={urls}
|
||||||
|
onChange={(e) => setUrls(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder={'magnet:?xt=urn:btih:…\ned2k://|file|…\nhttps://example.com/file.mkv'}
|
||||||
|
className="w-full resize-y rounded-lg border border-gray-200 px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={submitting || !syncPathID}
|
||||||
|
onClick={() => void submitLinks()}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />}
|
||||||
|
提交链接
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-xs text-ink-50">或</span>
|
||||||
|
|
||||||
|
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-2 text-sm text-ink-600 hover:bg-gray-50">
|
||||||
|
<FileUp className="h-4 w-4" />
|
||||||
|
{torrentFile ? torrentFile.name : '选择 .torrent'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".torrent"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => setTorrentFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={submitting || !syncPathID || !torrentFile}
|
||||||
|
onClick={() => void submitTorrent()}
|
||||||
|
className="rounded-lg border border-brand-200 px-3 py-2 text-sm text-brand-600 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
上传种子
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-ink-50">
|
||||||
|
说明:115 开放平台的磁力链路不支持选择部分文件,磁力任务会整包下载;.torrent 目前也按全选提交。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{lastResult ? (
|
||||||
|
<div className="space-y-1 rounded-lg bg-gray-50 p-3 text-xs">
|
||||||
|
<div className="font-medium text-ink-600">
|
||||||
|
成功 {lastResult.accepted} 条,失败 {lastResult.rejected} 条
|
||||||
|
</div>
|
||||||
|
{lastResult.items?.map((item, idx) => (
|
||||||
|
<div key={`${item.source}-${idx}`} className="flex gap-2">
|
||||||
|
{item.ok ? (
|
||||||
|
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-rose-500" />
|
||||||
|
)}
|
||||||
|
<span className="break-all text-ink-600">{item.source}</span>
|
||||||
|
{item.error ? <span className="text-rose-500">{item.error}</span> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{lastResult.skipped?.map((item, idx) => (
|
||||||
|
<div key={`skip-${idx}`} className="flex gap-2 text-ink-50">
|
||||||
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||||
|
<span className="break-all">{item.raw}</span>
|
||||||
|
<span>{item.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 任务列表 */}
|
||||||
|
<section className="space-y-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-gray-200 px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="submitted">已提交</option>
|
||||||
|
<option value="downloading">下载中</option>
|
||||||
|
<option value="succeeded">已完成</option>
|
||||||
|
<option value="failed">失败</option>
|
||||||
|
<option value="canceled">已取消</option>
|
||||||
|
</select>
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-xs text-ink-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||||
|
/>
|
||||||
|
自动刷新
|
||||||
|
</label>
|
||||||
|
<span className="ml-auto text-xs text-ink-50">共 {total} 条</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void act('__clear__', () => cloudDownloadAPI.clearFinished(), '已清空失败/取消任务')
|
||||||
|
}
|
||||||
|
className="rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs text-ink-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
清空失败/已取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[860px] text-left text-sm">
|
||||||
|
<thead className="text-xs text-ink-50">
|
||||||
|
<tr className="border-b border-gray-100">
|
||||||
|
<th className="py-2 pr-3 font-medium">来源</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">目标目录</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">大小</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">状态</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">提交时间</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-8 text-center text-sm text-ink-50">
|
||||||
|
{loading ? '加载中…' : '暂无任务'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
tasks.map((task) => {
|
||||||
|
const phase = taskPhase(task)
|
||||||
|
const busy = busyID === task.id
|
||||||
|
const terminal =
|
||||||
|
task.status === 'succeeded' ||
|
||||||
|
task.status === 'failed' ||
|
||||||
|
task.status === 'canceled'
|
||||||
|
return (
|
||||||
|
<tr key={task.id} className="border-b border-gray-50 align-top">
|
||||||
|
<td className="max-w-[320px] py-2.5 pr-3">
|
||||||
|
<div className="truncate text-ink-600" title={sourceLabel(task)}>
|
||||||
|
{sourceLabel(task)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-50">
|
||||||
|
{task.source_type.toUpperCase()}
|
||||||
|
{task.info_hash ? ` · ${task.info_hash.slice(0, 12)}…` : ''}
|
||||||
|
</div>
|
||||||
|
{task.error ? (
|
||||||
|
<div className="mt-0.5 text-xs text-rose-500">{task.error}</div>
|
||||||
|
) : null}
|
||||||
|
{task.stage_error ? (
|
||||||
|
<div className="mt-0.5 text-xs text-amber-600">{task.stage_error}</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 text-xs text-ink-600">
|
||||||
|
<div>{task.sync_path_name || task.sync_path_id.slice(0, 8)}</div>
|
||||||
|
{task.remote_display_path ? (
|
||||||
|
<div className="text-ink-50">{task.remote_display_path}</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 text-xs text-ink-600">
|
||||||
|
{task.size_bytes ? formatBytes(task.size_bytes) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${phase.cls}`}
|
||||||
|
>
|
||||||
|
{phase.spinner ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||||||
|
{phase.label}
|
||||||
|
</span>
|
||||||
|
{task.stage === 'done' ? (
|
||||||
|
<div className="mt-0.5 text-[11px] text-ink-50">
|
||||||
|
新增 {task.scan_added} · 刮削 {task.scrape_queued}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 text-xs text-ink-50">{formatTime(task.created_at)}</td>
|
||||||
|
<td className="py-2.5 pr-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{task.status === 'failed' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void act(task.id, () => cloudDownloadAPI.retry(task.id), '已重新提交')
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-ink-600 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{(task.stage === 'failed' || task.stage === 'na') ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void act(
|
||||||
|
task.id,
|
||||||
|
() => cloudDownloadAPI.retryPipeline(task.id),
|
||||||
|
'已重新推进后处理',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-ink-600 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
重做后处理
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{!terminal ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void act(task.id, () => cloudDownloadAPI.cancel(task.id), '已取消')
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-amber-600 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void act(task.id, () => cloudDownloadAPI.remove(task.id), '已删除')
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-rose-500 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 ? (
|
||||||
|
<div className="flex items-center justify-end gap-2 text-xs text-ink-50">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
className="rounded-md border border-gray-200 px-2 py-1 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
className="rounded-md border border-gray-200 px-2 py-1 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
Cloud,
|
Cloud,
|
||||||
|
CloudDownload,
|
||||||
Download,
|
Download,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
@@ -206,6 +208,10 @@ export function StrmManagePage() {
|
|||||||
<FolderPlus size={16} />
|
<FolderPlus size={16} />
|
||||||
添加同步目录
|
添加同步目录
|
||||||
</button>
|
</button>
|
||||||
|
<Link to="/cloud-downloads" className="neon-button">
|
||||||
|
<CloudDownload size={16} />
|
||||||
|
云下载
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// 115 云下载(离线下载)相关类型定义(与后端 model.CloudDownloadTask 对应)。
|
||||||
|
|
||||||
|
export type CloudDownloadStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'submitted'
|
||||||
|
| 'downloading'
|
||||||
|
| 'succeeded'
|
||||||
|
| 'failed'
|
||||||
|
| 'canceled'
|
||||||
|
|
||||||
|
export type CloudDownloadStage =
|
||||||
|
| 'pending'
|
||||||
|
| 'waiting_sync'
|
||||||
|
| 'syncing'
|
||||||
|
| 'scanning'
|
||||||
|
| 'scraping'
|
||||||
|
| 'done'
|
||||||
|
| 'na'
|
||||||
|
| 'failed'
|
||||||
|
|
||||||
|
export type CloudDownloadSourceType = 'magnet' | 'torrent' | 'ed2k' | 'http' | 'ftp'
|
||||||
|
|
||||||
|
export interface CloudDownloadTask {
|
||||||
|
id: string
|
||||||
|
account_id: string
|
||||||
|
sync_path_id: string
|
||||||
|
library_id?: string
|
||||||
|
provider: string
|
||||||
|
source_type: CloudDownloadSourceType
|
||||||
|
source_url: string
|
||||||
|
source_key: string
|
||||||
|
info_hash?: string
|
||||||
|
torrent_name?: string
|
||||||
|
torrent_sha1?: string
|
||||||
|
pick_code?: string
|
||||||
|
wp_path_id: string
|
||||||
|
save_path?: string
|
||||||
|
status: CloudDownloadStatus
|
||||||
|
percent: number
|
||||||
|
size_bytes: number
|
||||||
|
error: string
|
||||||
|
stage: CloudDownloadStage
|
||||||
|
stage_error: string
|
||||||
|
strm_record_id?: string
|
||||||
|
scan_added: number
|
||||||
|
scrape_queued: number
|
||||||
|
retry_count: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
completed_at?: string | null
|
||||||
|
// 列表接口补充的同步目录信息
|
||||||
|
sync_path_name?: string
|
||||||
|
remote_display_path?: string
|
||||||
|
local_path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudDownloadList {
|
||||||
|
items: CloudDownloadTask[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudDownloadSubmitItem {
|
||||||
|
task_id?: string
|
||||||
|
source: string
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudDownloadSkipped {
|
||||||
|
raw: string
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudDownloadSubmitResult {
|
||||||
|
accepted: number
|
||||||
|
rejected: number
|
||||||
|
items: CloudDownloadSubmitItem[]
|
||||||
|
skipped?: CloudDownloadSkipped[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudDownloadQuotaPackage {
|
||||||
|
surplus: number
|
||||||
|
used: number
|
||||||
|
count: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudDownloadQuota {
|
||||||
|
package?: CloudDownloadQuotaPackage[]
|
||||||
|
count: number
|
||||||
|
surplus: number
|
||||||
|
used: number
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user