Files
MeBox/internal/service/strm_queue.go
T
truewhile af67f4cd6e 优化
2026-09-01 16:26:25 +08:00

694 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// STRM 元数据下载/上传队列 worker。
//
// 下载队列:远端网盘 → 本地输出目录(nfo/图片/字幕);上传队列:本地 → 远端。
// 任务持久化在 DB,worker 轮询认领;失败按指数退避重试,超过上限标记 failed。
package service
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/service/cloud"
"github.com/ShukeBta/MMTL/internal/service/cloud115"
)
const (
strmMaxTaskRetry = 3
)
// downloadWorker 下载队列 worker:认领 → 解析直链 → 下载 → 落盘。
//
// 采用「批量认领 + 全局并发限流」:一次认领数个任务,用 StrmService 上的全局信号量
// 限制整个进程「同时换直链+下载」的并发数(与 115 换链风控匹配,见 strmDownloadSemCap),
// 同时让下载充分并行。换链走全局令牌桶(QPS=3)兜底,下载走 CDN 不限速。
func (s *StrmService) downloadWorker(ctx context.Context) {
const claimBatch = 12 // 每次批量认领的任务数
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
default:
}
// 115 风控/限流熔断:冷却期间整体暂停,不给 WAF 续封机会
if left := s.wafCooldownLeft(); left > 0 {
s.log.Debug("下载队列冷却中", zap.Duration("remaining", left))
sleepContext(ctx, left)
continue
}
tasks, err := s.repo.StrmDownload.ClaimPendingDownload(ctx, claimBatch)
if err != nil {
s.log.Warn("claim strm download task failed", zap.Error(err))
sleepContext(ctx, 3*time.Second)
continue
}
if len(tasks) == 0 {
sleepContext(ctx, 2*time.Second)
continue
}
// 并发处理本批任务:每个任务先获取全局下载槽位,槽位内部执行换链+下载。
// 信号量与令牌桶双重限速,确保任意时刻并发换链请求不超过安全阈值。
var wg sync.WaitGroup
for i := range tasks {
wg.Add(1)
go func(i int) {
defer wg.Done()
if !s.acquireDownloadSlot(ctx) {
return
}
defer s.releaseDownloadSlot()
s.processDownloadTask(ctx, &tasks[i])
}(i)
}
wg.Wait()
}
}
func (s *StrmService) processDownloadTask(ctx context.Context, task *model.StrmDownloadTask) {
cleanPath := sanitizeLocalPath(task.LocalPath)
if cleanPath != "" && cleanPath != task.LocalPath {
task.LocalPath = cleanPath
_ = s.repo.StrmDownload.Update(context.Background(), task)
}
finish := func(status, message string) {
now := time.Now()
task.Status = status
task.Error = message
task.FinishedAt = &now
if err := s.repo.StrmDownload.Update(context.Background(), task); err != nil {
s.log.Warn("update strm download task failed", zap.Error(err))
}
}
acct, err := s.repo.StrmAccount.FindByID(ctx, task.AccountID)
if err != nil || acct == nil {
finish(model.StrmTaskFailed, "网盘账号不存在")
return
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
s.downloadTaskFailWithRetry(task, err.Error())
return
}
link, err := provider.Resolve(ctx, task.RemoteRef)
if err != nil {
if is115Blocked(err) {
s.triggerWAFCooldown()
}
s.downloadTaskFailWithRetry(task, "解析下载地址失败:"+err.Error())
return
}
if err := downloadToFile(ctx, link, task.LocalPath, s.http); err != nil {
// 直链失效(403/404/410 等):清掉缓存让下一轮重新换取
if isHTTPDownloadFailure(err) && task.Provider == model.StrmProvider115 {
cloud115.ClearDownloadURLCache(task.RemoteRef)
}
s.downloadTaskFailWithRetry(task, "下载失败:"+err.Error())
return
}
finish(model.StrmTaskDone, "")
}
// uploadWorker 上传队列 worker:认领 → WebDAV/OpenList 上传 → 收尾。
func (s *StrmService) uploadWorker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
default:
}
tasks, err := s.repo.StrmUpload.ClaimPendingUpload(ctx, 1)
if err != nil {
s.log.Warn("claim strm upload task failed", zap.Error(err))
sleepContext(ctx, 3*time.Second)
continue
}
if len(tasks) == 0 {
sleepContext(ctx, 2*time.Second)
continue
}
for i := range tasks {
s.processUploadTask(ctx, &tasks[i])
}
}
}
func (s *StrmService) processUploadTask(ctx context.Context, task *model.StrmUploadTask) {
finish := func(status, message string) {
now := time.Now()
task.Status = status
task.Error = message
task.FinishedAt = &now
if err := s.repo.StrmUpload.Update(context.Background(), task); err != nil {
s.log.Warn("update strm upload task failed", zap.Error(err))
}
}
if task.Provider == model.StrmProvider115 {
s.processUpload115(ctx, task)
return
}
acct, err := s.repo.StrmAccount.FindByID(ctx, task.AccountID)
if err != nil || acct == nil {
finish(model.StrmTaskFailed, "网盘账号不存在")
return
}
cfg, err := s.strmAccountConfig(acct)
if err == nil && task.Provider == model.StrmProviderOpenList && cfg["token"] == "" && cfg["password"] == "" {
finish(model.StrmTaskFailed, "OpenList 账号需要配置 Token 或密码才能上传")
return
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
s.uploadTaskFailWithRetry(task, err.Error())
return
}
putter, ok := provider.(interface {
PutFile(ctx context.Context, remotePath string, r io.Reader) error
})
if !ok {
finish(model.StrmTaskFailed, "该网盘不支持元数据上传")
return
}
f, err := os.Open(task.LocalPath)
if err != nil {
s.uploadTaskFailWithRetry(task, "打开本地文件失败:"+err.Error())
return
}
if err := putter.PutFile(ctx, task.RemotePath, f); err != nil {
_ = f.Close()
s.uploadTaskFailWithRetry(task, "上传失败:"+err.Error())
return
}
_ = f.Close()
finish(model.StrmTaskDone, "")
}
// processUpload115 115 元数据上传:task.RemotePath 存的是父目录 cid,FileName 为远端文件名。
func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUploadTask) {
finish := func(status, message string) {
now := time.Now()
task.Status = status
task.Error = message
task.FinishedAt = &now
if err := s.repo.StrmUpload.Update(context.Background(), task); err != nil {
s.log.Warn("update strm upload task failed", zap.Error(err))
}
}
acct, err := s.repo.StrmAccount.FindByID(ctx, task.AccountID)
if err != nil || acct == nil {
finish(model.StrmTaskFailed, "网盘账号不存在")
return
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
s.uploadTaskFailWithRetry(task, err.Error())
return
}
named, ok := provider.(interface {
PutFileNamed(ctx context.Context, parentCID, fileName string, r io.Reader) error
})
if !ok {
finish(model.StrmTaskFailed, "该网盘不支持元数据上传")
return
}
f, err := os.Open(task.LocalPath)
if err != nil {
s.uploadTaskFailWithRetry(task, "打开本地文件失败:"+err.Error())
return
}
if err := named.PutFileNamed(ctx, task.RemotePath, task.FileName, f); err != nil {
_ = f.Close()
s.uploadTaskFailWithRetry(task, "上传失败:"+err.Error())
return
}
_ = f.Close()
finish(model.StrmTaskDone, "")
}
// downloadTaskFailWithRetry 下载失败任务按退避重试,超过上限标记 failed。
func (s *StrmService) downloadTaskFailWithRetry(task *model.StrmDownloadTask, message string) {
if !retryTask(&task.RetryCount, &task.Status, &task.Error, &task.NextTryAt, &task.FinishedAt, message) {
return
}
_ = s.repo.StrmDownload.Update(context.Background(), task)
}
// uploadTaskFailWithRetry 上传失败任务按退避重试,超过上限标记 failed。
func (s *StrmService) uploadTaskFailWithRetry(task *model.StrmUploadTask, message string) {
if !retryTask(&task.RetryCount, &task.Status, &task.Error, &task.NextTryAt, &task.FinishedAt, message) {
return
}
_ = s.repo.StrmUpload.Update(context.Background(), task)
}
// retryTask 失败状态机:重试次数不足则回 pending 并设置退避时间,否则 failed。
// 返回 false 表示无需再次落库(每次都会通过 Update 落库,因此恒返回 true)。
func retryTask(retryCount *int, status *string, errMsg *string, nextTryAt **time.Time, finishedAt **time.Time, message string) bool {
now := time.Now()
if *retryCount >= strmMaxTaskRetry {
*status = model.StrmTaskFailed
*errMsg = message
*finishedAt = &now
return true
}
*retryCount++
*status = model.StrmTaskPending
*errMsg = message
next := now.Add(time.Duration(*retryCount) * 30 * time.Second)
*nextTryAt = &next
*finishedAt = nil
return true
}
// downloadToFile 把直链内容下载到目标文件(临时文件 + 原子改名)。
func downloadToFile(ctx context.Context, link *cloud.DirectLink, target string, client *http.Client) error {
target = sanitizeLocalPath(target)
if link == nil || link.URL == "" {
return errors.New("空下载地址")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
if err != nil {
return err
}
for k, v := range link.Headers {
req.Header.Set(k, v)
}
// 115 的 CDN 直链要求与换取链接时相同的浏览器 UA,否则返回 403;
// 其他网盘(WebDAV/OpenList)对该值不敏感,统一兜底设置。
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", cloud115.DefaultUA)
}
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("http %d", resp.StatusCode)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
tmp := target + ".tmp"
out, err := os.Create(tmp)
if err != nil {
return err
}
_, copyErr := io.Copy(out, resp.Body)
closeErr := out.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return copyErr
}
if closeErr != nil {
_ = os.Remove(tmp)
return closeErr
}
return os.Rename(tmp, target)
}
// queueCleanupLoop 定期清理 7 天前的完成/失败/取消任务。
func (s *StrmService) queueCleanupLoop(ctx context.Context) {
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case <-ticker.C:
before := time.Now().AddDate(0, 0, -7)
if err := s.repo.StrmDownload.DeleteFinishedOlderThan(ctx, before); err != nil {
s.log.Warn("clean old strm download tasks failed", zap.Error(err))
}
if err := s.repo.StrmUpload.DeleteFinishedOlderThan(ctx, before); err != nil {
s.log.Warn("clean old strm upload tasks failed", zap.Error(err))
}
}
}
}
// ─── 队列查询与操作(handler 使用) ─────────────────────────────────────────────
// StrmQueueCounts 队列统计。
type StrmQueueCounts struct {
Pending int64 `json:"pending"`
Running int64 `json:"running"`
Done int64 `json:"done"`
Failed int64 `json:"failed"`
Canceled int64 `json:"canceled"`
}
// StrmQueueSnapshot 队列快照(统计 + 任务明细,分页)。
type StrmQueueSnapshot struct {
Counts StrmQueueCounts `json:"counts"`
Tasks []strmTaskView `json:"tasks"`
Total int64 `json:"total"` // 当前过滤条件下任务总数
Page int `json:"page"` // 当前页码(从 1 开始)
PageSize int `json:"page_size"` // 单页大小
}
// strmTaskView 队列任务统一视图(下载/上传共用)。
type strmTaskView struct {
ID string `json:"id"`
Kind string `json:"kind"` // download / upload
SyncPathID string `json:"sync_path_id"`
AccountID string `json:"account_id"`
Provider string `json:"provider"`
FileName string `json:"file_name"`
LocalPath string `json:"local_path"`
RemotePath string `json:"remote_path"`
Size int64 `json:"size"`
Status string `json:"status"`
Error string `json:"error"`
RetryCount int `json:"retry_count"`
CreatedAt string `json:"created_at"`
StartedAt *string `json:"started_at"`
FinishedAt *string `json:"finished_at"`
}
// DownloadQueueSnapshot 下载队列快照(分页)。
func (s *StrmService) DownloadQueueSnapshot(ctx context.Context, status string, page, pageSize int) (*StrmQueueSnapshot, error) {
page, pageSize = normalizeStrmTaskPage(page, pageSize)
tasks, total, err := s.repo.StrmDownload.List(ctx, status, page, pageSize)
if err != nil {
return nil, err
}
counts, err := s.repo.StrmDownload.CountByStatus(ctx)
if err != nil {
return nil, err
}
snap := &StrmQueueSnapshot{Counts: strmTaskCountsFrom(counts), Tasks: make([]strmTaskView, 0, len(tasks)), Total: total, Page: page, PageSize: pageSize}
for i := range tasks {
t := &tasks[i]
snap.Tasks = append(snap.Tasks, strmTaskView{
ID: t.ID,
Kind: "download",
SyncPathID: t.SyncPathID,
AccountID: t.AccountID,
Provider: t.Provider,
FileName: t.FileName,
LocalPath: t.LocalPath,
RemotePath: t.RemoteDir + "/" + t.FileName,
Size: t.Size,
Status: t.Status,
Error: t.Error,
RetryCount: t.RetryCount,
CreatedAt: t.CreatedAt.Local().Format(time.RFC3339),
StartedAt: timePtrString(t.StartedAt),
FinishedAt: timePtrString(t.FinishedAt),
})
}
return snap, nil
}
// UploadQueueSnapshot 上传队列快照(分页)。
func (s *StrmService) UploadQueueSnapshot(ctx context.Context, status string, page, pageSize int) (*StrmQueueSnapshot, error) {
page, pageSize = normalizeStrmTaskPage(page, pageSize)
tasks, total, err := s.repo.StrmUpload.List(ctx, status, page, pageSize)
if err != nil {
return nil, err
}
counts, err := s.repo.StrmUpload.CountByStatus(ctx)
if err != nil {
return nil, err
}
snap := &StrmQueueSnapshot{Counts: strmTaskCountsFrom(counts), Tasks: make([]strmTaskView, 0, len(tasks)), Total: total, Page: page, PageSize: pageSize}
for i := range tasks {
t := &tasks[i]
snap.Tasks = append(snap.Tasks, strmTaskView{
ID: t.ID,
Kind: "upload",
SyncPathID: t.SyncPathID,
AccountID: t.AccountID,
Provider: t.Provider,
FileName: t.FileName,
LocalPath: t.LocalPath,
RemotePath: t.RemotePath,
Size: t.Size,
Status: t.Status,
Error: t.Error,
RetryCount: t.RetryCount,
CreatedAt: t.CreatedAt.Local().Format(time.RFC3339),
StartedAt: timePtrString(t.StartedAt),
FinishedAt: timePtrString(t.FinishedAt),
})
}
return snap, nil
}
func strmTaskCountsFrom(m map[string]int64) StrmQueueCounts {
return StrmQueueCounts{
Pending: m[model.StrmTaskPending],
Running: m[model.StrmTaskRunning],
Done: m[model.StrmTaskDone],
Failed: m[model.StrmTaskFailed],
Canceled: m[model.StrmTaskCanceled],
}
}
// normalizeStrmTaskPage 钳制队列分页参数(与 repository 一致,保证回显正确)。
func normalizeStrmTaskPage(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 50
}
return page, pageSize
}
func timePtrString(t *time.Time) *string {
if t == nil {
return nil
}
v := t.Local().Format(time.RFC3339)
return &v
}
// CancelDownloadTask 取消一个排队/进行中的下载任务。
func (s *StrmService) CancelDownloadTask(ctx context.Context, id string) error {
task, err := s.repo.StrmDownload.FindByID(ctx, id)
if err != nil || task == nil {
return errNotFoundOr(err, "下载任务不存在")
}
if task.Status != model.StrmTaskPending && task.Status != model.StrmTaskRunning {
return errors.New("任务已结束,无法取消")
}
now := time.Now()
task.Status = model.StrmTaskCanceled
task.Error = "已取消"
task.FinishedAt = &now
return s.repo.StrmDownload.Update(ctx, task)
}
// RetryDownloadTask 重试一个失败的下载任务。
func (s *StrmService) RetryDownloadTask(ctx context.Context, id string) error {
task, err := s.repo.StrmDownload.FindByID(ctx, id)
if err != nil || task == nil {
return errNotFoundOr(err, "下载任务不存在")
}
if task.Status != model.StrmTaskFailed && task.Status != model.StrmTaskCanceled {
return errors.New("只有失败/已取消的任务可以重试")
}
task.Status = model.StrmTaskPending
task.Error = ""
task.RetryCount = 0
task.NextTryAt = nil
task.FinishedAt = nil
return s.repo.StrmDownload.Update(ctx, task)
}
// CancelUploadTask 取消一个排队/进行中的上传任务。
func (s *StrmService) CancelUploadTask(ctx context.Context, id string) error {
task, err := s.repo.StrmUpload.FindByID(ctx, id)
if err != nil || task == nil {
return errNotFoundOr(err, "上传任务不存在")
}
if task.Status != model.StrmTaskPending && task.Status != model.StrmTaskRunning {
return errors.New("任务已结束,无法取消")
}
now := time.Now()
task.Status = model.StrmTaskCanceled
task.Error = "已取消"
task.FinishedAt = &now
return s.repo.StrmUpload.Update(ctx, task)
}
// RetryUploadTask 重试一个失败的上传任务。
func (s *StrmService) RetryUploadTask(ctx context.Context, id string) error {
task, err := s.repo.StrmUpload.FindByID(ctx, id)
if err != nil || task == nil {
return errNotFoundOr(err, "上传任务不存在")
}
if task.Status != model.StrmTaskFailed && task.Status != model.StrmTaskCanceled {
return errors.New("只有失败/已取消的任务可以重试")
}
task.Status = model.StrmTaskPending
task.Error = ""
task.RetryCount = 0
task.NextTryAt = nil
task.FinishedAt = nil
return s.repo.StrmUpload.Update(ctx, task)
}
// ─── 下载队列批量操作(handler 使用) ─────────────────────────────────────────
// DeleteDownloadTask 删除一个下载任务记录。
func (s *StrmService) DeleteDownloadTask(ctx context.Context, id string) error {
return s.repo.StrmDownload.Delete(ctx, id)
}
// DeleteUploadTask 删除一个上传任务记录。
func (s *StrmService) DeleteUploadTask(ctx context.Context, id string) error {
return s.repo.StrmUpload.Delete(ctx, id)
}
// BatchActionDownloadTasks 对选中的下载任务执行批量操作(delete / retry / cancel)。
func (s *StrmService) BatchActionDownloadTasks(ctx context.Context, action string, ids []string) (int64, error) {
switch action {
case "delete":
return s.repo.StrmDownload.DeleteBatch(ctx, ids)
case "retry":
return s.repo.StrmDownload.RetryBatch(ctx, ids)
case "cancel":
return s.repo.StrmDownload.CancelBatch(ctx, ids)
default:
return 0, fmt.Errorf("不支持的批量操作: %s", action)
}
}
// BatchActionUploadTasks 对选中的上传任务执行批量操作(delete / retry / cancel)。
func (s *StrmService) BatchActionUploadTasks(ctx context.Context, action string, ids []string) (int64, error) {
switch action {
case "delete":
return s.repo.StrmUpload.DeleteBatch(ctx, ids)
case "retry":
return s.repo.StrmUpload.RetryBatch(ctx, ids)
case "cancel":
return s.repo.StrmUpload.CancelBatch(ctx, ids)
default:
return 0, fmt.Errorf("不支持的批量操作: %s", action)
}
}
// ClearDoneDownloadTasks 清空全部已完成下载记录,返回删除数量。
func (s *StrmService) ClearDoneDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.ClearDone(ctx)
}
// ClearFinishedDownloadTasks 清空全部已完成与失败的下载记录,返回删除数量。
func (s *StrmService) ClearFinishedDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.ClearFinished(ctx)
}
// ClearCanceledDownloadTasks 清空全部已取消的下载记录,返回删除数量。
func (s *StrmService) ClearCanceledDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.ClearCanceled(ctx)
}
// ClearCanceledUploadTasks 清空全部已取消的上传记录,返回删除数量。
func (s *StrmService) ClearCanceledUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.ClearCanceled(ctx)
}
// ClearDoneUploadTasks 清空全部已完成上传记录,返回删除数量。
func (s *StrmService) ClearDoneUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.ClearDone(ctx)
}
// ClearFinishedUploadTasks 清空全部已完成与失败的上传记录,返回删除数量。
func (s *StrmService) ClearFinishedUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.ClearFinished(ctx)
}
// RetryAllFailedDownloadTasks 批量重试所有失败下载任务,返回重新入队数量。
func (s *StrmService) RetryAllFailedDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.RetryAllFailed(ctx)
}
// RetryAllFailedUploadTasks 批量重试所有失败上传任务,返回重新入队数量。
func (s *StrmService) RetryAllFailedUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.RetryAllFailed(ctx)
}
// CancelPendingDownloadTasks 批量取消所有排队下载任务,返回取消数量。
func (s *StrmService) CancelPendingDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.CancelPending(ctx)
}
// CancelPendingUploadTasks 批量取消所有排队上传任务,返回取消数量。
func (s *StrmService) CancelPendingUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.CancelPending(ctx)
}
func sleepContext(ctx context.Context, d time.Duration) {
select {
case <-ctx.Done():
case <-time.After(d):
}
}
// ─── 115 风控/限流熔断 ────────────────────────────────────────────────────────
// triggerWAFCooldown 检测到 115 风控/限流后触发全局冷却,冷却期间下载 worker 暂停。
// 冷却时间取最大值,避免连续触发时缩短等待。
func (s *StrmService) triggerWAFCooldown() {
s.mu.Lock()
defer s.mu.Unlock()
until := time.Now().Add(strmWAFCooldown)
if until.After(s.wafUntil) {
s.wafUntil = until
s.log.Warn("115 风控/限流,下载队列进入冷却", zap.Duration("cooldown", strmWAFCooldown))
}
}
// wafCooldownLeft 返回剩余冷却时间(0 表示无需冷却)。
func (s *StrmService) wafCooldownLeft() time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
if s.wafUntil.After(time.Now()) {
return time.Until(s.wafUntil)
}
return 0
}
// is115Blocked 判断错误是否来自 115 的风控/限流(WAF 405 拦截页或限流错误码)。
func is115Blocked(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "115 接口返回 http 405") ||
strings.Contains(msg, "访问被阻断") ||
strings.Contains(msg, "request has been blocked") ||
strings.Contains(msg, "115 接口错误(770004") ||
strings.Contains(msg, "115 接口错误(406")
}
// isHTTPDownloadFailure 判断下载是否因 HTTP 状态码失败(直链失效需清缓存重取)。
func isHTTPDownloadFailure(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "http 4") || strings.Contains(msg, "http 5")
}