diff --git a/docs/cloud-download-plan.md b/docs/cloud-download-plan.md new file mode 100644 index 0000000..4def442 --- /dev/null +++ b/docs/cloud-download-plan.md @@ -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 `,即现有 `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: , 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` | diff --git a/internal/handler/cloud_download.go b/internal/handler/cloud_download.go new file mode 100644 index 0000000..e0c9a36 --- /dev/null +++ b/internal/handler/cloud_download.go @@ -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}) + } +} diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index 70a54c2..5daddf3 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -22,6 +22,7 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service. registerAdminAPIConfigRoutes(admin, svc) registerAdminRecognitionWordRoutes(admin, svc) registerAdminStrmRoutes(admin, svc) + registerAdminCloudDownloadRoutes(admin, svc) registerAdminScraperRoutes(admin, svc) registerAdminDatabaseRoutes(admin, svc) diff --git a/internal/model/cloud_download.go b/internal/model/cloud_download.go new file mode 100644 index 0000000..8585de7 --- /dev/null +++ b/internal/model/cloud_download.go @@ -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"` +} diff --git a/internal/model/model.go b/internal/model/model.go index a3b035b..c6a66f2 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -60,6 +60,7 @@ func AllModels() []interface{} { &StrmDownloadTask{}, &StrmUploadTask{}, &StrmDirCache{}, + &CloudDownloadTask{}, &ScrapeTask{}, &BackgroundTaskRecord{}, &EmbyMount{}, diff --git a/internal/repository/cloud_download_repository.go b/internal/repository/cloud_download_repository.go new file mode 100644 index 0000000..2d90dd0 --- /dev/null +++ b/internal/repository/cloud_download_repository.go @@ -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 +} diff --git a/internal/repository/library_repository.go b/internal/repository/library_repository.go index 68a9f5f..c04eec2 100644 --- a/internal/repository/library_repository.go +++ b/internal/repository/library_repository.go @@ -123,6 +123,20 @@ func (r *LibraryRepository) ListRoots(ctx context.Context, libraryID string) ([] 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) { if !r.hasLibraryRootsTable() { return nil, nil diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 666a4b5..0a8a69b 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -35,6 +35,7 @@ type Container struct { StrmDownload *StrmDownloadTaskRepository StrmUpload *StrmUploadTaskRepository StrmDirCache *StrmDirCacheRepository + CloudDownload *CloudDownloadRepository ScrapeTask *ScrapeTaskRepository EmbyMount *EmbyMountRepository Reader *ReaderRepository @@ -69,6 +70,7 @@ func New(db *gorm.DB) *Container { StrmDownload: &StrmDownloadTaskRepository{db: db}, StrmUpload: &StrmUploadTaskRepository{db: db}, StrmDirCache: &StrmDirCacheRepository{db: db}, + CloudDownload: &CloudDownloadRepository{db: db}, ScrapeTask: &ScrapeTaskRepository{db: db}, EmbyMount: &EmbyMountRepository{db: db}, Reader: &ReaderRepository{db: db}, diff --git a/internal/service/cloud115/client.go b/internal/service/cloud115/client.go index d29e803..7939dca 100644 --- a/internal/service/cloud115/client.go +++ b/internal/service/cloud115/client.go @@ -8,8 +8,10 @@ import ( "errors" "fmt" "io" + "mime/multipart" "net/http" "net/url" + "sort" "strings" "sync" "time" @@ -130,14 +132,24 @@ type RespBase struct { // 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) { - executor := c.executor - if executor == nil { - executor = GetGlobalExecutor() - } ua := "" if len(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 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 } - req, err := c.buildRequestWithUA(ctx, method, rawURL, form, access, ua) + req, err := build() if err != nil { return nil, err } @@ -293,6 +305,45 @@ func (c *OpenClient) buildRequestWithUA(ctx context.Context, method, rawURL stri 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 的业务请求。 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) diff --git a/internal/service/cloud115/offline.go b/internal/service/cloud115/offline.go new file mode 100644 index 0000000..b67b382 --- /dev/null +++ b/internal/service/cloud115/offline.go @@ -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 +} diff --git a/internal/service/cloud115/offline_test.go b/internal/service/cloud115/offline_test.go new file mode 100644 index 0000000..ad14021 --- /dev/null +++ b/internal/service/cloud115/offline_test.go @@ -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) + } +} diff --git a/internal/service/cloud_download.go b/internal/service/cloud_download.go new file mode 100644 index 0000000..c205424 --- /dev/null +++ b/internal/service/cloud_download.go @@ -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) +} diff --git a/internal/service/cloud_download_input.go b/internal/service/cloud_download_input.go new file mode 100644 index 0000000..d6e2469 --- /dev/null +++ b/internal/service/cloud_download_input.go @@ -0,0 +1,121 @@ +// 云下载输入解析:把用户粘贴的多行文本拆成可提交的链接,并生成轮询匹配键。 +// +// 支持的来源(与 115 /open/offline/add_task_urls 能力一致): +// - 磁力:magnet:?xt=urn:btih: +// - 电驴: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 "" +} diff --git a/internal/service/cloud_download_pipeline.go b/internal/service/cloud_download_pipeline.go new file mode 100644 index 0000000..bbe22fc --- /dev/null +++ b/internal/service/cloud_download_pipeline.go @@ -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 + } +} diff --git a/internal/service/cloud_download_poll.go b/internal/service/cloud_download_poll.go new file mode 100644 index 0000000..6096f43 --- /dev/null +++ b/internal/service/cloud_download_poll.go @@ -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 +} diff --git a/internal/service/cloud_download_submit.go b/internal/service/cloud_download_submit.go new file mode 100644 index 0000000..1336b27 --- /dev/null +++ b/internal/service/cloud_download_submit.go @@ -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) +} diff --git a/internal/service/cloud_download_test.go b/internal/service/cloud_download_test.go new file mode 100644 index 0000000..b30a3af --- /dev/null +++ b/internal/service/cloud_download_test.go @@ -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) + } +} diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index 0615a5b..a6abc60 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -55,6 +55,9 @@ type SchedulerService struct { segments *MediaSegmentService backup *BackupService + // cloudDownload 用于周期轮询 115 云下载任务状态(nil 表示不启用)。 + cloudDownload *CloudDownloadService + mu sync.Mutex stopCh chan struct{} jobs []*scheduledJob @@ -73,6 +76,11 @@ func (s *SchedulerService) SetBackupService(backup *BackupService) { s.backup = backup } +// SetCloudDownload 注入云下载服务:注入后才注册轮询任务。 +func (s *SchedulerService) SetCloudDownload(cd *CloudDownloadService) { + s.cloudDownload = cd +} + func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) { s.organizePipeline = pipeline } @@ -213,6 +221,15 @@ func (s *SchedulerService) Start(ctx context.Context) { 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 { initialDelay := 15 * time.Second if j.name == "library_scan" || j.name == "organize_source" || j.name == "segment_prewarm" { diff --git a/internal/service/scheduler_cloud_download.go b/internal/service/scheduler_cloud_download.go new file mode 100644 index 0000000..e7ec197 --- /dev/null +++ b/internal/service/scheduler_cloud_download.go @@ -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) +} diff --git a/internal/service/service.go b/internal/service/service.go index 964380e..2f358b2 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -72,6 +72,7 @@ type Container struct { RecognitionWords *RecognitionWordsService Danmaku *DanmakuService Strm *StrmService + CloudDownload *CloudDownloadService Cloud115 *Cloud115PlaybackService Reader *reader.ReaderService Database *DatabaseAdminService @@ -128,6 +129,11 @@ func (c *Container) Boot() { c.Strm.Start(c.stopCtx) } + // 115 云下载:恢复中断的后处理阶段 + 启动后处理推进循环。 + if c.CloudDownload != nil { + c.CloudDownload.Start(c.stopCtx) + } + // 启动刮削队列后台消费者 if c.Scraper != nil { c.Scraper.Start(c.stopCtx) @@ -178,6 +184,9 @@ func (c *Container) Close() { if c.Strm != nil { c.Strm.Stop() } + if c.CloudDownload != nil { + c.CloudDownload.Stop() + } if c.Watcher != nil { c.Watcher.Stop() } diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index a09d901..11a1233 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -146,6 +146,12 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.Profile = NewProfileService(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) + // 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) // 本地 HLS 档位的可用性取决于 ffmpeg 是否可用;未注入时按不可用处理。 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.SetBackupService(b.c.Backup) b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline) + b.c.Scheduler.SetCloudDownload(b.c.CloudDownload) b.c.Scheduler.SetSegments(b.c.Segments) b.c.Scheduler.SetCacheCleanupPolicyProvider(func() CacheCleanupPolicy { if b.cfg == nil { diff --git a/internal/service/strm_service.go b/internal/service/strm_service.go index 9108409..aee0c25 100644 --- a/internal/service/strm_service.go +++ b/internal/service/strm_service.go @@ -100,6 +100,12 @@ type StrmService struct { providerMu sync.Mutex 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 换直链+下载并发上限(风控兜底) downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限 downloadSemOnce sync.Once @@ -503,6 +509,52 @@ func (s *StrmService) ListAccounts(ctx context.Context) ([]model.StrmAccount, er 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 依据账号配置构建网盘驱动。 func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) { if acct != nil && acct.Provider == model.StrmProvider115 { diff --git a/internal/service/strm_sync.go b/internal/service/strm_sync.go index 9c25975..8e2cc5c 100644 --- a/internal/service/strm_sync.go +++ b/internal/service/strm_sync.go @@ -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.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)) + + // 同步结果落库之后再通知下游(云下载后处理:扫描 → 刮削)。 + // 异步 + 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 { diff --git a/web/src/api/cloudDownload.ts b/web/src/api/cloudDownload.ts new file mode 100644 index 0000000..9d00ea7 --- /dev/null +++ b/web/src/api/cloudDownload.ts @@ -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('/admin/cloud-downloads', { params }).then((r) => r.data), + + /** 提交一批链接(磁力 / ed2k / http / ftp)。部分失败也在 200 的 items 里返回。 */ + submit: (payload: { sync_path_id: string; urls: string[] }) => + api + .post('/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('/admin/cloud-downloads/torrent', form, { + timeout: LONG_REQUEST_TIMEOUT, + headers: { 'Content-Type': 'multipart/form-data' }, + }) + .then((r) => r.data) + }, + + quota: (accountID: string) => + api + .get('/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) +} diff --git a/web/src/appRoutes.tsx b/web/src/appRoutes.tsx index a6b2957..3c336a4 100644 --- a/web/src/appRoutes.tsx +++ b/web/src/appRoutes.tsx @@ -48,6 +48,9 @@ const StrmDownloadQueuePage = lazy(() => const StrmUploadQueuePage = lazy(() => 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 TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage }))) const MediaQualityPage = lazy(() => import('./pages/MediaQualityPage').then((m) => ({ default: m.MediaQualityPage }))) @@ -125,6 +128,7 @@ export const appRoutes: AppRoute[] = [ { path: 'strm', element: , adminOnly: true }, { path: 'strm/downloads', element: , adminOnly: true }, { path: 'strm/uploads', element: , adminOnly: true }, + { path: 'cloud-downloads', element: , adminOnly: true }, { path: 'scraper/queue', element: , adminOnly: true }, { path: 'queue', element: , adminOnly: true }, { path: 'quality', element: , adminOnly: true }, diff --git a/web/src/components/layoutNavigation.ts b/web/src/components/layoutNavigation.ts index cfa32c2..8f812c1 100644 --- a/web/src/components/layoutNavigation.ts +++ b/web/src/components/layoutNavigation.ts @@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react' import { Clock, AlertTriangle, + CloudDownload, ListChecks as SetupIcon, FileOutput, FolderOpen, @@ -49,6 +50,7 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [ { to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true }, { to: '/emby-mount', label: 'Emby 挂载', icon: Tv, 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 }, ] diff --git a/web/src/pages/CloudDownloadPage.tsx b/web/src/pages/CloudDownloadPage.tsx new file mode 100644 index 0000000..20fd656 --- /dev/null +++ b/web/src/pages/CloudDownloadPage.tsx @@ -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([]) + const [syncPathID, setSyncPathID] = useState('') + const [urls, setUrls] = useState('') + const [torrentFile, setTorrentFile] = useState(null) + const [quota, setQuota] = useState(null) + const [quotaError, setQuotaError] = useState('') + const [submitting, setSubmitting] = useState(false) + const [lastResult, setLastResult] = useState(null) + + const [tasks, setTasks] = useState([]) + 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, 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 ( +
+ 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" + > + + 刷新 + + } + /> + + {/* 提交面板 */} +
+
+ +
+ 云下载配额 +
+ {quota ? ( + <> + {quota.surplus} + / {quota.count} 剩余 + {quota.surplus <= 0 ? ( + 配额已用尽 + ) : null} + + ) : quotaError ? ( + {quotaError} + ) : ( + 选择目录后加载 + )} +
+
+
+ +