This commit is contained in:
truewhile
2026-11-12 18:58:45 +08:00
parent 79943d9067
commit d7c150ffd9
29 changed files with 4158 additions and 5 deletions
+163
View File
@@ -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})
}
}
+1
View File
@@ -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)
+81
View File
@@ -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"`
}
+1
View File
@@ -60,6 +60,7 @@ func AllModels() []interface{} {
&StrmDownloadTask{},
&StrmUploadTask{},
&StrmDirCache{},
&CloudDownloadTask{},
&ScrapeTask{},
&BackgroundTaskRecord{},
&EmbyMount{},
@@ -0,0 +1,241 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// CloudDownloadRepository persists model.CloudDownloadTask(115 云下载任务)。
type CloudDownloadRepository struct{ db *gorm.DB }
// CloudDownloadQuery 是任务列表的过滤条件。
type CloudDownloadQuery struct {
Status string // 精确匹配;空表示全部
SyncPathID string
AccountID string
Page int
PageSize int
}
// Create 插入一条云下载任务。
func (r *CloudDownloadRepository) Create(ctx context.Context, t *model.CloudDownloadTask) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(t).Error
})
}
// Update 更新任务的可变字段(不含主键与创建时间)。
func (r *CloudDownloadRepository) Update(ctx context.Context, t *model.CloudDownloadTask) error {
return r.UpdateFields(ctx, t.ID, map[string]any{
"account_id": t.AccountID,
"sync_path_id": t.SyncPathID,
"library_id": t.LibraryID,
"provider": t.Provider,
"source_type": t.SourceType,
"source_url": t.SourceURL,
"source_key": t.SourceKey,
"info_hash": t.InfoHash,
"torrent_name": t.TorrentName,
"torrent_sha1": t.TorrentSha1,
"pick_code": t.PickCode,
"wp_path_id": t.WpPathID,
"save_path": t.SavePath,
"status": t.Status,
"percent": t.Percent,
"size_bytes": t.SizeBytes,
"error": t.Error,
"stage": t.Stage,
"stage_error": t.StageError,
"strm_record_id": t.StrmRecordID,
"scan_added": t.ScanAdded,
"scrape_queued": t.ScrapeQueued,
"retry_count": t.RetryCount,
"last_poll_at": t.LastPollAt,
"next_poll_at": t.NextPollAt,
"completed_at": t.CompletedAt,
})
}
// UpdateFields 局部更新:调用方只写自己关心的列,避免覆盖并发写入的其它阶段字段
// (轮询线程写 Status,后处理线程写 Stage,两者必须互不覆盖)。
func (r *CloudDownloadRepository) UpdateFields(ctx context.Context, id string, fields map[string]any) error {
if id == "" || len(fields) == 0 {
return nil
}
fields["updated_at"] = time.Now()
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.CloudDownloadTask{}).Where("id = ?", id).Updates(fields).Error
})
}
// FindByID 按主键查询,未找到返回 (nil, nil)。
func (r *CloudDownloadRepository) FindByID(ctx context.Context, id string) (*model.CloudDownloadTask, error) {
var t model.CloudDownloadTask
err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &t, nil
}
// List 分页查询任务列表,同时返回总数。
func (r *CloudDownloadRepository) List(ctx context.Context, q CloudDownloadQuery) ([]model.CloudDownloadTask, int64, error) {
db := r.db.WithContext(ctx).Model(&model.CloudDownloadTask{})
if q.Status != "" {
db = db.Where("status = ?", q.Status)
}
if q.SyncPathID != "" {
db = db.Where("sync_path_id = ?", q.SyncPathID)
}
if q.AccountID != "" {
db = db.Where("account_id = ?", q.AccountID)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
page := q.Page
if page < 1 {
page = 1
}
size := q.PageSize
if size <= 0 {
size = 20
}
if size > 200 {
size = 200
}
var rows []model.CloudDownloadTask
err := db.Order("created_at desc").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error
return rows, total, err
}
// ListByStatuses 取指定状态的任务(轮询用)。
func (r *CloudDownloadRepository) ListByStatuses(ctx context.Context, statuses []string) ([]model.CloudDownloadTask, error) {
if len(statuses) == 0 {
return nil, nil
}
var rows []model.CloudDownloadTask
err := r.db.WithContext(ctx).
Where("status IN ?", statuses).
Order("created_at asc").
Find(&rows).Error
return rows, err
}
// ListBySyncPathAndStage 取某个同步目录下处于指定阶段的任务(后处理推进用)。
func (r *CloudDownloadRepository) ListBySyncPathAndStage(ctx context.Context, syncPathID, stage string) ([]model.CloudDownloadTask, error) {
var rows []model.CloudDownloadTask
err := r.db.WithContext(ctx).
Where("sync_path_id = ? AND stage = ?", syncPathID, stage).
Order("created_at asc").
Find(&rows).Error
return rows, err
}
// ListPipelineCandidates 取「下载已成功、后处理尚未完成」的任务。
func (r *CloudDownloadRepository) ListPipelineCandidates(ctx context.Context) ([]model.CloudDownloadTask, error) {
var rows []model.CloudDownloadTask
err := r.db.WithContext(ctx).
Where("status = ? AND stage IN ?", model.CloudDownloadSucceeded,
[]string{model.CloudDownloadStagePending, model.CloudDownloadStageWaitingSync}).
Order("created_at asc").
Find(&rows).Error
return rows, err
}
// ResetStaleStages 把上次进程退出时停在中间阶段的任务重置为待推进。
// STRM 同步/扫描/刮削都是幂等操作,重启后重新推进是安全的。
func (r *CloudDownloadRepository) ResetStaleStages(ctx context.Context) (int64, error) {
var affected int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.CloudDownloadTask{}).
Where("stage IN ?", []string{
model.CloudDownloadStageSyncing,
model.CloudDownloadStageScanning,
model.CloudDownloadStageScraping,
}).
Updates(map[string]any{
"stage": model.CloudDownloadStagePending,
"stage_error": "",
"updated_at": time.Now(),
})
affected = res.RowsAffected
return res.Error
})
return affected, err
}
// FindActiveBySourceKeys 返回给定匹配键中仍处于活跃状态的任务键集合。
// 用于提交时去重:同一个磁力重复提交只会浪费 115 配额。
func (r *CloudDownloadRepository) FindActiveBySourceKeys(ctx context.Context, keys []string) (map[string]bool, error) {
out := make(map[string]bool, len(keys))
if len(keys) == 0 {
return out, nil
}
var found []string
err := r.db.WithContext(ctx).Model(&model.CloudDownloadTask{}).
Where("source_key IN ?", keys).
Where("status IN ?", []string{
model.CloudDownloadQueued,
model.CloudDownloadSubmitted,
model.CloudDownloadDownloading,
}).
Pluck("source_key", &found).Error
if err != nil {
return out, err
}
for _, k := range found {
out[k] = true
}
return out, nil
}
// Delete 硬删除一条任务记录。
func (r *CloudDownloadRepository) Delete(ctx context.Context, id string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.CloudDownloadTask{}).Error
})
}
// ClearFinished 删除所有「无后续价值」的终态任务(失败/已取消)。
// 成功的任务保留:它们承载「本地已生成 strm / 已刮削」的证据,供用户回查。
func (r *CloudDownloadRepository) ClearFinished(ctx context.Context) (int64, error) {
var affected int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().
Where("status IN ?", []string{
model.CloudDownloadFailed,
model.CloudDownloadCanceled,
}).
Delete(&model.CloudDownloadTask{})
affected = res.RowsAffected
return res.Error
})
return affected, err
}
// CountBySyncPath 统计某同步目录下仍在活跃的任务数(前端展示用)。
func (r *CloudDownloadRepository) CountBySyncPath(ctx context.Context, syncPathID string) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.CloudDownloadTask{}).
Where("sync_path_id = ?", syncPathID).
Where("status IN ?", []string{
model.CloudDownloadQueued,
model.CloudDownloadSubmitted,
model.CloudDownloadDownloading,
}).
Count(&n).Error
return n, err
}
+14
View File
@@ -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
+2
View File
@@ -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},
+56 -5
View File
@@ -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)
+303
View File
@@ -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, &quota); err != nil {
return nil, err
}
return &quota, 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
}
+170
View File
@@ -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)
}
}
+433
View File
@@ -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)
}
+121
View File
@@ -0,0 +1,121 @@
// 云下载输入解析:把用户粘贴的多行文本拆成可提交的链接,并生成轮询匹配键。
//
// 支持的来源(与 115 /open/offline/add_task_urls 能力一致):
// - 磁力:magnet:?xt=urn:btih:<hex40 或 base32>
// - 电驴:ed2k://|file|...
// - HTTP(S) / FTP 直链
//
// 只做「字符串层面」的识别,绝不主动请求这些 URL(无 SSRF 面)。
package service
import (
"net/url"
"strings"
)
// cloudDownloadParsedLink 是一条解析后的待提交链接。
type cloudDownloadParsedLink struct {
SourceType string // model.CloudDownloadSource*
URL string // 原始(trim 后)链接
Key string // 去重与轮询匹配键:BT 类为大写 info_hash,其余为链接本身
InfoHash string // 仅 BT 类有值
}
// cloudDownloadSkipped 是一条被跳过的原始输入。
type cloudDownloadSkipped struct {
Raw string `json:"raw"`
Reason string `json:"reason"`
}
// parseCloudDownloadLinks 解析多行输入。
// 返回去重后的有效链接与被跳过的行(用于前端逐条反馈,而不是笼统报错)。
func parseCloudDownloadLinks(raw []string) ([]cloudDownloadParsedLink, []cloudDownloadSkipped) {
out := make([]cloudDownloadParsedLink, 0, len(raw))
skipped := make([]cloudDownloadSkipped, 0)
seen := make(map[string]bool, len(raw))
for _, line := range raw {
// 允许多行文本整体粘贴:按换行再拆一次。
for _, part := range strings.Split(line, "\n") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
link, reason := parseCloudDownloadLink(part)
if reason != "" {
skipped = append(skipped, cloudDownloadSkipped{Raw: part, Reason: reason})
continue
}
if seen[link.Key] {
skipped = append(skipped, cloudDownloadSkipped{Raw: part, Reason: "重复链接(本批已存在)"})
continue
}
seen[link.Key] = true
out = append(out, link)
}
}
return out, skipped
}
// parseCloudDownloadLink 识别单条链接;reason 非空表示不支持。
func parseCloudDownloadLink(raw string) (cloudDownloadParsedLink, string) {
lower := strings.ToLower(raw)
switch {
case strings.HasPrefix(lower, "magnet:"):
hash := magnetInfoHash(raw)
if hash == "" {
return cloudDownloadParsedLink{}, "磁力链接缺少有效的 btih 哈希"
}
return cloudDownloadParsedLink{
SourceType: "magnet",
URL: raw,
Key: hash,
InfoHash: hash,
}, ""
case strings.HasPrefix(lower, "ed2k://"):
return cloudDownloadParsedLink{
SourceType: "ed2k",
URL: raw,
Key: raw,
}, ""
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
return cloudDownloadParsedLink{
SourceType: "http",
URL: raw,
Key: raw,
}, ""
case strings.HasPrefix(lower, "ftp://"):
return cloudDownloadParsedLink{
SourceType: "ftp",
URL: raw,
Key: raw,
}, ""
default:
return cloudDownloadParsedLink{}, "不支持的链接类型(仅支持磁力 / ed2k / http(s) / ftp)"
}
}
// magnetInfoHash 从磁力链接中提取大写化的 btih 哈希。
// 兼容 hex(40 字符)与 base32(32 字符)两种写法;无法识别时返回空串。
func magnetInfoHash(link string) string {
u, err := url.Parse(strings.TrimSpace(link))
if err != nil {
return ""
}
for _, xt := range u.Query()["xt"] {
const prefix = "urn:btih:"
if len(xt) <= len(prefix) || !strings.EqualFold(xt[:len(prefix)], prefix) {
continue
}
hash := strings.TrimSpace(xt[len(prefix):])
// 去掉可能存在的 &dn= 之类残留(少数客户端拼接不规范)。
if idx := strings.IndexAny(hash, "&?#"); idx >= 0 {
hash = hash[:idx]
}
if len(hash) != 40 && len(hash) != 32 {
continue
}
return strings.ToUpper(hash)
}
return ""
}
+285
View File
@@ -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
}
}
+149
View File
@@ -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
}
+439
View File
@@ -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)
}
+220
View File
@@ -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)
}
}
+17
View File
@@ -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" {
@@ -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)
}
+9
View File
@@ -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()
}
+7
View File
@@ -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 {
+52
View File
@@ -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 {
+12
View File
@@ -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 {