mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
38b85bb487
* 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>
359 lines
11 KiB
Go
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
|
|
}
|
|
}
|