Files
MeBox/internal/service/organizer_directory_versions.go
T
truewhile 38b85bb487 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>
2026-09-07 13:56:30 +08:00

359 lines
11 KiB
Go

package service
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
)
// existingVersionPaths returns existing destination files that represent the
// same media, combining two strategies and de-duplicating by path:
//
// 1. DB identity: media rows already scanned into the destination root whose
// title (case-insensitive) + year [or + season/episode] match the source.
// This is robust to directory case/layout differences.
// 2. Filesystem: video files inside the computed destination folder (matching
// the SxxExx tag for episodes). Covers destinations that were not scanned.
func (o *OrganizerService) existingVersionPaths(ctx context.Context, destRoot, destDir, title, episodeTag string, year, season, episode int) []string {
return mergeExistingVersionPaths(
o.existingByIdentity(ctx, destRoot, title, year, season, episode),
o.existingByFolder(destDir, episodeTag),
)
}
func mergeExistingVersionPaths(groups ...[]string) []string {
seen := map[string]struct{}{}
var out []string
add := func(p string) {
if p == "" {
return
}
c := filepath.Clean(p)
if _, ok := seen[c]; ok {
return
}
if _, err := os.Stat(c); err != nil {
return
}
seen[c] = struct{}{}
out = append(out, c)
}
for _, group := range groups {
for _, p := range group {
add(p)
}
}
return out
}
func (o *OrganizerService) allExistingPathsInDB(ctx context.Context, paths []string) bool {
if o == nil || o.repo == nil || o.repo.DB == nil || len(paths) == 0 {
return false
}
cleaned := make([]string, 0, len(paths))
seen := map[string]struct{}{}
for _, path := range paths {
path = filepath.Clean(strings.TrimSpace(path))
if path == "" || path == "." {
continue
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
cleaned = append(cleaned, path)
}
if len(cleaned) == 0 {
return false
}
var count int64
if err := o.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Where("path IN ?", cleaned).
Count(&count).Error; err != nil {
return false
}
return count == int64(len(cleaned))
}
// existingByIdentity finds scanned destination media matching the parsed
// identity (case-insensitive title + year for movies; title + season/episode
// for episodes), located under destRoot.
func (o *OrganizerService) existingByIdentity(ctx context.Context, destRoot, title string, year, season, episode int) []string {
if o.repo == nil || o.repo.DB == nil {
return nil
}
title = strings.TrimSpace(title)
if title == "" {
return nil
}
q := o.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("deleted_at IS NULL").
Where("LOWER(title) = ?", strings.ToLower(title))
if season > 0 || episode > 0 {
q = q.Where("season_num = ? AND episode_num = ?", season, episode)
} else if year > 0 {
q = q.Where("year = ?", year)
}
var rows []model.Media
if err := q.Find(&rows).Error; err != nil {
return nil
}
var out []string
for _, r := range rows {
if r.Path != "" && pathWithin(r.Path, destRoot) {
out = append(out, r.Path)
}
}
return out
}
func (o *OrganizerService) existingByExternalIdentity(ctx context.Context, destRoot string, match *Match, season, episode int) []string {
if o.repo == nil || o.repo.DB == nil || match == nil {
return nil
}
var conds []string
var args []any
if match.TMDbID > 0 {
conds = append(conds, "tm_db_id = ?")
args = append(args, match.TMDbID)
}
if match.BangumiID > 0 {
conds = append(conds, "bangumi_id = ?")
args = append(args, match.BangumiID)
}
if strings.TrimSpace(match.DoubanID) != "" {
conds = append(conds, "douban_id = ?")
args = append(args, strings.TrimSpace(match.DoubanID))
}
if strings.TrimSpace(match.TheTVDBID) != "" {
conds = append(conds, "thetvdb_id = ?")
args = append(args, strings.TrimSpace(match.TheTVDBID))
}
if len(conds) == 0 {
return nil
}
q := o.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("deleted_at IS NULL").
Where("("+strings.Join(conds, " OR ")+")", args...)
if season > 0 || episode > 0 {
q = q.Where("season_num = ? AND episode_num = ?", season, episode)
}
var rows []model.Media
if err := q.Find(&rows).Error; err != nil {
return nil
}
var out []string
for _, row := range rows {
if row.Path != "" && pathWithin(row.Path, destRoot) {
out = append(out, row.Path)
}
}
return out
}
// existingByFolder returns video files already present in destDir that
// represent the same media. For an episode (episodeTag != "") it matches files
// carrying the same SxxExx tag; for a movie it matches every video file in the
// movie folder.
func (o *OrganizerService) existingByFolder(destDir, episodeTag string) []string {
entries, err := os.ReadDir(destDir)
if err != nil {
return nil
}
tag := strings.ToLower(episodeTag)
var out []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if _, ok := videoExtensions[strings.ToLower(filepath.Ext(name))]; !ok {
continue
}
if tag != "" && !strings.Contains(strings.ToLower(name), tag) {
continue
}
out = append(out, filepath.Join(destDir, name))
}
return out
}
// replaceVersions promotes a higher-resolution src into dst, then removes the
// old lower-resolution files (and their NFO sidecars + DB rows). dst may
// coincide with one of the existing versions being superseded, so the new file
// is first transferred to a temporary staging name inside the destination
// directory. Only after the transfer succeeds are the old versions removed and
// the staged file renamed into place. This keeps the lower-res versions intact
// if the transfer fails (hardlink across mounts, disk full, etc.) instead of
// destroying them before the new file exists.
func (o *OrganizerService) replaceVersions(ctx context.Context, src string, existing []string, dst string, mode TransferMode) error {
dstDir := filepath.Dir(dst)
if err := os.MkdirAll(dstDir, 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users.
return err
}
// Unique staging name so dst (which may already exist as the lower-res
// version) is never the transfer target and never clobbered early.
stage := dst + ".replace" + randomSuffix()
cleanup := func() {
_ = os.Remove(stage)
_ = os.Remove(nfoPath(stage))
removeStagedArtwork(stage)
}
if err := transferFile(src, stage, mode); err != nil {
cleanup()
return err
}
if err := transferSidecarNFO(src, stage, mode); err != nil {
o.log.Warn("organize replace sidecar nfo failed",
zap.String("from", src), zap.String("to", dst), zap.Error(err))
}
if err := transferSidecarArtwork(src, stage, mode); err != nil {
o.log.Warn("organize replace sidecar artwork failed",
zap.String("from", src), zap.String("to", dst), zap.Error(err))
}
// New file is safely staged. 先把现有 dst 改名为备份、rename stage→dst
// 成功后,才删除旧版本:此前顺序是先删旧版本再 rename,一旦 rename
// 失败(Windows 下 dst 被播放器/杀软占用很常见),cleanup 会删掉
// stage——旧版本已删、move 模式下源已不在、新文件也删,数据彻底丢失。
var backup string
if _, err := os.Stat(dst); err == nil {
backup = dst + ".replacing-" + randomSuffix()
if err := os.Rename(dst, backup); err != nil {
cleanup()
return fmt.Errorf("备份现有文件失败(可能被其他程序占用):%w", err)
}
}
if err := os.Rename(stage, dst); err != nil {
if backup != "" {
if rbErr := os.Rename(backup, dst); rbErr != nil {
o.log.Error("organize replace restore backup failed",
zap.String("backup", backup), zap.Error(rbErr))
}
}
cleanup()
return err
}
// 新文件已就位,现在才删除被取代的旧版本。dst 路径此时已是新文件,
// 文件级删除必须跳过(DB 行仍按原语义清理)。
for _, e := range existing {
if e != dst {
if nfo := nfoPath(e); nfo != "" {
_ = os.Remove(nfo)
}
if err := os.Remove(e); err != nil && !os.IsNotExist(err) {
o.log.Warn("organize replace remove existing failed",
zap.String("path", e), zap.Error(err))
}
}
if o.repo != nil && o.repo.DB != nil {
_ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error
}
}
if backup != "" {
// 备份文件即被取代的旧 dst 内容,新文件已成功落位后移除。
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
o.log.Warn("organize replace remove backup failed",
zap.String("path", backup), zap.Error(err))
}
}
moveSidecarRename(nfoPath(stage), nfoPath(dst))
moveStagedArtwork(stage, dst)
return nil
}
// randomSuffix returns a short random suffix for staging filenames.
func randomSuffix() string {
return strconv.Itoa(int(time.Now().UnixNano()) & 0xffffff)
}
// moveStagedArtwork renames artwork sidecars staged alongside `stage` into
// their final names next to `dst`.
func moveStagedArtwork(stage, dst string) {
stageBases := mediaSidecarBaseVariants(stage)
dstBase := mediaSidecarBase(dst)
if dstBase == "" {
dstBase = strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
}
stageDir := filepath.Dir(stage)
for _, stageBase := range stageBases {
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
if _, err := os.Stat(srcPath); err != nil {
continue
}
_ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext))
}
}
}
}
// moveSidecarRename renames a staged sidecar to its final name if it exists.
func moveSidecarRename(from, to string) {
if from == "" || to == "" {
return
}
if _, err := os.Stat(from); err != nil {
return
}
_ = os.Rename(from, to)
}
// removeStagedArtwork removes artwork sidecars that were staged alongside
// `stage`, used when a replace fails and its staged outputs must be cleaned up.
func removeStagedArtwork(stage string) {
stageBases := mediaSidecarBaseVariants(stage)
stageDir := filepath.Dir(stage)
for _, stageBase := range stageBases {
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
_ = os.Remove(filepath.Join(stageDir, stageBase+suffix+ext))
}
}
}
}
// resolutionArea returns the pixel area (width*height) of a video file for 洗版
// comparison. It prefers ffprobe; when unavailable it falls back to a
// resolution token in the filename (2160p/1080p/720p). Returns 0 when the
// resolution cannot be determined, in which case the caller treats the file as
// "unknown" and never performs a destructive replace.
func (o *OrganizerService) resolutionArea(ctx context.Context, path string) int {
// Prefer a scanned media row's stored dimensions. The destination library
// is normally scanned with ffprobe, so its files have accurate Width/Height
// even after organize stripped the resolution token from the filename.
if o.repo != nil && o.repo.DB != nil {
var m model.Media
if err := o.repo.DB.WithContext(ctx).
Select("width", "height").
Where("path = ?", path).
Limit(1).Take(&m).Error; err == nil && m.Width > 0 && m.Height > 0 {
return m.Width * m.Height
}
}
if o.probe != nil {
if pr, err := o.probe.Probe(ctx, path); err == nil && pr != nil && pr.Width > 0 && pr.Height > 0 {
return pr.Width * pr.Height
}
}
switch detectResolutionScore(strings.ToLower(filepath.Base(path))) {
case 4:
return 3840 * 2160
case 3:
return 1920 * 1080
case 2:
return 1280 * 720
default:
return 0
}
}