mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
feat(strm): 同名多版本择优 / keep_ext,并支持合并展示与播放切换 (#30)
* feat(strm): 同名多版本择优/keep_ext,并支持合并展示与播放切换 默认对同名不同扩展(如 .mkv/.mp4)按体积→mtime→扩展名优先级择优生成一条 strm,冲突打日志;开启 keep_ext 则分别生成 name.ext.strm。媒体详情与播放器可切换版本,列表继续合并同片多版本,Emby MediaSources 名称区分容器/分辨率。 Co-authored-by: truewhile <truewhile@users.noreply.github.com> * fix(meta): 边车匹配忽略 .mkv.strm 中间扩展,同片共享 NFO/海报/字幕 NFO、海报、字幕与刮削写出统一使用 mediaSidecarBase 词干,使竞女01.mkv.strm 与竞女01.mp4.strm 配对同一套 竞女01.nfo / 竞女01-poster / 竞女01.srt; 仍兼容历史上的 name.mkv.nfo 命名。 Co-authored-by: truewhile <truewhile@users.noreply.github.com> * fix(emby): People NFO 按共享词干匹配 keep_ext strm resolveMediaPeople 改为查找竞女01.nfo(并兼容旧的竞女01.mkv.nfo), 同时修正把 decodeNFOFile 的 partial 误当 ok 导致完整 NFO 被跳过的问题。 Co-authored-by: truewhile <truewhile@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
+138
-58
@@ -49,11 +49,13 @@ type strmSyncState struct {
|
||||
mu sync.Mutex
|
||||
processed int // 已处理文件计数(用于定期落库进度)
|
||||
lastProgressFlush time.Time // 上次进度落库时间
|
||||
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
|
||||
seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 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 但列表未到」窗口内的重复入队
|
||||
@@ -67,6 +69,13 @@ type strmSyncState struct {
|
||||
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 {
|
||||
@@ -276,6 +285,7 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m
|
||||
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)
|
||||
@@ -386,6 +396,7 @@ func (st *strmSyncState) run() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
st.flushPreferredVideos()
|
||||
st.flushPendingDownloads()
|
||||
st.flushProgress()
|
||||
if st.cfg.UploadMeta && st.provider != nil {
|
||||
@@ -1195,21 +1206,123 @@ func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *clo
|
||||
return allFiles, nil
|
||||
}
|
||||
|
||||
// handleVideo 生成/更新 .strm 文件。
|
||||
// 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)]
|
||||
st.mu.Lock()
|
||||
if st.seenVideo["v:"+relSansExt] {
|
||||
st.mu.Unlock()
|
||||
if st.cfg.KeepExt {
|
||||
st.writeVideoStrm(entry, rel, ext, rel+".strm", true)
|
||||
return
|
||||
}
|
||||
st.seenVideo["v:"+relSansExt] = true
|
||||
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()
|
||||
|
||||
targetRel := relSansExt + ".strm"
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1219,28 +1332,35 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
}
|
||||
if _, exists := st.seenVideoTarget[target]; exists {
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
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()
|
||||
st.touchProgress()
|
||||
done()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
content, err := st.strmContent(entry, rel, ext)
|
||||
if err != nil {
|
||||
// 并发 worker 下 rec.Message 无锁写会有数据竞争,这里仅记录日志;
|
||||
// 最终同步结果的 message 由 finishSync 统一填充。
|
||||
st.s.log.Warn("build strm content failed", zap.String("file", rel), zap.Error(err))
|
||||
done()
|
||||
return
|
||||
}
|
||||
existing := ""
|
||||
@@ -1248,7 +1368,6 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
existing = string(data)
|
||||
}
|
||||
if existing == content {
|
||||
// 对齐本地 strm 修改时间为远端 mtime,便于后续秒级比对
|
||||
if entry.MTime > 0 {
|
||||
mTime := time.Unix(entry.MTime, 0)
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
@@ -1256,21 +1375,24 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
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 {
|
||||
@@ -1280,7 +1402,7 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
st.mu.Lock()
|
||||
st.rec.NewStrm++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
done()
|
||||
}
|
||||
|
||||
// strmContent 构建 strm 文件内容(一行指向本服务播放端点的 URL)。
|
||||
@@ -1534,50 +1656,8 @@ func (st *strmSyncState) walkLocalSource() error {
|
||||
st.touchProgress()
|
||||
return nil
|
||||
}
|
||||
relSansExt := rel[:len(rel)-len(ext)]
|
||||
st.mu.Lock()
|
||||
st.seenVideo["v:"+relSansExt] = true
|
||||
st.mu.Unlock()
|
||||
content, err := st.strmContent(cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size()}, rel, ext)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
target, err := joinLocalRel(st.p.LocalPath, relSansExt+".strm")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mTime := info.ModTime()
|
||||
if st.syncType == model.StrmSyncTypeIncremental {
|
||||
if tInfo, err := os.Stat(target); err == nil && tInfo.Size() > 0 && tInfo.ModTime().Unix() == mTime.Unix() {
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile(target); err == nil && string(data) == content {
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return nil
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(content), 0o644); err == nil {
|
||||
_ = os.Rename(tmp, target)
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
} else {
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.rec.NewStrm++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
entry := cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size(), MTime: info.ModTime().Unix()}
|
||||
st.handleVideo(entry, rel, ext)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user