Files
2026-09-11 20:45:54 +08:00

2287 lines
68 KiB
Go
Raw Permalink 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 同步引擎:扫描网盘/本地目录,生成 .strm 文件,按需入队元数据下载/上传,
// 并清理远端已不存在的本地多余文件。参考 QMediaSync 的 STRM 同步流程实现。
package service
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
var errFallbackToWalkRemote = errors.New("fallback to walk remote")
// remoteMetaItem 记录远端存在的单个元数据文件副本信息(大小、文件ID、内容SHA1、修改时间)。
type remoteMetaItem struct {
ID string
Size int64
Sha1 string
MTime int64
}
// strmSyncState 是一次同步执行的上下文。
type strmSyncState struct {
s *StrmService
ctx context.Context
p *model.StrmSyncPath
acct *model.StrmAccount
provider cloud.Provider // local 提供方为 nil
cfg *strmPathConfig
rec *model.StrmSyncRecord
syncType string
mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune)
seenDir map[string]bool // 清洗后的目录相对路径 → 远端存在该目录(供整目录 prune)
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
seenMetaTarget map[string]cloud.FileEntry
seenVideoTarget map[string]cloud.FileEntry
// remoteVideos:prefer 模式下同名(去扩展名)视频候选列表,walk 结束后择优写盘
remoteVideos map[string][]remoteVideoCandidate
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重)
// recentDoneUploadSizes:近期已成功上传的 local_path → size,缩短「done 但列表未到」窗口内的重复入队
recentDoneUploadSizes map[string]int64
pendingDownloads []*model.StrmDownloadTask
pendingUploads []*model.StrmUploadTask
dirCache sync.Map // dirID (string) -> relativePath (string)
dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建)
dirCacheDirty map[string]string // 待批量落库的目录缓存(dirID → 相对路径),避免逐目录单条 upsert
scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件
}
// remoteVideoCandidate 记录同名视频的一个远端副本(不同扩展名/体积)。
type remoteVideoCandidate struct {
entry cloud.FileEntry
rel string
ext string
}
// StartSync 启动一次同步(异步执行,同一目录同时只允许一个任务)。
// syncType 支持 "incremental"(默认增量)和 "full"(全量同步)。
func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ...string) error {
p, err := s.repo.StrmSyncPath.FindByID(ctx, pathID)
if err != nil || p == nil {
return errNotFoundOr(err, "同步目录不存在")
}
if !p.Enabled {
return errors.New("同步目录已禁用")
}
if p.Provider == model.StrmProviderLocal && strings.TrimSpace(p.RemotePath) == "" {
return errors.New("本地同步需要填写源目录")
}
if p.Provider != model.StrmProviderLocal && strings.TrimSpace(p.AccountID) == "" {
return errors.New("该同步目录未关联网盘账号")
}
s.mu.Lock()
if _, exists := s.running[pathID]; exists {
s.mu.Unlock()
return errors.New("该目录正在同步中")
}
// 同步在后台持续执行,不受 HTTP 请求生命周期影响
runCtx, cancel := context.WithCancel(s.baseCtx)
s.running[pathID] = cancel
s.mu.Unlock()
mode := model.StrmSyncTypeIncremental
if len(syncType) > 0 && syncType[0] != "" {
mode = syncType[0]
} else if p.SyncMode != "" {
mode = p.SyncMode
}
if mode != model.StrmSyncTypeFull {
mode = model.StrmSyncTypeIncremental
}
now := time.Now()
rec := &model.StrmSyncRecord{
SyncPathID: pathID,
SyncType: mode,
Status: model.StrmSyncRecordRunning,
StartedAt: &now,
}
if err := s.repo.StrmSyncRecord.Create(ctx, rec); err != nil {
s.clearRunning(pathID, cancel)
cancel()
return err
}
status := model.StrmSyncRecordRunning
p.LastSyncAt = &now
p.LastSyncStatus = status
p.LastSyncMessage = "同步进行中"
_ = s.repo.StrmSyncPath.Update(ctx, p)
helper.Go(s.log, "strm.sync", func() { s.runSync(runCtx, p, rec, cancel) })
return nil
}
// CancelSync 取消正在进行的同步(若为僵尸运行状态则直接自愈重置)。
func (s *StrmService) CancelSync(ctx context.Context, pathID string) error {
s.mu.Lock()
cancel, exists := s.running[pathID]
s.mu.Unlock()
// 不在这里预删 running 标记:runSync 退出时的 clearRunning 会按
// cancel 身份校验后删除,避免旧同步收尾误删新同步的标记。
if exists && cancel != nil {
cancel()
}
// 无论内存中是否活跃,确保同步目录状态正确重置为已取消
if p, err := s.repo.StrmSyncPath.FindByID(ctx, pathID); err == nil && p != nil {
if p.LastSyncStatus == model.StrmSyncRecordRunning {
p.LastSyncStatus = model.StrmSyncRecordCanceled
p.LastSyncMessage = "已取消"
_ = s.repo.StrmSyncPath.Update(ctx, p)
}
}
return nil
}
// IsSyncRunning 报告某同步目录是否正在同步。
func (s *StrmService) IsSyncRunning(pathID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, exists := s.running[pathID]
return exists
}
// clearRunning 清除同步的运行标记;仅当 map 中登记的 cancel 与本次同步
// 一致时才删除,防止慢收尾的旧同步把随后启动的新同步标记误删掉。
func (s *StrmService) clearRunning(pathID string, cancel context.CancelFunc) {
s.mu.Lock()
if cur, ok := s.running[pathID]; ok {
if cancel == nil || cur == nil || sameCancelFunc(cur, cancel) {
delete(s.running, pathID)
}
}
s.mu.Unlock()
}
// sameCancelFunc 比较两个 cancel 是否为同一实例(每次 WithCancel 返回
// 独立闭包,函数指针即身份)。约定 running 表只登记 StartSync 的 cancel。
func sameCancelFunc(a, b context.CancelFunc) bool {
return reflect.ValueOf(a).Pointer() == reflect.ValueOf(b).Pointer()
}
// ListRemoteDir 列出网盘账号某目录下的条目(供前端目录选择器使用)。
func (s *StrmService) ListRemoteDir(ctx context.Context, accountID, dir string) ([]cloud.FileEntry, error) {
acct, err := s.repo.StrmAccount.FindByID(ctx, accountID)
if err != nil || acct == nil {
return nil, errNotFoundOr(err, "网盘账号不存在")
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
return nil, err
}
if dir == "" {
if acct.Provider == model.StrmProvider115 {
dir = "0"
} else {
dir = "/"
}
}
return provider.List(ctx, dir)
}
// ResolveRemoteDirPath 解析远端目录的完整展示路径。115 的目录以 ID 存储,
// 用户无法辨认,这里按 ID 反查 115 返回的祖先链拼出人类可读路径;路径型
// 网盘(CD2/OpenList)与本地目录的 remote_path 本身就是路径,原样返回。
func (s *StrmService) ResolveRemoteDirPath(ctx context.Context, accountID, dir string) (string, error) {
acct, err := s.repo.StrmAccount.FindByID(ctx, accountID)
if err != nil || acct == nil {
return "", errNotFoundOr(err, "网盘账号不存在")
}
dir = strings.TrimSpace(dir)
if dir == "" {
return "", nil
}
if acct.Provider != model.StrmProvider115 {
return dir, nil
}
if dir == "0" {
return "", nil
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
return "", err
}
oc, ok := provider.(interface{ OpenClient() *cloud115.OpenClient })
if !ok {
return "", fmt.Errorf("115: 客户端初始化失败")
}
detail, err := oc.OpenClient().GetFsDetailByCid(ctx, dir)
if err != nil {
return "", fmt.Errorf("115: 解析目录路径失败:%w", err)
}
return cloud115FullPath(detail), nil
}
// cloud115FullPath 把 115 目录详情的祖先链拼成以 / 开头的完整路径。
func cloud115FullPath(detail *cloud115.RemoteFileDetail) string {
if detail == nil {
return ""
}
segments := make([]string, 0, len(detail.Paths)+1)
hasSelf := false
for _, p := range detail.Paths {
if p.FileId == "0" || p.FileId == "" {
continue
}
if p.FileId == detail.FileId {
hasSelf = true
}
if name := strings.TrimSpace(p.Name); name != "" {
segments = append(segments, name)
}
}
if !hasSelf && strings.TrimSpace(detail.FileName) != "" {
segments = append(segments, strings.TrimSpace(detail.FileName))
}
if len(segments) == 0 {
return ""
}
return "/" + strings.Join(segments, "/")
}
// runSync 执行同步主体;结束时更新记录与目录状态。
func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *model.StrmSyncRecord, cancel context.CancelFunc) {
defer s.clearRunning(p.ID, cancel)
defer cancel()
cfg, err := s.strmEffectiveConfig(ctx, p)
if err != nil {
s.finishSync(p, rec, model.StrmSyncRecordFailed, err.Error())
return
}
st := &strmSyncState{
s: s,
ctx: ctx,
p: p,
cfg: cfg,
rec: rec,
syncType: rec.SyncType,
seenVideo: map[string]bool{},
seenDir: map[string]bool{"": true},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
remoteVideos: map[string][]remoteVideoCandidate{},
}
if p.Provider != model.StrmProviderLocal {
acct, err := s.repo.StrmAccount.FindByID(ctx, p.AccountID)
if err != nil || acct == nil {
s.finishSync(p, rec, model.StrmSyncRecordFailed, "网盘账号不存在或已删除")
return
}
if !acct.Enabled {
s.finishSync(p, rec, model.StrmSyncRecordFailed, "网盘账号已禁用")
return
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
s.finishSync(p, rec, model.StrmSyncRecordFailed, err.Error())
return
}
st.acct = acct
st.provider = provider
}
if err := st.run(); err != nil {
if errors.Is(err, context.Canceled) {
s.finishSync(p, rec, model.StrmSyncRecordCanceled, "已取消")
} else {
s.finishSync(p, rec, model.StrmSyncRecordFailed, err.Error())
}
return
}
s.finishSync(p, rec, model.StrmSyncRecordDone, "")
}
// finishSync 落库同步结果。
func (s *StrmService) finishSync(p *model.StrmSyncPath, rec *model.StrmSyncRecord, status, message string) {
now := time.Now()
rec.Status = status
rec.Message = message
rec.FinishedAt = &now
if err := s.repo.StrmSyncRecord.Update(context.Background(), rec); err != nil {
s.log.Warn("update strm sync record failed", zap.Error(err))
}
p.LastSyncStatus = status
p.LastSyncMessage = message
if status != model.StrmSyncRecordFailed && message == "" {
syncTypeLabel := "增量"
if rec.SyncType == model.StrmSyncTypeFull {
syncTypeLabel = "全量"
}
p.LastSyncMessage = fmt.Sprintf("[%s] 完成:新增/更新 %d 个 strm,跳过 %d 个,下载 %d 个元数据,上传 %d 个元数据,清理 %d 个文件",
syncTypeLabel, rec.NewStrm, rec.Skipped, rec.NewMeta, rec.Uploaded, rec.Pruned)
}
if err := s.repo.StrmSyncPath.Update(context.Background(), p); err != nil {
s.log.Warn("update strm sync path failed", zap.Error(err))
}
s.log.Info("strm sync finished",
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))
}
func (st *strmSyncState) run() error {
if err := ensureLocalDir(st.p.LocalPath); err != nil {
return fmt.Errorf("创建输出目录失败:%w", err)
}
if st.cfg.DownloadMeta {
if active, err := st.s.repo.StrmDownload.GetActiveLocalPathMap(st.ctx, st.p.ID); err == nil {
st.activeDownloadPaths = active
} else {
st.activeDownloadPaths = map[string]bool{}
}
}
if st.cfg.UploadMeta {
if active, err := st.s.repo.StrmUpload.GetActiveLocalPathMap(st.ctx, st.p.ID); err == nil {
st.activeUploadPaths = active
} else {
st.activeUploadPaths = map[string]bool{}
}
since := time.Now().Add(-strmRecentUploadSkipWindow)
if recent, err := st.s.repo.StrmUpload.GetRecentDoneUploadSizeMap(st.ctx, st.p.ID, since); err == nil {
st.recentDoneUploadSizes = recent
} else {
st.recentDoneUploadSizes = map[string]int64{}
st.s.log.Warn("加载近期已完成上传任务失败,跳过 done 窗口去重",
zap.String("path_id", st.p.ID), zap.Error(err))
}
}
if st.provider != nil {
if open115, ok := st.provider.(cloud.OpenAPI115Provider); ok && st.p.Provider == model.StrmProvider115 {
err := st.walk115Flat(open115.OpenClient())
if err != nil {
if errors.Is(err, errFallbackToWalkRemote) {
st.s.log.Warn("115: 扁平列表文件数超限,自动降级为传统并发递归同步",
zap.String("path_id", st.p.ID))
if errWalk := st.walkRemote(); errWalk != nil {
return errWalk
}
} else {
return err
}
}
} else {
if err := st.walkRemote(); err != nil {
return err
}
}
} else {
if err := st.walkLocalSource(); err != nil {
return err
}
}
st.flushPreferredVideos()
st.flushPendingDownloads()
st.flushProgress()
if st.cfg.UploadMeta && st.provider != nil {
// 115 上传需要父目录 cid,先用 dirCache 构建「路径 → cid」反向索引
if st.p.Provider == model.StrmProvider115 {
reversed := map[string]string{}
st.dirCache.Range(func(key, value any) bool {
path, ok := value.(string)
if ok && path != "" {
if id, ok2 := key.(string); ok2 {
reversed[path] = id
}
}
return true
})
st.dirPathToID = reversed
}
if err := st.scanLocalMetaForUpload(); err != nil {
return err
}
st.flushPendingUploads()
}
if err := st.pruneLocal(); err != nil {
return err
}
st.flushProgress()
_ = st.ctx.Err()
return nil
}
// strmScanWorkers 远端目录树并发遍历的 worker 数。115 开放平台有全局
// 令牌桶限流(QPS/QPM/QPH),并发请求自动排队,不会触发风控;并发让
// 多个目录列表请求的网络往返彼此重叠,大幅缩短大目录树同步耗时。
const strmScanWorkers = 8
// strmProcessWorkers 115 平铺拉取后本地文件分类处理(生成 strm/元数据入队)
// 的 worker 数。本地磁盘 I/O 是大库同步的尾部瓶颈,输出目录在网络挂载上尤甚;
// processRemoteFile 的共享状态均由 st.mu 保护,可安全并发。
const strmProcessWorkers = 8
// walkRemote 并发广度优先遍历网盘目录树。
// 多个 worker 并行执行 List(受全局 115 令牌桶限流约束),子目录动态
// 入队;任一目录失败则取消其余 worker 并返回错误(与旧串行版语义一致)。
func (st *strmSyncState) walkRemote() error {
defer st.flushPendingDownloads()
root := strings.TrimSpace(st.p.RemotePath)
if root == "" {
root = "/"
}
type dirTask struct {
id string
rel string
}
ctx, cancel := context.WithCancel(st.ctx)
defer cancel()
// 工作队列用「互斥锁 + 条件变量 + 动态 slice」实现,而不是有界
// channel:有界缓冲下所有 worker 可能同时阻塞在发送上、无人接收,
// closer 又在等 pending 归零,形成永久死锁。push 永不阻塞即可保证
// 有进度就一定有推进。
// pending 计数 = 尚未处理完的任务数(在 work 里或正在被 List)。
var (
walkMu sync.Mutex
walkCond = sync.NewCond(&walkMu)
work []dirTask
pending int
)
push := func(t dirTask) {
walkMu.Lock()
work = append(work, t)
pending++
walkCond.Signal()
walkMu.Unlock()
}
// ctx 取消时唤醒所有等待中的 worker 让其退出。
go func() {
<-ctx.Done()
walkMu.Lock()
walkCond.Broadcast()
walkMu.Unlock()
}()
// 根目录入队
push(dirTask{id: root, rel: ""})
var (
wg sync.WaitGroup
errMu sync.Mutex
firstErr error
)
for i := 0; i < strmScanWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// worker 解析远端响应 panic 时取消整个同步,让其余 worker
// 正常收尾;正常退出不取消。
if err := helper.Recover(st.s.log, "strm.sync.walkRemote", func() error {
for {
walkMu.Lock()
for len(work) == 0 {
if ctx.Err() != nil || pending == 0 {
walkMu.Unlock()
return nil
}
walkCond.Wait()
}
task := work[0]
work = work[1:]
walkMu.Unlock()
if ctx.Err() != nil {
walkMu.Lock()
pending--
walkCond.Broadcast()
walkMu.Unlock()
return nil
}
entries, err := st.provider.List(ctx, task.id)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
}
errMu.Unlock()
walkMu.Lock()
pending--
walkCond.Broadcast()
walkMu.Unlock()
cancel()
return nil
}
for _, entry := range entries {
cleanName := cleanEntryName(entry.Name, entry.IsDir)
rel := cleanName
if task.rel != "" {
rel = task.rel + "/" + cleanName
}
if entry.IsDir {
st.markSeenDir(rel)
st.dirCache.Store(entry.ID, rel)
st.deferDirCacheSave(entry.ID, rel)
push(dirTask{id: entry.ID, rel: rel})
} else {
st.processRemoteFile(entry, rel)
}
}
walkMu.Lock()
pending--
if pending == 0 {
walkCond.Broadcast()
}
walkMu.Unlock()
}
}); err != nil {
cancel()
}
}()
}
wg.Wait()
st.flushDirCacheSave()
if firstErr != nil {
return firstErr
}
return ctx.Err()
}
// processRemoteFile 分类处理远端文件:视频生成 STRM,元数据入下载队列。
func (st *strmSyncState) processRemoteFile(entry cloud.FileEntry, rel string) {
st.markSeenDir(filepath.ToSlash(filepath.Dir(rel)))
fileName := entry.Name
if st.isExcluded(fileName) {
return
}
ext := strings.ToLower(filepath.Ext(fileName))
switch {
case st.isVideoExt(ext, entry.Size):
st.handleVideo(entry, rel, ext)
case st.isMetaExt(ext):
st.recordRemoteMeta(entry, rel)
if st.cfg.DownloadMeta {
st.handleMeta(entry, rel, ext)
} else {
st.touchProgress()
}
default:
st.touchProgress()
}
}
func (st *strmSyncState) isExcluded(fileName string) bool {
lower := strings.ToLower(fileName)
for _, keyword := range st.cfg.ExcludeName {
if keyword != "" && strings.Contains(lower, keyword) {
return true
}
}
return false
}
func (st *strmSyncState) isVideoExt(ext string, size int64) bool {
if st.cfg.MinSize > 0 && size < st.cfg.MinSize {
return false
}
for _, e := range st.cfg.VideoExt {
if "."+e == ext {
return true
}
}
return false
}
func (st *strmSyncState) isMetaExt(ext string) bool {
// Legacy MeBox installs may have persisted strm.meta_ext without img.
// .img is emitted by the artwork writer as a fallback image container, so
// keep accepting existing sidecars without requiring a settings migration.
if strings.EqualFold(ext, ".img") {
return true
}
for _, e := range st.cfg.MetaExt {
if "."+e == ext {
return true
}
}
return false
}
// cleanDirRel 对 115 扁平化拉取的目录相对路径逐段套用目录级文件名清洗,
// 确保与 walkRemote / joinLocalRel(sanitizeRelativePath)使用同一套清洗规则。
// 若不清洗,目录名中的冒号等非法字符会直达 rel,而 seenVideo/remoteMeta 的 key
// 与磁盘实际路径不一致,导致 pruneLocal 误删已下载的 strm、上传误传或重复下载。
// 空 rel(根目录)原样返回。
func cleanDirRel(rel string) string {
if rel == "" {
return ""
}
parts := strings.Split(rel, "/")
out := make([]string, 0, len(parts))
for _, part := range parts {
if part == "" {
continue
}
clean := cleanEntryName(part, true)
if clean != "" && clean != "." && clean != ".." {
out = append(out, clean)
}
}
return strings.Join(out, "/")
}
// deferDirCacheSave 暂存一条目录缓存写入,由 flushDirCacheSave 统一批量落库。
// 首次全量同步可能有上万个目录,逐目录单条 upsert 会造成明显的 SQLite 写锁
// 竞争;内存 dirCache(sync.Map)始终即时可用,落库仅服务于下次增量预加载。
func (st *strmSyncState) deferDirCacheSave(dirID, relPath string) {
st.mu.Lock()
if st.dirCacheDirty == nil {
st.dirCacheDirty = map[string]string{}
}
st.dirCacheDirty[dirID] = relPath
st.mu.Unlock()
}
// flushDirCacheSave 把暂存的目录缓存一次性批量落库;失败仅记日志(缓存缺失
// 只影响下次增量的目录解析提速,正确性由"重新向 115 获取"兜底)。
func (st *strmSyncState) flushDirCacheSave() {
st.mu.Lock()
dirty := st.dirCacheDirty
st.dirCacheDirty = nil
st.mu.Unlock()
if len(dirty) == 0 {
return
}
if err := st.s.repo.StrmDirCache.SetBatch(st.ctx, st.p.ID, dirty); err != nil {
st.s.log.Warn("batch save strm dir cache failed",
zap.Error(err), zap.Int("count", len(dirty)), zap.String("path_id", st.p.ID))
}
}
// walk115Flat 使用 115 开放平台扁平化分页批量拉取机制与目录拓扑缓存(参考 QMediaSync)。
// 极大地降低 API 请求次数并支持毫秒级/秒级增量同步。
func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
defer st.flushPendingDownloads()
ctx, cancel := context.WithCancel(st.ctx)
defer cancel()
rootCID := strings.TrimSpace(st.p.RemotePath)
if rootCID == "" {
rootCID = "0"
}
// 1. 目录拓扑缓存处理
st.dirCache.Store(rootCID, "")
if st.syncType == model.StrmSyncTypeFull {
// 全量同步:清空本路径的历史目录缓存
if err := st.s.repo.StrmDirCache.DeleteBySyncPathID(ctx, st.p.ID); err != nil {
st.s.log.Warn("delete strm dir cache failed", zap.Error(err))
}
} else {
// 增量同步:预加载历史目录缓存(过滤历史一对多塌陷冲突的脏数据以自愈刷新)
cached, err := st.s.repo.StrmDirCache.ListBySyncPathID(ctx, st.p.ID)
if err == nil {
pathCounts := make(map[string]int, len(cached))
for _, item := range cached {
pathCounts[item.Path]++
}
for _, item := range cached {
// 若同一个 path 对应了多个不同 dir_id,说明包含历史层级塌陷的脏数据,不预加载,让后续步骤重新向 115 获取精确路径
if pathCounts[item.Path] > 1 {
continue
}
st.dirCache.Store(item.DirID, cleanDirRel(item.Path))
}
}
}
// 2. 自适应分治拉取文件列表(单目录超 9500 时自动对子目录并发分治扁平化)
allFiles, err := st.fetch115FilesAdaptive(ctx, open115, rootCID)
if err != nil {
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
// 4. 收集所有未在缓存中的父目录 ID (file.Pid)
missingPids := make(map[string]struct{})
for _, f := range allFiles {
pid := f.Pid
if pid == "" || pid == rootCID {
continue
}
if _, ok := st.dirCache.Load(pid); !ok {
missingPids[pid] = struct{}{}
}
}
// 并发补全未知目录详情与祖先链
if len(missingPids) > 0 {
pidList := make([]string, 0, len(missingPids))
for pid := range missingPids {
pidList = append(pidList, pid)
}
pidCh := make(chan string, len(pidList))
for _, pid := range pidList {
pidCh <- pid
}
close(pidCh)
var (
pwg sync.WaitGroup
dirWorkers = 8
doneDirs atomic.Int64
totalDirs = len(pidList)
errMu sync.Mutex
firstErr error
)
if len(pidList) < dirWorkers {
dirWorkers = len(pidList)
}
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (0/%d)...", totalDirs))
for i := 0; i < dirWorkers; i++ {
pwg.Add(1)
go func() {
defer pwg.Done()
// 解析目录详情 panic 时中止整个同步(避免带着损坏的相对路径
// 继续执行);正常退出不取消。
if err := helper.Recover(st.s.log, "strm.sync.walk115.dirTree", func() error {
for pid := range pidCh {
if ctx.Err() != nil {
return nil
}
if _, loaded := st.dirCache.Load(pid); loaded {
if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) {
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
}
continue
}
detail, err := open115.GetFsDetailByCid(ctx, pid)
if err != nil {
// 目录详情解析失败会导致下游文件 rel 无法还原真实父路径,
// seen key 与磁盘路径对不上:增量 prune 会误删本地文件、上传会
// 误传本地未变文件、下载会重复下载。这里不是降级容错,而是
// 直接中止整个同步——宁可本次同步失败,也不带着损坏的相对路径
// 继续执行造成大规模误删/误传/重下(参考用户反馈"云盘没动却重下重传")。
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("115: 解析目录树失败(file_id=%s):%w", pid, err)
}
errMu.Unlock()
st.scanIncomplete.Store(true)
cancel()
return nil
} else if detail != nil {
// 解析相对路径
relPath := cleanDirRel(detail.RelativePath(rootCID))
st.dirCache.Store(pid, relPath)
st.deferDirCacheSave(pid, relPath)
// 顺便解析并缓存 detail.Paths 中包含的中间各层级目录
for _, ancestor := range detail.Paths {
if ancestor.FileId == "0" || ancestor.FileId == rootCID {
continue
}
if _, loaded := st.dirCache.Load(ancestor.FileId); !loaded {
subDetail := &cloud115.RemoteFileDetail{
FileId: ancestor.FileId,
FileName: ancestor.Name,
Paths: nil,
}
for _, p := range detail.Paths {
subDetail.Paths = append(subDetail.Paths, p)
if p.FileId == ancestor.FileId {
break
}
}
ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID))
st.dirCache.Store(ancestor.FileId, ancestorRel)
st.deferDirCacheSave(ancestor.FileId, ancestorRel)
}
}
}
if n := doneDirs.Add(1); n%10 == 0 || n == int64(totalDirs) {
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
}
}
return nil
}); err != nil {
cancel()
}
}()
}
pwg.Wait()
// 目录解析阶段结束即批量落库已解析的缓存:失败路径也保留部分成果,
// 下次同步可少解析一批目录。
st.flushDirCacheSave()
if firstErr != nil {
// 目录树解析失败会导致 rel 塌缩,若继续处理会让大量本地文件
// 被错误判定为"云端不存在"而重复下载/上传,并可能误删本地文件。
// 中止本次同步,避免在损坏的相对路径上执行任何写操作。
return firstErr
}
}
st.updateSyncMessage(fmt.Sprintf("正在生成 STRM 与同步文件 (共 %d 个)...", len(allFiles)))
// 5. 分类处理所有文件。本地磁盘 I/O(Stat/读内容比对/写盘)远慢于列表
// 拉取,串行消化是大库同步的尾部瓶颈(输出目录在网络挂载上尤甚);
// processRemoteFile 的共享状态均由 st.mu 保护(walkRemote 已并发调用),
// 这里用有界 worker 池并行处理。rel 构建依赖 dirCache 且需在父目录缺失
// 时整体中止,保留在生产者侧串行完成。
type strmFileTask struct {
file cloud115.RemoteFile
rel string
}
fileCh := make(chan strmFileTask)
var (
procWg sync.WaitGroup
procErrMu sync.Mutex
procErr error
)
for i := 0; i < strmProcessWorkers; i++ {
procWg.Add(1)
go func() {
defer procWg.Done()
if err := helper.Recover(st.s.log, "strm.sync.walk115.process", func() error {
for t := range fileCh {
// 中止(ctx 取消)后排空队列即可,不再产生任何写操作
if ctx.Err() != nil {
continue
}
entry := cloud.FileEntry{
ID: t.file.FileId,
Name: t.file.FileName,
IsDir: false,
Size: t.file.FileSize,
MTime: t.file.Utime,
PickCode: t.file.PickCode,
Sha1: t.file.Sha1,
}
st.processRemoteFile(entry, t.rel)
}
return nil
}); err != nil {
procErrMu.Lock()
if procErr == nil {
procErr = err
}
procErrMu.Unlock()
cancel()
}
}()
}
feed:
for _, f := range allFiles {
if ctx.Err() != nil {
break
}
cleanName := cleanEntryName(f.FileName, false)
var rel string
if f.Pid == "" || f.Pid == rootCID {
rel = cleanName
} else {
if parentVal, ok := st.dirCache.Load(f.Pid); ok && parentVal.(string) != "" {
rel = cleanDirRel(parentVal.(string)) + "/" + cleanName
} else {
// 父目录不在目录缓存,无法还原真实相对路径。若继续用塌缩后的
// 根路径处理,该文件会被错误判定,导致重复下载/上传或误删本地文件。
// 目录树不完整时宁可中止本次同步,也不带着损坏的 rel 继续执行。
procErrMu.Lock()
if procErr == nil {
procErr = fmt.Errorf("115: 文件 %s 的父目录未解析成功,目录树不完整,中止同步以防误删/误传", cleanName)
}
procErrMu.Unlock()
cancel()
break
}
}
select {
case fileCh <- strmFileTask{file: f, rel: rel}:
case <-ctx.Done():
break feed
}
}
close(fileCh)
procWg.Wait()
if procErr != nil {
return procErr
}
return ctx.Err()
}
const (
flat115PageSize = 1150
flat115Threshold = 9500
max115AdaptiveDepth = 10
)
type adaptive115Task struct {
cid string
rel string
depth int
}
// fetch115FlatSubtree 扁平拉取单个文件数在安全阈值内的子树全部文件。
func fetch115FlatSubtree(ctx context.Context, open115 *cloud115.OpenClient, cid string, firstBatch []cloud115.RemoteFile, totalCount int64, log *zap.Logger) ([]cloud115.RemoteFile, error) {
allFiles := make([]cloud115.RemoteFile, 0, totalCount)
allFiles = append(allFiles, firstBatch...)
if totalCount <= int64(len(firstBatch)) {
return allFiles, nil
}
totalPages := int((totalCount + flat115PageSize - 1) / flat115PageSize)
type pageTask struct {
offset int
}
pageTasks := make([]pageTask, 0, totalPages-1)
for page := 1; page < totalPages; page++ {
pageTasks = append(pageTasks, pageTask{offset: page * flat115PageSize})
}
var (
filesMu sync.Mutex
wg sync.WaitGroup
taskCh = make(chan pageTask, len(pageTasks))
errMu sync.Mutex
fetchErr error
)
for _, t := range pageTasks {
taskCh <- t
}
close(taskCh)
workers := 8
if len(pageTasks) < workers {
workers = len(pageTasks)
}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := helper.Recover(log, "strm.sync.walk115.page", func() error {
for t := range taskCh {
if ctx.Err() != nil {
return nil
}
files, _, err := open115.GetFsListFlat(ctx, cid, t.offset, flat115PageSize)
if err != nil {
errMu.Lock()
if fetchErr == nil {
fetchErr = err
}
errMu.Unlock()
return nil
}
filesMu.Lock()
allFiles = append(allFiles, files...)
filesMu.Unlock()
}
return nil
}); err != nil {
errMu.Lock()
if fetchErr == nil {
fetchErr = err
}
errMu.Unlock()
}
}()
}
wg.Wait()
if fetchErr != nil {
return nil, fmt.Errorf("115: 分页拉取失败:%w", fetchErr)
}
return allFiles, nil
}
// list115DirDirect 列出指定目录下的直接子项(单层 cur=1&show_dir=1)。
func list115DirDirect(ctx context.Context, open115 *cloud115.OpenClient, cid string) ([]cloud115.RemoteFile, error) {
var out []cloud115.RemoteFile
for offset := 0; ; offset += flat115PageSize {
files, _, err := open115.GetFsList(ctx, cid, offset, flat115PageSize)
if err != nil {
return nil, err
}
out = append(out, files...)
if len(files) < flat115PageSize {
break
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
return out, nil
}
// fetch115FilesAdaptive 采用自适应分治策略抓取 115 目录树下的全部文件:
// 115 开放平台扁平搜索对 offset+limit 有 10000 的最大深度限制。
// - 若子树文件总数 < 9500,直接使用全速扁平分页批量拉取;
// - 若子树文件总数 >= 9500(大库或超大分类目录),自动分治:仅单层列出该目录的直属子项(cur=1),
// 直属纯文件直接收集,直属子目录则派发为独立的子树任务继续递归探测与拉取;
// - 若超大单目录下无子目录或层级过深(>10层),安全回退到 errFallbackToWalkRemote。
func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *cloud115.OpenClient, rootCID string) ([]cloud115.RemoteFile, error) {
var (
allFiles []cloud115.RemoteFile
filesMu sync.Mutex
walkMu sync.Mutex
walkCond = sync.NewCond(&walkMu)
work []adaptive115Task
pending int
errMu sync.Mutex
firstErr error
)
push := func(t adaptive115Task) {
walkMu.Lock()
work = append(work, t)
pending++
walkCond.Signal()
walkMu.Unlock()
}
go func() {
<-ctx.Done()
walkMu.Lock()
walkCond.Broadcast()
walkMu.Unlock()
}()
push(adaptive115Task{cid: rootCID, rel: "", depth: 0})
workers := 8
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := helper.Recover(st.s.log, "strm.sync.walk115.adaptive", func() error {
for {
walkMu.Lock()
for len(work) == 0 {
if ctx.Err() != nil || pending == 0 {
walkMu.Unlock()
return nil
}
walkCond.Wait()
}
task := work[0]
work = work[1:]
walkMu.Unlock()
if ctx.Err() != nil {
walkMu.Lock()
pending--
walkCond.Broadcast()
walkMu.Unlock()
return nil
}
firstBatch, totalCount, err := open115.GetFsListFlat(ctx, task.cid, 0, flat115PageSize)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("115: 获取文件列表失败(cid=%s):%w", task.cid, err)
}
errMu.Unlock()
walkMu.Lock()
pending--
walkCond.Broadcast()
walkMu.Unlock()
return nil
}
if totalCount < flat115Threshold {
// 安全深度内:直接扁平拉取该子树全部文件
files, err := fetch115FlatSubtree(ctx, open115, task.cid, firstBatch, totalCount, st.s.log)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = err
}
errMu.Unlock()
walkMu.Lock()
pending--
walkCond.Broadcast()
walkMu.Unlock()
return nil
}
filesMu.Lock()
allFiles = append(allFiles, files...)
currentCount := len(allFiles)
filesMu.Unlock()
st.updateSyncMessage(fmt.Sprintf("正在拉取远端文件列表 (已获取 %d 个文件)...", currentCount))
} else {
// 子树过大(>=9500):分治展开该目录直接子项
if task.depth >= max115AdaptiveDepth {
// 深度超限兜底:单目录嵌套超 10 层仍超 9500,回退为传统递归
errMu.Lock()
if firstErr == nil {
firstErr = errFallbackToWalkRemote
}
errMu.Unlock()
walkMu.Lock()
pending--
walkCond.Broadcast()
walkMu.Unlock()
return nil
}
st.s.log.Info("115: 目录文件数超限,自动分治展开子目录并发扁平拉取",
zap.String("cid", task.cid),
zap.String("rel", task.rel),
zap.Int64("total_count", totalCount),
zap.Int("depth", task.depth))
directEntries, err := list115DirDirect(ctx, open115, task.cid)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("115: 列出单层目录失败(cid=%s):%w", task.cid, err)
}
errMu.Unlock()
walkMu.Lock()
pending--
walkCond.Broadcast()
walkMu.Unlock()
return nil
}
for _, f := range directEntries {
if f.Category == cloud115.TypeDir {
cleanName := cleanEntryName(f.FileName, true)
subRel := cleanName
if task.rel != "" {
subRel = task.rel + "/" + cleanName
}
st.dirCache.Store(f.FileId, subRel)
st.deferDirCacheSave(f.FileId, subRel)
push(adaptive115Task{cid: f.FileId, rel: subRel, depth: task.depth + 1})
} else {
filesMu.Lock()
allFiles = append(allFiles, f)
filesMu.Unlock()
}
}
}
walkMu.Lock()
pending--
if pending == 0 {
walkCond.Broadcast()
}
walkMu.Unlock()
}
}); err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = err
}
errMu.Unlock()
}
}()
}
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
return allFiles, nil
}
// handleVideo 收集/生成 .strm 文件。
// KeepExt=true:每个视频写 name.ext.strm,保留全部版本。
// KeepExt=false(默认):同名候选先入队,walk 结束后按体积→mtime→扩展名优先级择优写一条 name.strm。
func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
relSansExt := rel[:len(rel)-len(ext)]
if st.cfg.KeepExt {
st.writeVideoStrm(entry, rel, ext, rel+".strm", true)
return
}
st.mu.Lock()
if st.remoteVideos == nil {
st.remoteVideos = map[string][]remoteVideoCandidate{}
}
st.remoteVideos[relSansExt] = append(st.remoteVideos[relSansExt], remoteVideoCandidate{
entry: entry,
rel: rel,
ext: ext,
})
st.mu.Unlock()
st.touchProgress()
}
// flushPreferredVideos 在 prefer 模式下对同名多版本择优写盘,并记录冲突日志。
func (st *strmSyncState) flushPreferredVideos() {
if st.cfg.KeepExt {
return
}
st.mu.Lock()
pending := st.remoteVideos
st.remoteVideos = map[string][]remoteVideoCandidate{}
st.mu.Unlock()
for base, cands := range pending {
if len(cands) == 0 {
continue
}
winnerIdx := 0
for i := 1; i < len(cands); i++ {
if betterStrmVideoCandidate(cands[i], cands[winnerIdx], st.cfg.VideoExt) {
winnerIdx = i
}
}
winner := cands[winnerIdx]
if len(cands) > 1 {
skippedRels := make([]string, 0, len(cands)-1)
skippedRefs := make([]string, 0, len(cands)-1)
for i, c := range cands {
if i == winnerIdx {
continue
}
skippedRels = append(skippedRels, c.rel)
ref := strings.TrimSpace(c.entry.PickCode)
if ref == "" {
ref = strings.TrimSpace(c.entry.ID)
}
if ref != "" {
skippedRefs = append(skippedRefs, ref)
}
}
st.s.log.Info("strm multi-version prefer",
zap.String("base", base),
zap.String("winner", winner.rel),
zap.String("winner_ref", firstNonEmpty(winner.entry.PickCode, winner.entry.ID)),
zap.Int64("winner_size", winner.entry.Size),
zap.Strings("skipped", skippedRels),
zap.Strings("skipped_refs", skippedRefs),
)
st.mu.Lock()
st.rec.Skipped += int64(len(cands) - 1)
st.mu.Unlock()
}
// 候选已在 handleVideo 计入 Total,此处不再重复 touchProgress
st.writeVideoStrm(winner.entry, winner.rel, winner.ext, base+".strm", false)
}
}
// betterStrmVideoCandidate 决定 prefer 模式下的赢家:体积更大 → mtime 更新 → video_ext 列表更靠前。
func betterStrmVideoCandidate(candidate, current remoteVideoCandidate, videoExtOrder []string) bool {
if candidate.entry.Size != current.entry.Size {
return candidate.entry.Size > current.entry.Size
}
if candidate.entry.MTime != current.entry.MTime {
return candidate.entry.MTime > current.entry.MTime
}
return strmExtPriority(candidate.ext, videoExtOrder) < strmExtPriority(current.ext, videoExtOrder)
}
func strmExtPriority(ext string, order []string) int {
ext = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
for i, item := range order {
if strings.TrimPrefix(strings.ToLower(strings.TrimSpace(item)), ".") == ext {
return i
}
}
return len(order) + 1
}
// writeVideoStrm 把单个视频写成目标 .strm(targetRel 相对本地输出根)。
// countProgress 控制是否计入 Total(prefer 模式下候选已在收集阶段计数)。
func (st *strmSyncState) writeVideoStrm(entry cloud.FileEntry, rel, ext, targetRel string, countProgress bool) {
seenKey := "v:" + strings.TrimSuffix(targetRel, ".strm")
st.mu.Lock()
if st.seenVideo[seenKey] {
st.mu.Unlock()
if countProgress {
st.touchProgress()
}
return
}
st.seenVideo[seenKey] = true
st.mu.Unlock()
target, err := joinLocalRel(st.p.LocalPath, targetRel)
if err != nil {
st.s.log.Warn("strm target path out of root", zap.String("rel", targetRel), zap.Error(err))
if countProgress {
st.touchProgress()
}
return
}
st.mu.Lock()
if st.seenVideoTarget == nil {
st.seenVideoTarget = map[string]cloud.FileEntry{}
}
if _, exists := st.seenVideoTarget[target]; exists {
st.mu.Unlock()
if countProgress {
st.touchProgress()
}
return
}
st.seenVideoTarget[target] = entry
st.mu.Unlock()
done := func() {
if countProgress {
st.touchProgress()
}
}
// 增量同步模式快速检查:本地 strm 文件存在、非空且修改时间与远端 mtime 一致,直接跳过无需读磁盘
if st.syncType == model.StrmSyncTypeIncremental && entry.MTime > 0 {
if info, err := os.Stat(target); err == nil && info.Size() > 0 && info.ModTime().Unix() == entry.MTime {
st.mu.Lock()
st.rec.Skipped++
st.mu.Unlock()
done()
return
}
}
content, err := st.strmContent(entry, rel, ext)
if err != nil {
st.s.log.Warn("build strm content failed", zap.String("file", rel), zap.Error(err))
done()
return
}
existing := ""
if data, err := os.ReadFile(target); err == nil {
existing = string(data)
}
if existing == content {
if entry.MTime > 0 {
mTime := time.Unix(entry.MTime, 0)
_ = os.Chtimes(target, mTime, mTime)
}
st.mu.Lock()
st.rec.Skipped++
st.mu.Unlock()
done()
return
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
st.s.log.Warn("mkdir strm dir failed", zap.String("dir", filepath.Dir(target)), zap.Error(err))
done()
return
}
tmp := target + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
st.s.log.Warn("write strm tmp failed", zap.String("file", target), zap.Error(err))
done()
return
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Remove(tmp)
st.s.log.Warn("rename strm failed", zap.String("file", target), zap.Error(err))
done()
return
}
if entry.MTime > 0 {
mTime := time.Unix(entry.MTime, 0)
_ = os.Chtimes(target, mTime, mTime)
}
st.mu.Lock()
st.rec.NewStrm++
st.mu.Unlock()
done()
}
// strmContent 构建 strm 文件内容(一行指向本服务播放端点的 URL)。
func (st *strmSyncState) strmContent(entry cloud.FileEntry, rel, ext string) (string, error) {
q := url.Values{}
switch st.p.Provider {
case model.StrmProvider115:
q.Set("acct", st.p.AccountID)
q.Set("pickcode", entry.PickCode)
case model.StrmProviderCloudDrive, model.StrmProviderOpenList:
q.Set("acct", st.p.AccountID)
q.Set("ref", entry.ID)
case model.StrmProviderLocal:
src, err := joinLocalRel(st.p.RemotePath, rel)
if err != nil {
return "", err
}
q.Set("path", src)
default:
return "", fmt.Errorf("不支持的提供方:%s", st.p.Provider)
}
// 本地源用 path 参数携带真实文件路径;网盘源用 path 参数展示目录结构
if st.p.Provider != model.StrmProviderLocal {
if pathParam := st.strmPathParam(rel); pathParam != "" {
q.Set("path", pathParam)
}
}
suffix := ""
if encoded := q.Encode(); encoded != "" {
suffix = "?" + encoded
}
return st.cfg.BaseURL + "/api/strm/play/" + st.p.Provider + "/video" + ext + suffix, nil
}
// strmPathParam 按 add_path 模式生成 path 查询参数(1=完整相对路径 2=仅文件名 3=不带)。
func (st *strmSyncState) strmPathParam(rel string) string {
switch st.cfg.AddPath {
case 1:
return rel
case 2:
_, name := filepath.Split(rel)
return name
default:
return ""
}
}
// usableSha1 归一化远端内容哈希:115 对目录/未完成文件可能返回空串或占位符 "-",均视为不可用。
func usableSha1(sha string) string {
sha = strings.TrimSpace(sha)
if sha == "-" {
return ""
}
return sha
}
// localSha1Matches 计算本地文件 SHA1 并与远端哈希做大小写不敏感比对
// (115 列表返回大写 hex,本地计算为小写)。读取/哈希失败按"视为同一文件"
// 处理,避免瞬时读文件错误触发大规模重复上传/下载。
func (st *strmSyncState) localSha1Matches(path, remoteSha1 string) bool {
local, err := cloud115.FileSHA1(path)
if err != nil {
st.s.log.Warn("strm 计算本地元数据 SHA1 失败,按同一文件处理",
zap.String("path", path), zap.Error(err))
return true
}
return strings.EqualFold(local, remoteSha1)
}
// recordRemoteMeta 记录远端存在的元数据索引、文件大小、文件引用及内容 SHA1(多副本聚合追加)。
// 对同一文件 ID 严格去重,避免当 download_meta 与 upload_meta 同时开启时因重复记录引发误判与自杀式删除。
func (st *strmSyncState) recordRemoteMeta(entry cloud.FileEntry, rel string) {
st.mu.Lock()
defer st.mu.Unlock()
if st.remoteMeta == nil {
st.remoteMeta = map[string][]remoteMetaItem{}
}
key := "m:" + rel
st.seenMeta[key] = true
if entry.ID != "" {
for _, existing := range st.remoteMeta[key] {
if existing.ID == entry.ID {
return
}
}
}
st.remoteMeta[key] = append(st.remoteMeta[key], remoteMetaItem{
ID: entry.ID,
Size: entry.Size,
Sha1: usableSha1(entry.Sha1),
MTime: entry.MTime,
})
}
func (st *strmSyncState) flushPendingDownloads() {
st.mu.Lock()
if len(st.pendingDownloads) == 0 {
st.mu.Unlock()
return
}
batch := st.pendingDownloads
st.pendingDownloads = nil
st.mu.Unlock()
if err := st.s.repo.StrmDownload.CreateInBatches(st.ctx, batch, 100); err != nil {
st.s.log.Warn("batch enqueue strm download tasks failed", zap.Error(err))
}
}
func (st *strmSyncState) flushPendingUploads() {
st.mu.Lock()
if len(st.pendingUploads) == 0 {
st.mu.Unlock()
return
}
batch := st.pendingUploads
st.pendingUploads = nil
st.mu.Unlock()
if err := st.s.repo.StrmUpload.CreateInBatches(st.ctx, batch, 100); err != nil {
st.s.log.Warn("batch enqueue strm upload tasks failed", zap.Error(err))
}
}
// handleMeta 元数据入下载队列。本地已存在时:开启上传元数据则一律跳过(以本地
// 为准);否则大小与 SHA1(115 提供)均一致视为同一文件跳过,内容不同则下载覆盖。
func (st *strmSyncState) handleMeta(entry cloud.FileEntry, rel, ext string) {
st.recordRemoteMeta(entry, rel)
target, err := joinLocalRel(st.p.LocalPath, rel)
if err != nil {
return
}
st.mu.Lock()
if st.seenMetaTarget == nil {
st.seenMetaTarget = map[string]cloud.FileEntry{}
}
if _, exists := st.seenMetaTarget[target]; exists {
// 该本地目标路径在当前批次中已被处理(存在同名/重名冲突),直接忽略重复项,避免多份不同大小的文件在本地交替覆盖导致增量死循环
st.mu.Unlock()
st.touchProgress()
return
}
st.seenMetaTarget[target] = entry
st.mu.Unlock()
if info, err := os.Stat(target); err == nil {
// 开启上传元数据时以本地为准:本地已存在的元数据不再用远端版本覆盖,
// 与网盘版本的差异交给上传队列把本地文件推回网盘,避免下载/上传
// 两个队列互相覆盖形成回环。
if st.cfg.UploadMeta {
st.touchProgress()
return
}
// 同名同大小:115 提供远端 SHA1 时做内容级比对,网盘更新了同大小
// 元数据也能被下载到本地;无哈希(其他网盘/列表未返回)或哈希一致
// 视为同一文件跳过。
remoteSha := usableSha1(entry.Sha1)
if info.Size() == entry.Size && (remoteSha == "" || st.localSha1Matches(target, remoteSha)) {
st.touchProgress()
return
}
}
st.mu.Lock()
if st.activeDownloadPaths == nil {
if active, err := st.s.repo.StrmDownload.GetActiveLocalPathMap(st.ctx, st.p.ID); err == nil {
st.activeDownloadPaths = active
} else {
st.activeDownloadPaths = map[string]bool{}
}
}
if st.activeDownloadPaths[target] {
st.mu.Unlock()
st.touchProgress()
return
}
st.activeDownloadPaths[target] = true
st.mu.Unlock()
task := &model.StrmDownloadTask{
SyncPathID: st.p.ID,
AccountID: st.p.AccountID,
Provider: st.p.Provider,
FileName: entry.Name,
RemoteRef: entry.PickCode,
RemoteDir: st.p.RemotePath,
LocalPath: target,
Size: entry.Size,
Status: model.StrmTaskPending,
}
if task.RemoteRef == "" {
task.RemoteRef = entry.ID
}
// 115 用 pickcode 定位;DAV/OpenList 用路径定位
if st.p.Provider != model.StrmProvider115 {
task.RemoteRef = entry.ID
}
st.mu.Lock()
st.pendingDownloads = append(st.pendingDownloads, task)
shouldFlush := len(st.pendingDownloads) >= 100
st.rec.NewMeta++
st.mu.Unlock()
if shouldFlush {
st.flushPendingDownloads()
}
st.touchProgress()
}
// walkLocalSource 本地源:视频生成 STRM,元数据就地存在。
func (st *strmSyncState) walkLocalSource() error {
srcRoot := filepath.Clean(st.p.RemotePath)
info, err := os.Stat(srcRoot)
if err != nil {
return fmt.Errorf("本地源目录不可访问:%w", err)
}
if !info.IsDir() {
return errors.New("本地源目录不是目录")
}
return filepath.WalkDir(srcRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if path == srcRoot {
return nil
}
select {
case <-st.ctx.Done():
return st.ctx.Err()
default:
}
if d.IsDir() {
rel, relErr := filepath.Rel(srcRoot, path)
if relErr == nil {
st.markSeenDir(filepath.ToSlash(rel))
}
return nil
}
rel, err := filepath.Rel(srcRoot, path)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
if st.isExcluded(rel) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
ext := strings.ToLower(filepath.Ext(rel))
if !st.isVideoExt(ext, info.Size()) {
st.touchProgress()
return nil
}
entry := cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size(), MTime: info.ModTime().Unix()}
st.handleVideo(entry, rel, ext)
return nil
})
}
// scanLocalMetaForUpload 扫描本地元数据,与远端比对后入上传队列。
// 以本地为准:网盘端不存在、同名不同大小、或同名同大小但 SHA1 不同(115 提供
// 远端哈希时做内容级比对)均入队覆盖上传;同名同大小同内容视为同一文件跳过。
// 另:近期已成功上传且大小未变的路径跳过入队,缩短「任务 done 但 115 列表滞后」窗口。
func (st *strmSyncState) scanLocalMetaForUpload() error {
defer st.flushPendingUploads()
if st.activeUploadPaths == nil {
if active, err := st.s.repo.StrmUpload.GetActiveLocalPathMap(st.ctx, st.p.ID); err == nil {
st.activeUploadPaths = active
} else {
st.activeUploadPaths = map[string]bool{}
}
}
if st.recentDoneUploadSizes == nil {
since := time.Now().Add(-strmRecentUploadSkipWindow)
if recent, err := st.s.repo.StrmUpload.GetRecentDoneUploadSizeMap(st.ctx, st.p.ID, since); err == nil {
st.recentDoneUploadSizes = recent
} else {
st.recentDoneUploadSizes = map[string]int64{}
}
}
var pendingDeletes map[string][]string
if st.p.Provider == model.StrmProvider115 {
pendingDeletes = map[string][]string{}
}
localRoot := filepath.Clean(st.p.LocalPath)
err := filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if path == localRoot {
return nil
}
select {
case <-st.ctx.Done():
return st.ctx.Err()
default:
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(localRoot, path)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
ext := strings.ToLower(filepath.Ext(rel))
if !st.isMetaExt(ext) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
st.mu.Lock()
entries, exists := st.remoteMeta["m:"+rel]
st.mu.Unlock()
if exists && len(entries) > 0 {
// 择优比对:只要远端存在任一副本的大小匹配且 SHA1 匹配(或无 SHA1),即判定远端已有最新副本,无需上传
matchedIdx := -1
for i, it := range entries {
if it.Size == info.Size() {
if it.Sha1 == "" || st.localSha1Matches(path, it.Sha1) {
matchedIdx = i
break
}
}
}
if matchedIdx >= 0 {
// 远端已存在完全一致的副本,跳过上传!
// 若远端还存在其他同名脏副本(副本总数 > 1),在 115 下收集待删除 ID,稍后按目录批量删除。
// 严禁将已命中的最新副本 ID (matchedID) 放入待删列表,杜绝误杀唯一有效副本。
if len(entries) > 1 && st.p.Provider == model.StrmProvider115 {
parentCID := st.uploadRemoteTarget(rel)
if parentCID != "" {
matchedID := entries[matchedIdx].ID
for i, it := range entries {
if i != matchedIdx && it.ID != "" && it.ID != matchedID {
pendingDeletes[parentCID] = append(pendingDeletes[parentCID], it.ID)
}
}
}
}
return nil
}
}
// 近期已成功上传且大小未变:115 列表可能尚未反映,避免重复入队
if doneSize, ok := st.recentDoneUploadSizes[path]; ok && doneSize == info.Size() {
return nil
}
remoteTarget := st.uploadRemoteTarget(rel)
if st.p.Provider == model.StrmProvider115 && remoteTarget == "" {
// 115 远端不存在对应父目录(如孤儿子目录),禁止降级到根目录上传以防错位死循环
return nil
}
// 入队上传(以本地为准):网盘端不存在;或所有远端副本均内容不同。
// 115 的上传接口不保证同名覆盖,任务携带远端所有旧副本 ID(RemoteRef,以逗号连接),
// 由上传端一次性批量删除所有旧文件后再上传,彻底根除同名文件堆积。
st.mu.Lock()
if st.activeUploadPaths != nil && st.activeUploadPaths[path] {
st.mu.Unlock()
return nil
}
if st.activeUploadPaths != nil {
st.activeUploadPaths[path] = true
}
st.mu.Unlock()
task := &model.StrmUploadTask{
SyncPathID: st.p.ID,
AccountID: st.p.AccountID,
Provider: st.p.Provider,
FileName: filepath.Base(rel),
LocalPath: path,
RemotePath: remoteTarget,
Size: info.Size(),
Status: model.StrmTaskPending,
}
if exists && len(entries) > 0 && st.p.Provider == model.StrmProvider115 {
var oldIDs []string
for _, it := range entries {
if it.ID != "" {
oldIDs = append(oldIDs, it.ID)
}
}
task.RemoteRef = strings.Join(oldIDs, ",")
}
st.mu.Lock()
st.pendingUploads = append(st.pendingUploads, task)
shouldFlush := len(st.pendingUploads) >= 100
st.rec.Uploaded++
st.mu.Unlock()
if shouldFlush {
st.flushPendingUploads()
}
return nil
})
if err != nil {
return err
}
// 批量清理 115 冗余旧元数据副本(按父目录聚合,一次请求批量删除该目录下全部冗余文件,彻底避免并发触发限流器超时)
if len(pendingDeletes) > 0 {
st.cleanupBatchRedundantFiles(pendingDeletes)
}
return nil
}
// cleanupBatchRedundantFiles 异步按父目录批量清理 115 远端冗余旧元数据副本。
func (st *strmSyncState) cleanupBatchRedundantFiles(deletesByParent map[string][]string) {
if len(deletesByParent) == 0 || st.provider == nil {
return
}
open115, ok := st.provider.(cloud.OpenAPI115Provider)
if !ok {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
totalPruned := 0
for parentCID, fileIDs := range deletesByParent {
if ctx.Err() != nil {
break
}
if len(fileIDs) == 0 {
continue
}
if err := open115.OpenClient().DeleteFiles(ctx, parentCID, fileIDs...); err != nil {
st.s.log.Warn("批量清理 115 冗余旧元数据副本失败",
zap.String("parent_cid", parentCID),
zap.Int("count", len(fileIDs)),
zap.Error(err))
} else {
totalPruned += len(fileIDs)
}
time.Sleep(100 * time.Millisecond)
}
if totalPruned > 0 {
st.s.log.Info("已完成批量清理 115 冗余旧元数据副本", zap.Int("total_pruned", totalPruned))
}
}()
}
// remoteUploadPath 远端元数据目标路径 = 同步目录远端根 + 相对路径。
func (st *strmSyncState) remoteUploadPath(rel string) string {
root := strings.TrimRight(normalizeRemotePath(st.p.RemotePath), "/")
if root == "/" || root == "" {
return "/" + rel
}
return root + "/" + rel
}
// uploadRemoteTarget 返回上传任务的目标远端描述。
// - 115:返回父目录 cid(供 PutFileNamed 定位),基于 dirPathToID 把父目录相对路径映射到 cid。
// - 网盘桥接(clouddrive2/openlist):返回完整远端路径。
func (st *strmSyncState) uploadRemoteTarget(rel string) string {
if st.p.Provider == model.StrmProvider115 {
dir := rel
if idx := strings.LastIndexByte(dir, '/'); idx >= 0 {
dir = dir[:idx]
} else {
dir = ""
}
if dir == "" {
// 文件在同步根目录下,父目录即 115 同步根目录 ID
return st.p.RemotePath
}
if cid, ok := st.dirPathToID[dir]; ok && cid != "" {
return cid
}
// 子目录在 115 远端不存在(本地孤儿子目录或远端已删除该分类文件夹),
// 返回空串,禁止降级回退到根目录上传以防污染根目录与错位死循环。
return ""
}
return st.remoteUploadPath(rel)
}
// taskExists 检查是否已有同目录、同目标的进行中/已完成任务(避免重复入队)。
func (st *strmSyncState) taskExists(kind, syncPathID, localPath string) bool {
ctx := st.ctx
var count int64
switch kind {
case "download":
count = st.s.repo.StrmDownload.CountActive(ctx, syncPathID, localPath)
default:
count = st.s.repo.StrmUpload.CountActive(ctx, syncPathID, localPath)
}
return count > 0
}
// markSeenDir 记录远端存在的目录及其全部祖先目录。
func (st *strmSyncState) markSeenDir(rel string) {
rel = strings.Trim(filepath.ToSlash(rel), "/")
if rel == "." {
rel = ""
}
st.mu.Lock()
if st.seenDir == nil {
st.seenDir = map[string]bool{"": true}
}
for {
st.seenDir[rel] = true
if rel == "" {
break
}
if idx := strings.LastIndexByte(rel, '/'); idx >= 0 {
rel = rel[:idx]
} else {
rel = ""
}
}
st.mu.Unlock()
}
// refreshUnseen115Dirs 补查本地存在、但 115 扁平文件列表未覆盖的目录。
// 扁平接口不返回空目录;逐层补查这些候选目录可以避免把远端仍存在的空目录误删。
func (st *strmSyncState) refreshUnseen115Dirs(localRoot string, dirs []string) error {
if st.p.Provider != model.StrmProvider115 || st.provider == nil {
return nil
}
dirIDs := map[string]string{"": strings.TrimSpace(st.p.RemotePath)}
if dirIDs[""] == "" {
dirIDs[""] = "0"
}
st.dirCache.Range(func(key, value any) bool {
id, idOK := key.(string)
rel, relOK := value.(string)
if idOK && relOK && id != "" {
dirIDs[cleanDirRel(rel)] = id
}
return true
})
liveIDs := map[string]string{"": dirIDs[""]}
listed := map[string]bool{}
sort.Strings(dirs)
for _, dir := range dirs {
rel, err := filepath.Rel(localRoot, dir)
if err != nil {
continue
}
rel = cleanDirRel(filepath.ToSlash(rel))
st.mu.Lock()
seen := st.seenDir[rel]
st.mu.Unlock()
if seen {
if id := dirIDs[rel]; id != "" {
liveIDs[rel] = id
}
continue
}
parentRel := ""
if idx := strings.LastIndexByte(rel, '/'); idx >= 0 {
parentRel = rel[:idx]
}
parentID := liveIDs[parentRel]
if parentID == "" {
continue // 父目录已确认不存在,子目录也必然是本地孤儿
}
if listed[parentRel] {
continue
}
entries, err := st.provider.List(st.ctx, parentID)
if err != nil {
return fmt.Errorf("核对 115 远端目录 %s 失败:%w", parentRel, err)
}
listed[parentRel] = true
for _, entry := range entries {
if !entry.IsDir {
continue
}
childRel := cleanEntryName(entry.Name, true)
if parentRel != "" {
childRel = parentRel + "/" + childRel
}
st.markSeenDir(childRel)
liveIDs[childRel] = entry.ID
dirIDs[childRel] = entry.ID
}
}
return nil
}
// pruneLocal 清理本地远端已不存在的内容:
// - 整个目录在远端不存在时,递归删除该本地目录(包括元数据);
// - 目录仍存在但视频已删除时,仅删除对应的 .strm,保留本地元数据;
// - DeleteDir 开启时,最后再清理其余空目录。
func (st *strmSyncState) pruneLocal() error {
// 增量同步保护:本次远端扫描不完整(目录详情解析失败 / 文件父路径降级)时,
// seenVideo 覆盖不全,按"远端不存在"清理会误删刚下载或已存在的本地 .strm,
// 进而触发"下次增量重新下载"的循环。此时跳过清理,仅做进度落库。
if st.syncType == model.StrmSyncTypeIncremental && st.scanIncomplete.Load() {
st.s.log.Warn("strm 增量同步跳过清理:本次远端扫描不完整,prune 已禁用",
zap.String("path_id", st.p.ID))
return nil
}
localRoot := filepath.Clean(st.p.LocalPath)
var dirs []string
var strmFiles []string
err := filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if path == localRoot {
return nil
}
select {
case <-st.ctx.Done():
return st.ctx.Err()
default:
}
if d.IsDir() {
dirs = append(dirs, path)
return nil
}
rel, err := filepath.Rel(localRoot, path)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
ext := strings.ToLower(filepath.Ext(rel))
if ext == ".strm" {
strmFiles = append(strmFiles, path)
}
return nil
})
if err != nil {
return err
}
if err := st.refreshUnseen115Dirs(localRoot, dirs); err != nil {
return err
}
// 先从浅到深找出最上层孤儿目录;父目录已判定为孤儿时无需重复处理子目录。
sort.Strings(dirs)
orphanRoots := make([]string, 0)
for _, dir := range dirs {
rel, relErr := filepath.Rel(localRoot, dir)
if relErr != nil {
continue
}
rel = filepath.ToSlash(rel)
st.mu.Lock()
existsRemotely := st.seenDir[rel]
st.mu.Unlock()
if existsRemotely {
continue
}
underOrphan := false
for _, root := range orphanRoots {
childRel, childErr := filepath.Rel(root, dir)
if childErr == nil && childRel != ".." && !strings.HasPrefix(childRel, ".."+string(filepath.Separator)) {
underOrphan = true
break
}
}
if !underOrphan {
orphanRoots = append(orphanRoots, dir)
}
}
for _, dir := range orphanRoots {
var fileCount int64
_ = filepath.WalkDir(dir, func(_ string, d os.DirEntry, walkErr error) error {
if walkErr == nil && !d.IsDir() {
fileCount++
}
return nil
})
if err := os.RemoveAll(dir); err == nil {
st.mu.Lock()
st.rec.Pruned += fileCount
st.mu.Unlock()
}
}
// 对仍存在于远端的目录,按原规则清理失去远端视频来源的单个 .strm。
for _, path := range strmFiles {
if _, err := os.Stat(path); err != nil {
continue // 已随孤儿目录递归删除
}
rel, relErr := filepath.Rel(localRoot, path)
if relErr != nil {
continue
}
rel = filepath.ToSlash(rel)
relSansExt := strings.TrimSuffix(rel, filepath.Ext(rel))
st.mu.Lock()
remove := !st.seenVideo["v:"+relSansExt]
st.mu.Unlock()
if remove {
if err := os.Remove(path); err == nil {
st.mu.Lock()
st.rec.Pruned++
st.mu.Unlock()
}
}
}
if st.cfg.DeleteDir {
sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err == nil && len(entries) == 0 {
_ = os.Remove(dir)
}
}
}
return nil
}
// touchProgress 进度计数并限流防抖落库(避免高频写 SQLite 导致锁竞争)。
func (st *strmSyncState) touchProgress() {
st.mu.Lock()
st.rec.Total++
st.processed++
now := time.Now()
flush := st.processed%100 == 0 || (st.processed%20 == 0 && now.Sub(st.lastProgressFlush) >= 2*time.Second)
if flush {
st.lastProgressFlush = now
}
st.mu.Unlock()
if flush {
st.flushProgress()
}
}
func (st *strmSyncState) flushProgress() {
st.mu.Lock()
rec := *st.rec
st.mu.Unlock()
if err := st.s.repo.StrmSyncRecord.Update(st.ctx, &rec); err != nil {
st.s.log.Warn("update strm sync progress failed", zap.Error(err))
}
}
// updateSyncMessage 实时更新同步阶段提示信息,让前端界面清晰了解当前进度。
func (st *strmSyncState) updateSyncMessage(msg string) {
st.mu.Lock()
st.rec.Message = msg
st.p.LastSyncMessage = msg
rec := *st.rec
p := *st.p
st.mu.Unlock()
_ = st.s.repo.StrmSyncRecord.Update(st.ctx, &rec)
_ = st.s.repo.StrmSyncPath.Update(st.ctx, &p)
}
// ─── 定时同步巡检 ──────────────────────────────────────────────────────────────
func (s *StrmService) cronLoop(ctx context.Context) {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
// 记录上次检查到的分钟:一轮循环若被慢操作拖过 60s(远端 List 慢、
// 串行 StartSync、DB 忙),ticker 会丢掉中间的 tick,命中排程的分钟
// 若只按"当前分钟相等"判定就会被静默跳过。逐分钟回放补触。
last := time.Now().Truncate(time.Minute)
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case now := <-ticker.C:
now = now.Truncate(time.Minute)
paths, err := s.repo.StrmSyncPath.List(ctx)
if err != nil {
last = now
continue
}
due := make([]time.Time, 0, 2)
for m := last.Add(time.Minute); !m.After(now); m = m.Add(time.Minute) {
due = append(due, m)
}
last = now
if len(due) == 0 {
continue
}
for i := range paths {
p := &paths[i]
if !p.Enabled || !p.EnableCron || strings.TrimSpace(p.Cron) == "" {
continue
}
matched := false
for _, m := range due {
if cronMatches(p.Cron, m) {
matched = true
break
}
}
if !matched {
continue
}
s.mu.Lock()
_, running := s.running[p.ID]
s.mu.Unlock()
if running {
continue
}
s.log.Info("strm cron triggered sync", zap.String("path_id", p.ID), zap.String("cron", p.Cron))
if err := s.StartSync(ctx, p.ID); err != nil {
s.log.Warn("strm cron sync start failed", zap.String("path_id", p.ID), zap.Error(err))
}
}
}
}
}
// cronMatches 匹配 5 段 cron 表达式(分 时 日 月 周)。支持 * ? /n a-b a,b 组合。
func cronMatches(expr string, t time.Time) bool {
fields := strings.Fields(strings.TrimSpace(expr))
if len(fields) != 5 {
return false
}
now := []int{t.Minute(), t.Hour(), t.Day(), int(t.Month()), int(t.Weekday())}
if now[4] == 0 {
now[4] = 7 // 周日常量统一为 7
}
for i, field := range fields {
if !cronFieldMatches(field, now[i]) {
return false
}
}
return true
}
// cronFieldMatches 匹配单个 cron 字段。
func cronFieldMatches(field string, value int) bool {
if field == "*" || field == "?" {
return true
}
for _, item := range strings.Split(field, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if strings.HasPrefix(item, "*/") {
step, err := strconv.Atoi(strings.TrimPrefix(item, "*/"))
if err != nil || step <= 0 {
continue
}
if value%step == 0 {
return true
}
continue
}
if strings.Contains(item, "-") {
bounds := strings.SplitN(item, "-", 2)
lo, errLo := strconv.Atoi(strings.TrimSpace(bounds[0]))
hi, errHi := strconv.Atoi(strings.TrimSpace(bounds[1]))
if errLo == nil && errHi == nil && lo <= hi && value >= lo && value <= hi {
return true
}
continue
}
n, err := strconv.Atoi(item)
if err == nil && n == value {
return true
}
}
return false
}