mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
1258 lines
42 KiB
Go
1258 lines
42 KiB
Go
// Package service — organize an arbitrary source directory (e.g. the download
|
|
// directory) into the destination library with dedup + 洗版 (resolution
|
|
// replacement).
|
|
//
|
|
// Unlike OrganizeLibraryWithOptions, which only touches model.Media rows that
|
|
// already belong to a registered library, OrganizeDirectory walks the source
|
|
// directory on disk directly. This lets operators organize the whole download
|
|
// directory (/downloads or a NAS direct-read path configured by the operator)
|
|
// even though it is not a registered library.
|
|
//
|
|
// Two protections requested by operators:
|
|
//
|
|
// - 去重:目的地已存在同一媒体时不再从来源整理过去(避免重复 / 多倍占用存储)。
|
|
// - 洗版:若来源分辨率高于目的地已存在的版本,则用高分辨率替换低分辨率。
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
|
)
|
|
|
|
const (
|
|
organizeSkipAlreadyOrganized = "already organized"
|
|
organizeSkipDuplicateLibrary = "duplicate in library"
|
|
organizeSkipTargetExists = "target file exists"
|
|
organizeSkipSampleClip = "sample/trailer clip"
|
|
)
|
|
|
|
// OrganizeSourceCandidate is a selectable organize source directory surfaced to
|
|
// the UI so operators can organize an arbitrary directory (such as the download
|
|
// directory) and not only registered libraries.
|
|
type OrganizeSourceCandidate struct {
|
|
Label string `json:"label"`
|
|
Path string `json:"path"`
|
|
Kind string `json:"kind"` // "download" | "media"
|
|
}
|
|
|
|
// OrganizeSourceCandidates returns the configured directories that are valid
|
|
// organize sources (download dir + media dir). It uses the container-visible
|
|
// paths; in NAS direct-read mode those equal the host paths the operator sees.
|
|
func (o *OrganizerService) OrganizeSourceCandidates(ctx context.Context) []OrganizeSourceCandidate {
|
|
out := []OrganizeSourceCandidate{}
|
|
seen := map[string]struct{}{}
|
|
add := func(label, path, kind string) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" || path == "." || strings.HasPrefix(path, ".") {
|
|
return
|
|
}
|
|
clean := filepath.Clean(path)
|
|
if !isAccessibleDir(clean) {
|
|
return
|
|
}
|
|
if _, ok := seen[clean]; ok {
|
|
return
|
|
}
|
|
seen[clean] = struct{}{}
|
|
out = append(out, OrganizeSourceCandidate{Label: label, Path: clean, Kind: kind})
|
|
}
|
|
add("默认整理源", o.settingValue(ctx, "organize.source_dir"), "source")
|
|
add("下载器保存目录", o.settingValue(ctx, "qbittorrent.savepath"), "download")
|
|
add("下载目录", envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads"), "download")
|
|
add("媒体目录", envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media"), "media")
|
|
return out
|
|
}
|
|
|
|
func (o *OrganizerService) settingValue(ctx context.Context, key string) string {
|
|
if o.repo == nil || o.repo.Setting == nil {
|
|
return ""
|
|
}
|
|
if v, err := o.repo.Setting.Get(ctx, key); err == nil {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// defaultSourceRoot resolves the source root for a directory organize:
|
|
// explicit override → organize.source_dir setting → qB default save path →
|
|
// download container dir.
|
|
func (o *OrganizerService) defaultSourceRoot(ctx context.Context, override string) string {
|
|
if r := strings.TrimSpace(override); r != "" {
|
|
return r
|
|
}
|
|
if v := o.settingValue(ctx, "organize.source_dir"); v != "" {
|
|
return v
|
|
}
|
|
if v := o.settingValue(ctx, "qbittorrent.savepath"); v != "" {
|
|
return v
|
|
}
|
|
return envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")
|
|
}
|
|
|
|
// defaultDestRoot resolves the destination root for a directory organize:
|
|
// explicit override → organize.target_dir setting → media container dir.
|
|
func (o *OrganizerService) defaultDestRoot(ctx context.Context, override string) string {
|
|
if r := strings.TrimSpace(override); r != "" {
|
|
return r
|
|
}
|
|
if o.repo != nil && o.repo.Setting != nil {
|
|
if v, err := o.repo.Setting.Get(ctx, "organize.target_dir"); err == nil && strings.TrimSpace(v) != "" {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")
|
|
}
|
|
|
|
// OrganizeDirectory organizes every video file found under opts.SourcePath into
|
|
// the destination root, applying dedup + 洗版 (resolution replacement).
|
|
func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeOptions) (*OrganizeResult, error) {
|
|
requestedSource := strings.TrimSpace(o.defaultSourceRoot(ctx, opts.SourcePath))
|
|
if requestedSource == "" {
|
|
return nil, errors.New("source path required")
|
|
}
|
|
source, info, statErr := resolveAccessibleMappedPath(requestedSource)
|
|
if statErr != nil {
|
|
return nil, fmt.Errorf("source directory not accessible: %s", filepath.Clean(requestedSource))
|
|
}
|
|
requestedDest := strings.TrimSpace(o.defaultDestRoot(ctx, opts.DestPath))
|
|
if _, ok := ParseCloudLibraryMount(requestedDest); ok {
|
|
return nil, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
|
|
}
|
|
dest := resolveMappedDestinationPath(requestedDest)
|
|
if dest == "" || dest == "." {
|
|
return nil, errors.New("destination path required")
|
|
}
|
|
if !opts.DryRun {
|
|
if err := ensureOrganizeDestinationWritable(dest); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
mode := o.resolveTransferMode(ctx, opts.TransferMode)
|
|
res := &OrganizeResult{SourcePath: source, DestPath: dest, DryRun: opts.DryRun}
|
|
metadataCache := map[string]*Match{}
|
|
if !info.IsDir() {
|
|
ext := strings.ToLower(filepath.Ext(source))
|
|
if _, ok := videoExtensions[ext]; !ok {
|
|
return nil, fmt.Errorf("source is not a supported video file: %s", source)
|
|
}
|
|
if skipped, reason := shouldSkipOrganizeSourceVideo(source, filepath.Dir(source)); skipped {
|
|
res.Skipped++
|
|
res.Items = append(res.Items, OrganizePreviewItem{Source: source, Action: "skip", Reason: reason})
|
|
o.log.Info("organize file finished",
|
|
zap.String("source", source),
|
|
zap.String("dest", dest),
|
|
zap.String("mode", string(mode)),
|
|
zap.Int("organized", res.Organized),
|
|
zap.Int("replaced", res.Replaced),
|
|
zap.Int("skipped", res.Skipped),
|
|
zap.Any("skip_reasons", OrganizeSkipReasonCounts(res)),
|
|
)
|
|
return res, nil
|
|
}
|
|
if err := o.organizeSourceFile(ctx, source, filepath.Dir(source), dest, mode, opts.MediaType, opts.MediaCategory, opts.DryRun, opts.AllowReplaceExisting, metadataCache, res); err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", filepath.Base(source), err.Error()))
|
|
res.Items = append(res.Items, OrganizePreviewItem{Source: source, Action: "error", Reason: err.Error()})
|
|
}
|
|
o.log.Info("organize file finished",
|
|
zap.String("source", source),
|
|
zap.String("dest", dest),
|
|
zap.String("mode", string(mode)),
|
|
zap.Int("organized", res.Organized),
|
|
zap.Int("replaced", res.Replaced),
|
|
zap.Int("skipped", res.Skipped),
|
|
zap.Any("skip_reasons", OrganizeSkipReasonCounts(res)),
|
|
)
|
|
return res, nil
|
|
}
|
|
walkErr := walk(source, func(path string, wi walkInfo) error {
|
|
if wi.isDir {
|
|
return nil
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if _, ok := videoExtensions[ext]; !ok {
|
|
return nil
|
|
}
|
|
if skipped, reason := shouldSkipOrganizeSourceVideo(path, source); skipped {
|
|
res.Skipped++
|
|
res.Items = append(res.Items, OrganizePreviewItem{Source: path, Action: "skip", Reason: reason})
|
|
return nil
|
|
}
|
|
if err := o.organizeSourceFile(ctx, path, source, dest, mode, opts.MediaType, opts.MediaCategory, opts.DryRun, opts.AllowReplaceExisting, metadataCache, res); err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", filepath.Base(path), err.Error()))
|
|
res.Items = append(res.Items, OrganizePreviewItem{Source: path, Action: "error", Reason: err.Error()})
|
|
}
|
|
return nil
|
|
})
|
|
if walkErr != nil {
|
|
return res, walkErr
|
|
}
|
|
o.log.Info("organize directory finished",
|
|
zap.String("source", source),
|
|
zap.String("dest", dest),
|
|
zap.String("mode", string(mode)),
|
|
zap.Int("organized", res.Organized),
|
|
zap.Int("replaced", res.Replaced),
|
|
zap.Int("skipped", res.Skipped),
|
|
zap.Any("skip_reasons", OrganizeSkipReasonCounts(res)),
|
|
)
|
|
return res, nil
|
|
}
|
|
|
|
func ensureOrganizeDestinationWritable(dest string) error {
|
|
dest = strings.TrimSpace(dest)
|
|
if dest == "" || dest == "." {
|
|
return errors.New("destination path required")
|
|
}
|
|
if _, ok := ParseCloudLibraryMount(dest); ok {
|
|
return errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
|
|
}
|
|
if err := os.MkdirAll(dest, 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users.
|
|
return fmt.Errorf("destination path is not a writable directory: %s: %w", dest, err)
|
|
}
|
|
probe, err := os.CreateTemp(dest, ".mediastation-write-test-*") // #nosec G304 -- dest is operator-configured organize root.
|
|
if err != nil {
|
|
return fmt.Errorf("destination path is not writable: %s: %w", dest, err)
|
|
}
|
|
name := probe.Name()
|
|
if closeErr := probe.Close(); closeErr != nil {
|
|
_ = os.Remove(name)
|
|
return fmt.Errorf("destination path write probe failed: %s: %w", dest, closeErr)
|
|
}
|
|
if err := os.Remove(name); err != nil {
|
|
return fmt.Errorf("destination path cleanup probe failed: %s: %w", dest, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type organizeDirectoryLayout struct {
|
|
MediaType string
|
|
Category string
|
|
}
|
|
|
|
// organizeSourceFile organizes a single video file from the source directory
|
|
// into destRoot, applying dedup + 洗版.
|
|
func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRoot, destRoot string, mode TransferMode, mediaTypeOverride, mediaCategoryOverride string, dryRun bool, allowReplaceExisting bool, metadataCache map[string]*Match, res *OrganizeResult) error {
|
|
ext := filepath.Ext(src)
|
|
title, year := CleanQuery(src)
|
|
if title == "" {
|
|
title = strings.TrimSuffix(filepath.Base(src), ext)
|
|
}
|
|
// CleanQuery lowercases the parsed title; title-case it so organized output
|
|
// matches typical library casing (and stays consistent for dedup).
|
|
parsedTitle := title
|
|
title = sanitizeFilename(titleCaseWords(title))
|
|
if title == "" {
|
|
title = "Unknown"
|
|
}
|
|
season, episode := ParseEpisode(src)
|
|
pathLayout := o.inferOrganizeDirectoryLayout(src, sourceRoot)
|
|
layout := pathLayout
|
|
forcedType := normalizeOrganizeMediaType(mediaTypeOverride)
|
|
inferredType := o.inferMediaTypeForSourceFile(src, title, season, episode)
|
|
if forcedType != "" {
|
|
if layout.Category != "" && layout.MediaType != "" && layout.MediaType != forcedType {
|
|
layout.Category = ""
|
|
}
|
|
layout.MediaType = forcedType
|
|
} else if inferredType != "" {
|
|
if inferredType == "tv" && layout.MediaType == "movie" {
|
|
// 文件名中明确有季/集信息时,目录名只能作为弱提示;否则
|
|
// 下载到错误的“电影/外语电影”等目录会把剧集按电影入库。
|
|
layout = organizeDirectoryLayout{MediaType: inferredType}
|
|
} else if layout.MediaType == "" {
|
|
layout.MediaType = inferredType
|
|
}
|
|
}
|
|
var metadataMatch *Match
|
|
if match := o.lookupOrganizeMetadata(ctx, src, sourceRoot, layout.MediaType, title, year, season, episode, metadataCache); match != nil {
|
|
metadataMatch = match
|
|
if matchedTitle := sanitizeFilename(strings.TrimSpace(match.Title)); matchedTitle != "" {
|
|
title = matchedTitle
|
|
parsedTitle = strings.TrimSpace(match.Title)
|
|
}
|
|
if match.Year > 0 {
|
|
year = match.Year
|
|
}
|
|
}
|
|
if category := strings.TrimSpace(mediaCategoryOverride); category != "" {
|
|
layout.Category = sanitizeFilename(category)
|
|
} else if category := o.smartClassifySourceFile(ctx, src, sourceRoot, layout.MediaType, title, parsedTitle, metadataMatch); category != "" {
|
|
// MoviePilot 的分类策略以识别后的元数据为主,下载/源目录只作为
|
|
// 兜底提示。这里即使源目录已有二级分类,也允许 TMDb/Bangumi/NFO
|
|
// 识别结果修正到真正的分类,避免错误目录导致错误入库。
|
|
layout.Category = category
|
|
}
|
|
if forcedType == "" {
|
|
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" {
|
|
layout.Category = normalizedCategory
|
|
if layout.MediaType == "" || layout.MediaType == "tv" || layout.MediaType == "anime" || pathLayout.Category != layout.Category {
|
|
layout.MediaType = impliedType
|
|
}
|
|
}
|
|
}
|
|
layoutRoot, matchedLibrary := o.organizeLibraryRootForLayout(ctx, destRoot, layout.MediaType, layout.Category)
|
|
if !matchedLibrary && layout.MediaType != "" {
|
|
layoutRoot = o.organizeRoot(destRoot, layout.MediaType, layout.Category)
|
|
}
|
|
if !matchedLibrary && layout.Category != "" {
|
|
layoutRoot = categoryRoot(layoutRoot, sanitizeFilename(layout.Category))
|
|
}
|
|
if !matchedLibrary && !dryRun {
|
|
o.ensureOrganizeLibraryForRoot(ctx, layoutRoot, layout.MediaType, layout.Category)
|
|
}
|
|
|
|
var destDir, dst, episodeTag string
|
|
isSeries := season > 0 || episode > 0
|
|
if layout.MediaType != "" {
|
|
isSeries = isSeriesLibraryType(layout.MediaType) && (season > 0 || episode > 0)
|
|
}
|
|
target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
|
|
Root: layoutRoot,
|
|
MediaType: layout.MediaType,
|
|
Category: layout.Category,
|
|
Title: title,
|
|
Ext: ext,
|
|
Year: year,
|
|
Season: season,
|
|
Episode: episode,
|
|
Series: isSeries,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
destDir = target.Dir
|
|
dst = target.Path
|
|
episodeTag = target.EpisodeTag
|
|
|
|
// 源文件已经位于目标位置:无需处理。
|
|
if filepath.Clean(src) == filepath.Clean(dst) {
|
|
res.Skipped++
|
|
res.Items = append(res.Items, OrganizePreviewItem{
|
|
Source: src, Target: dst, Action: "skip", Reason: organizeSkipAlreadyOrganized,
|
|
MediaType: layout.MediaType, Category: layout.Category, Title: title,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// 去重候选:合并「目的地媒体库已扫描入库的同一媒体(按标题/年份/季集匹配,
|
|
// 不受目录大小写或布局影响)」与「目标文件夹内已存在的同名视频文件」。
|
|
externalExisting := o.existingByExternalIdentity(ctx, destRoot, metadataMatch, season, episode)
|
|
identityExisting := o.existingByIdentity(ctx, destRoot, parsedTitle, year, season, episode)
|
|
folderExisting := o.existingByFolder(destDir, episodeTag)
|
|
existing := mergeExistingVersionPaths(externalExisting, identityExisting, folderExisting)
|
|
if len(existing) > 0 {
|
|
srcArea := o.resolutionArea(ctx, src)
|
|
bestArea := 0
|
|
for _, e := range existing {
|
|
if a := o.resolutionArea(ctx, e); a > bestArea {
|
|
bestArea = a
|
|
}
|
|
}
|
|
// 洗版:仅当来源与已存在版本的分辨率都可判定、且来源更高时才替换;
|
|
// 任一方分辨率未知时保守跳过,绝不删除无法判定的已存在文件。
|
|
if allowReplaceExisting && srcArea > 0 && bestArea > 0 && srcArea > bestArea {
|
|
res.Items = append(res.Items, OrganizePreviewItem{
|
|
Source: src, Target: dst, Action: "replace", Reason: "higher resolution",
|
|
MediaType: layout.MediaType, Category: layout.Category, Title: title,
|
|
})
|
|
if dryRun {
|
|
res.Replaced++
|
|
return nil
|
|
}
|
|
if err := o.replaceVersions(ctx, src, existing, dst, mode); err != nil {
|
|
return err
|
|
}
|
|
o.log.Info("organize replaced lower-resolution media",
|
|
zap.String("from", src),
|
|
zap.String("to", dst),
|
|
zap.Int("src_area", srcArea),
|
|
zap.Int("existing_area", bestArea),
|
|
)
|
|
res.Replaced++
|
|
return nil
|
|
}
|
|
// 去重:目的地已存在同一媒体且不低于来源分辨率,跳过不再整理过去。
|
|
reason := organizeSkipTargetExists
|
|
if len(externalExisting) > 0 || len(identityExisting) > 0 || o.allExistingPathsInDB(ctx, existing) {
|
|
reason = organizeSkipDuplicateLibrary
|
|
}
|
|
o.log.Debug("organize skip duplicate",
|
|
zap.String("src", src), zap.String("dest_dir", destDir), zap.String("reason", reason))
|
|
res.Skipped++
|
|
res.Items = append(res.Items, OrganizePreviewItem{
|
|
Source: src, Target: dst, Action: "skip", Reason: reason,
|
|
MediaType: layout.MediaType, Category: layout.Category, Title: title,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
res.Items = append(res.Items, OrganizePreviewItem{
|
|
Source: src, Target: dst, Action: "organize",
|
|
MediaType: layout.MediaType, Category: layout.Category, Title: title,
|
|
})
|
|
if dryRun {
|
|
res.Organized++
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(destDir, 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users.
|
|
return err
|
|
}
|
|
if _, err := os.Stat(dst); err == nil {
|
|
res.Skipped++
|
|
if len(res.Items) > 0 {
|
|
res.Items[len(res.Items)-1].Action = "skip"
|
|
res.Items[len(res.Items)-1].Reason = organizeSkipTargetExists
|
|
}
|
|
return nil
|
|
}
|
|
if err := transferFile(src, dst, mode); err != nil {
|
|
return err
|
|
}
|
|
if err := transferSidecarNFO(src, dst, mode); err != nil {
|
|
o.log.Warn("organize sidecar nfo failed",
|
|
zap.String("from", src), zap.String("to", dst), zap.Error(err))
|
|
}
|
|
res.Organized++
|
|
return nil
|
|
}
|
|
|
|
func shouldSkipOrganizeSourceVideo(path, sourceRoot string) (bool, string) {
|
|
cleanPath := filepath.Clean(path)
|
|
cleanRoot := filepath.Clean(sourceRoot)
|
|
if rel, err := filepath.Rel(cleanRoot, cleanPath); err == nil && rel != "." && !strings.HasPrefix(rel, "..") {
|
|
dir := filepath.Dir(rel)
|
|
if dir != "." {
|
|
for _, part := range strings.Split(dir, string(os.PathSeparator)) {
|
|
switch normalizeOrganizeCategoryKey(part) {
|
|
case "sample", "samples", "trailer", "trailers", "preview", "previews", "teaser", "teasers":
|
|
return true, organizeSkipSampleClip
|
|
}
|
|
}
|
|
}
|
|
}
|
|
base := strings.ToLower(strings.TrimSuffix(filepath.Base(cleanPath), filepath.Ext(cleanPath)))
|
|
normalized := strings.NewReplacer("_", " ", "-", " ", ".", " ").Replace(base)
|
|
fields := strings.Fields(normalized)
|
|
if len(fields) == 0 {
|
|
return false, ""
|
|
}
|
|
if len(fields) == 1 && strings.HasPrefix(fields[0], "sample") {
|
|
return true, organizeSkipSampleClip
|
|
}
|
|
last := fields[len(fields)-1]
|
|
switch last {
|
|
case "sample", "trailer", "preview", "teaser":
|
|
return true, organizeSkipSampleClip
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
func normalizeOrganizeMediaType(mediaType string) string {
|
|
switch strings.ToLower(strings.TrimSpace(mediaType)) {
|
|
case "movie", "film":
|
|
return "movie"
|
|
case "tv", "series", "show", "drama":
|
|
return "tv"
|
|
case "anime", "animation":
|
|
return "anime"
|
|
case "variety":
|
|
return "variety"
|
|
case "adult", "nsfw":
|
|
return "adult"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (o *OrganizerService) inferMediaTypeForSourceFile(src, title string, season, episode int) string {
|
|
if season > 0 || episode > 0 {
|
|
return "tv"
|
|
}
|
|
return normalizeMediaType("", title, src)
|
|
}
|
|
|
|
func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sourceRoot, mediaType, title string, year, season, episode int, cache map[string]*Match) *Match {
|
|
seriesLike := isSeriesLibraryType(mediaType) || season > 0 || episode > 0
|
|
if local, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && local != nil {
|
|
if match := organizeMatchFromLocalMetadata(local); match != nil {
|
|
return match
|
|
}
|
|
} else if err != nil && o.log != nil {
|
|
o.log.Debug("organize read local metadata before rename failed", zap.String("path", src), zap.Error(err))
|
|
}
|
|
if o == nil || o.scraper == nil || !o.scraper.AnyEnabled() {
|
|
return nil
|
|
}
|
|
libType := normalizeOrganizeMediaType(mediaType)
|
|
if libType == "" {
|
|
libType = organizeLibraryModelType(mediaType)
|
|
}
|
|
lib := &model.Library{Path: sourceRoot, Type: libType, Enabled: true}
|
|
media := &model.Media{
|
|
Title: title,
|
|
Year: year,
|
|
Path: src,
|
|
SeasonNum: season,
|
|
EpisodeNum: episode,
|
|
}
|
|
for _, candidate := range scrapeQueryCandidates(media, lib) {
|
|
key := organizeMetadataCacheKey(lib.Type, candidate, year)
|
|
if cache != nil {
|
|
if cached, ok := cache[key]; ok {
|
|
if cached != nil {
|
|
return cached
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
match := o.scraper.lookup(ctx, lib, candidate, year)
|
|
if match != nil && strings.TrimSpace(match.Title) != "" {
|
|
if !organizeMetadataMatchTrusted(candidate, year, match) {
|
|
if cache != nil {
|
|
cache[key] = nil
|
|
}
|
|
if o.log != nil {
|
|
o.log.Warn("organize metadata match rejected before rename",
|
|
zap.String("source", src),
|
|
zap.String("query", candidate),
|
|
zap.String("title", match.Title),
|
|
zap.Int("source_year", year),
|
|
zap.Int("match_year", match.Year),
|
|
zap.Int("tmdb_id", match.TMDbID),
|
|
zap.Int("bangumi_id", match.BangumiID),
|
|
zap.String("douban_id", match.DoubanID),
|
|
zap.String("thetvdb_id", match.TheTVDBID))
|
|
}
|
|
continue
|
|
}
|
|
if cache != nil {
|
|
cache[key] = match
|
|
}
|
|
if o.log != nil {
|
|
o.log.Info("organize metadata matched before rename",
|
|
zap.String("source", src),
|
|
zap.String("query", candidate),
|
|
zap.String("title", match.Title),
|
|
zap.Int("year", match.Year),
|
|
zap.Int("tmdb_id", match.TMDbID),
|
|
zap.Int("bangumi_id", match.BangumiID),
|
|
zap.String("douban_id", match.DoubanID),
|
|
zap.String("thetvdb_id", match.TheTVDBID))
|
|
}
|
|
return match
|
|
}
|
|
if cache != nil {
|
|
cache[key] = nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func organizeMetadataMatchTrusted(query string, sourceYear int, match *Match) bool {
|
|
if match == nil || strings.TrimSpace(match.Title) == "" {
|
|
return false
|
|
}
|
|
if sourceYear > 0 && match.Year > 0 {
|
|
diff := sourceYear - match.Year
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff > 1 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match {
|
|
if local == nil || strings.TrimSpace(local.Title) == "" {
|
|
return nil
|
|
}
|
|
match := &Match{
|
|
Title: strings.TrimSpace(local.Title),
|
|
OriginalName: strings.TrimSpace(local.OriginalName),
|
|
Overview: local.Overview,
|
|
PosterURL: local.PosterURL,
|
|
BackdropURL: local.BackdropURL,
|
|
Year: local.Year,
|
|
Rating: local.Rating,
|
|
TMDbID: local.TMDbID,
|
|
DoubanID: local.DoubanID,
|
|
TheTVDBID: local.TheTVDBID,
|
|
NSFW: local.NSFW,
|
|
}
|
|
if local.Genres != "" {
|
|
match.Genres = splitNFOList(local.Genres)
|
|
}
|
|
if local.Countries != "" {
|
|
match.Countries = splitNFOList(local.Countries)
|
|
}
|
|
if local.Languages != "" {
|
|
match.Languages = splitNFOList(local.Languages)
|
|
}
|
|
return match
|
|
}
|
|
|
|
func organizeMetadataCacheKey(mediaType, query string, year int) string {
|
|
return strings.ToLower(strings.TrimSpace(mediaType)) + "|" + fmt.Sprint(year) + "|" + strings.ToLower(strings.TrimSpace(query))
|
|
}
|
|
|
|
func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sourceRoot, mediaType, title, parsedTitle string, metadataMatch *Match) string {
|
|
if o == nil || !o.isSmartClassifyEnabled(ctx) {
|
|
return ""
|
|
}
|
|
seriesLike := isSeriesLibraryType(mediaType)
|
|
input := mediaClassifyInput{
|
|
MediaType: mediaType,
|
|
Title: strings.Join([]string{title, parsedTitle, filepath.Base(src)}, " "),
|
|
Category: strings.Join(organizeDirectoryCategoryCandidates(src, sourceRoot), " "),
|
|
}
|
|
if metadataMatch != nil {
|
|
input.Title = strings.Join([]string{
|
|
metadataMatch.OriginalName,
|
|
title,
|
|
parsedTitle,
|
|
filepath.Base(src),
|
|
}, " ")
|
|
input.Languages = metadataMatch.Languages
|
|
input.Countries = metadataMatch.Countries
|
|
input.Genres = metadataMatch.Genres
|
|
if metadataMatch.NSFW {
|
|
input.MediaType = "adult"
|
|
}
|
|
}
|
|
if meta, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && meta != nil && meta.HasNFO {
|
|
input.Title = strings.Join([]string{meta.Title, meta.OriginalName, title, parsedTitle, filepath.Base(src)}, " ")
|
|
input.Languages = parseCommaList(meta.Languages)
|
|
input.Countries = parseCommaList(meta.Countries)
|
|
input.Genres = parseCommaList(meta.Genres)
|
|
if meta.NSFW {
|
|
input.MediaType = "adult"
|
|
}
|
|
}
|
|
return sanitizeFilename(classifyMediaCategory(input, o.categoryMap()))
|
|
}
|
|
|
|
func (o *OrganizerService) inferOrganizeDirectoryLayout(src, sourceRoot string) organizeDirectoryLayout {
|
|
for _, name := range organizeDirectoryCategoryCandidates(src, sourceRoot) {
|
|
if mediaType, category := o.mediaTypeForDirectoryCategory(name); mediaType != "" && category != "" {
|
|
return organizeDirectoryLayout{MediaType: mediaType, Category: category}
|
|
}
|
|
}
|
|
return organizeDirectoryLayout{}
|
|
}
|
|
|
|
func organizeDirectoryCategoryCandidates(src, sourceRoot string) []string {
|
|
var out []string
|
|
seen := map[string]struct{}{}
|
|
add := func(value string) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || value == "." || value == string(os.PathSeparator) {
|
|
return
|
|
}
|
|
key := strings.ToLower(value)
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
|
|
cleanSourceRoot := filepath.Clean(sourceRoot)
|
|
for _, part := range organizePathNameParts(cleanSourceRoot) {
|
|
add(part)
|
|
}
|
|
rel, err := filepath.Rel(cleanSourceRoot, filepath.Clean(src))
|
|
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
|
|
return out
|
|
}
|
|
dir := filepath.Dir(rel)
|
|
if dir == "." {
|
|
return out
|
|
}
|
|
for _, part := range strings.Split(dir, string(os.PathSeparator)) {
|
|
add(part)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func organizePathNameParts(path string) []string {
|
|
clean := filepath.Clean(strings.TrimSpace(path))
|
|
if clean == "" || clean == "." {
|
|
return nil
|
|
}
|
|
volume := filepath.VolumeName(clean)
|
|
if volume != "" {
|
|
clean = strings.TrimPrefix(clean, volume)
|
|
}
|
|
clean = strings.Trim(clean, string(os.PathSeparator))
|
|
if clean == "" {
|
|
base := filepath.Base(filepath.Clean(path))
|
|
if base == "." || base == string(os.PathSeparator) {
|
|
return nil
|
|
}
|
|
return []string{base}
|
|
}
|
|
parts := strings.Split(clean, string(os.PathSeparator))
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" && part != "." {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (o *OrganizerService) mediaTypeForDirectoryCategory(name string) (string, string) {
|
|
key := strings.ToLower(strings.TrimSpace(name))
|
|
if key == "" {
|
|
return "", ""
|
|
}
|
|
if hit, ok := o.directoryCategoryTypes()[key]; ok {
|
|
return hit.MediaType, hit.Category
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func (o *OrganizerService) directoryCategoryTypes() map[string]organizeDirectoryLayout {
|
|
categories := o.categoryMap()
|
|
out := map[string]organizeDirectoryLayout{}
|
|
add := func(category, mediaType string) {
|
|
category = strings.TrimSpace(category)
|
|
if category == "" {
|
|
return
|
|
}
|
|
out[strings.ToLower(category)] = organizeDirectoryLayout{
|
|
MediaType: mediaType,
|
|
Category: category,
|
|
}
|
|
}
|
|
addConfigured := func(key, fallback, mediaType string) {
|
|
add(fallback, mediaType)
|
|
add(categoryName(categories, key, fallback), mediaType)
|
|
}
|
|
addConfigured("animation_movie", "动画电影", "movie")
|
|
addConfigured("chinese_movie", "华语电影", "movie")
|
|
addConfigured("jk_movie", "日韩电影", "movie")
|
|
addConfigured("euus_movie", "欧美电影", "movie")
|
|
addConfigured("foreign_movie", "外语电影", "movie")
|
|
addConfigured("domestic_tv", "国产剧", "tv")
|
|
addConfigured("euus_tv", "欧美剧", "tv")
|
|
addConfigured("jk_tv", "日韩剧", "tv")
|
|
addConfigured("cn_anime", "国漫", "anime")
|
|
addConfigured("jp_anime", "日番", "anime")
|
|
addConfigured("variety", "综艺", "variety")
|
|
addConfigured("documentary", "纪录片", "tv")
|
|
addConfigured("children", "儿童", "tv")
|
|
addConfigured("uncategorized_tv", "未分类", "tv")
|
|
addConfigured("adult", "成人", "adult")
|
|
addConfigured("adult_9kg", "9KG", "adult")
|
|
addConfigured("adult_jav", "番号", "adult")
|
|
return out
|
|
}
|
|
|
|
func (o *OrganizerService) organizeLibraryRootForLayout(ctx context.Context, destRoot, mediaType, category string) (string, bool) {
|
|
if o == nil || o.repo == nil || o.repo.Library == nil {
|
|
return "", false
|
|
}
|
|
libraries, err := o.repo.Library.List(ctx)
|
|
if err != nil {
|
|
if o.log != nil {
|
|
o.log.Debug("list libraries for organize target failed", zap.Error(err))
|
|
}
|
|
return "", false
|
|
}
|
|
destRoot = filepath.Clean(strings.TrimSpace(destRoot))
|
|
mediaType = normalizeOrganizeMediaType(mediaType)
|
|
aliases := o.organizeCategoryAliases(mediaType, category)
|
|
|
|
bestPath := ""
|
|
bestScore := -1
|
|
bestDepth := -1
|
|
for _, lib := range libraries {
|
|
if !lib.Enabled || strings.TrimSpace(lib.Path) == "" {
|
|
continue
|
|
}
|
|
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
|
continue
|
|
}
|
|
if destRoot != "" && destRoot != "." && !pathWithin(lib.Path, destRoot) && !pathWithin(destRoot, lib.Path) {
|
|
continue
|
|
}
|
|
categoryMatch := len(aliases) > 0 && libraryMatchesOrganizeCategory(lib, aliases)
|
|
typeScore := organizeLibraryTypeScore(mediaType, lib.Type)
|
|
if len(aliases) > 0 {
|
|
if !categoryMatch {
|
|
continue
|
|
}
|
|
} else if typeScore <= 0 {
|
|
continue
|
|
}
|
|
score := typeScore
|
|
if categoryMatch {
|
|
score += 20
|
|
}
|
|
depth := pathDepth(lib.Path)
|
|
if score > bestScore || (score == bestScore && depth > bestDepth) {
|
|
bestScore = score
|
|
bestDepth = depth
|
|
bestPath = lib.Path
|
|
}
|
|
}
|
|
if bestPath == "" {
|
|
return "", false
|
|
}
|
|
return filepath.Clean(bestPath), true
|
|
}
|
|
|
|
func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, root, mediaType, category string) {
|
|
if o == nil || o.repo == nil || o.repo.Library == nil {
|
|
return
|
|
}
|
|
root = filepath.Clean(strings.TrimSpace(root))
|
|
if root == "" || root == "." {
|
|
return
|
|
}
|
|
if _, ok := ParseCloudLibraryMount(root); ok {
|
|
return
|
|
}
|
|
libraries, err := o.repo.Library.List(ctx)
|
|
if err != nil {
|
|
if o.log != nil {
|
|
o.log.Debug("list libraries before organize auto-create failed", zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
for _, lib := range libraries {
|
|
if !lib.Enabled || strings.TrimSpace(lib.Path) == "" {
|
|
continue
|
|
}
|
|
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
|
continue
|
|
}
|
|
if pathWithin(root, lib.Path) {
|
|
return
|
|
}
|
|
}
|
|
name := strings.TrimSpace(category)
|
|
if name == "" {
|
|
name = filepath.Base(root)
|
|
}
|
|
if name == "" || name == "." || name == string(os.PathSeparator) {
|
|
name = organizeLibraryTypeName(mediaType)
|
|
}
|
|
lib := model.Library{
|
|
Name: name,
|
|
Path: root,
|
|
Type: organizeLibraryModelType(mediaType),
|
|
Enabled: true,
|
|
}
|
|
if err := o.repo.Library.Create(ctx, &lib); err != nil {
|
|
if o.log != nil {
|
|
o.log.Warn("organize auto-create library failed",
|
|
zap.String("path", root),
|
|
zap.String("type", lib.Type),
|
|
zap.String("name", lib.Name),
|
|
zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
if o.log != nil {
|
|
o.log.Info("organize auto-created missing library",
|
|
zap.String("path", root),
|
|
zap.String("type", lib.Type),
|
|
zap.String("name", lib.Name))
|
|
}
|
|
}
|
|
|
|
func organizeLibraryModelType(mediaType string) string {
|
|
switch normalizeOrganizeMediaType(mediaType) {
|
|
case "tv", "anime", "variety":
|
|
return "tv"
|
|
case "adult", "movie":
|
|
return "movie"
|
|
default:
|
|
return "movie"
|
|
}
|
|
}
|
|
|
|
func organizeLibraryTypeName(mediaType string) string {
|
|
switch normalizeOrganizeMediaType(mediaType) {
|
|
case "tv":
|
|
return "电视剧"
|
|
case "anime":
|
|
return "动漫"
|
|
case "variety":
|
|
return "综艺"
|
|
case "adult":
|
|
return "成人"
|
|
default:
|
|
return "电影"
|
|
}
|
|
}
|
|
|
|
func (o *OrganizerService) organizeCategoryAliases(mediaType, category string) map[string]struct{} {
|
|
aliases := map[string]struct{}{}
|
|
add := func(values ...string) {
|
|
for _, value := range values {
|
|
key := normalizeOrganizeCategoryKey(value)
|
|
if key != "" {
|
|
aliases[key] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
categories := o.categoryMap()
|
|
add(category)
|
|
switch normalizeOrganizeCategoryKey(category) {
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "jp_anime", "日番")), "日番", "日漫", "日本动漫", "日本動畫", "日本动画":
|
|
add("日番", "日漫", "日本动漫", "日本动画")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "cn_anime", "国漫")), "国漫", "国产动漫", "國漫":
|
|
add("国漫", "国产动漫")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "domestic_tv", "国产剧")), "国产剧", "国剧", "大陆剧", "国产电视剧":
|
|
add("国产剧", "国剧", "大陆剧", "国产电视剧")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "euus_tv", "欧美剧")), "欧美剧", "欧美电视剧":
|
|
add("欧美剧", "欧美电视剧")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "jk_tv", "日韩剧")), "日韩剧", "日剧", "韩剧":
|
|
add("日韩剧", "日剧", "韩剧")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "variety", "综艺")), "综艺", "真人秀":
|
|
add("综艺", "真人秀")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "documentary", "纪录片")), "纪录片", "纪录":
|
|
add("纪录片", "纪录")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "children", "儿童")), "儿童", "少儿":
|
|
add("儿童", "少儿")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "chinese_movie", "华语电影")), "华语电影", "国产电影", "大陆电影":
|
|
add("华语电影", "国产电影", "大陆电影")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "foreign_movie", "外语电影")), "外语电影":
|
|
add("外语电影")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "animation_movie", "动画电影")), "动画电影", "动漫电影":
|
|
add("动画电影", "动漫电影")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "adult", "成人")), "成人":
|
|
add("成人")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "adult_9kg", "9KG")), "9kg":
|
|
add("9KG")
|
|
case normalizeOrganizeCategoryKey(categoryName(categories, "adult_jav", "番号")), "番号", "jav":
|
|
add("番号", "JAV")
|
|
}
|
|
return aliases
|
|
}
|
|
|
|
func libraryMatchesOrganizeCategory(lib model.Library, aliases map[string]struct{}) bool {
|
|
for _, value := range []string{lib.Name, filepath.Base(filepath.Clean(lib.Path))} {
|
|
if _, ok := aliases[normalizeOrganizeCategoryKey(value)]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func organizeLibraryTypeScore(mediaType, libraryType string) int {
|
|
libraryType = normalizeOrganizeMediaType(libraryType)
|
|
if mediaType == "" || libraryType == "" {
|
|
return 1
|
|
}
|
|
if mediaType == libraryType {
|
|
return 8
|
|
}
|
|
if mediaType == "anime" && libraryType == "tv" {
|
|
return 5
|
|
}
|
|
if mediaType == "variety" && libraryType == "tv" {
|
|
return 5
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func normalizeOrganizeCategoryKey(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
value = strings.ReplaceAll(value, " ", "")
|
|
value = strings.ReplaceAll(value, "_", "")
|
|
value = strings.ReplaceAll(value, "-", "")
|
|
return value
|
|
}
|
|
|
|
func pathDepth(path string) int {
|
|
path = filepath.Clean(path)
|
|
if path == "." || path == string(os.PathSeparator) {
|
|
return 0
|
|
}
|
|
return len(strings.Split(path, string(os.PathSeparator)))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// titleCaseWords upper-cases the first letter of each ASCII word; CJK and other
|
|
// non-ASCII leading characters are left untouched. Roman numerals (ii, iii, iv,
|
|
// …) are fully upper-cased so sequels like "Wandering Earth II" keep their
|
|
// canonical casing instead of becoming "Ii".
|
|
func titleCaseWords(s string) string {
|
|
fields := strings.Fields(s)
|
|
for i, w := range fields {
|
|
if isRomanNumeral(w) {
|
|
fields[i] = strings.ToUpper(w)
|
|
continue
|
|
}
|
|
r := []rune(w)
|
|
if len(r) > 0 && r[0] < 128 {
|
|
r[0] = unicode.ToUpper(r[0])
|
|
fields[i] = string(r)
|
|
}
|
|
}
|
|
return strings.Join(fields, " ")
|
|
}
|
|
|
|
// sequelNumerals is a conservative whitelist of multi-letter Roman numerals
|
|
// used for movie/series sequels. A whitelist avoids false positives on normal
|
|
// English words that happen to be valid numerals (e.g. "mix", "civ", "mi").
|
|
var sequelNumerals = map[string]struct{}{
|
|
"ii": {}, "iii": {}, "iv": {}, "vi": {}, "vii": {}, "viii": {},
|
|
"ix": {}, "xi": {}, "xii": {}, "xiii": {}, "xiv": {}, "xv": {},
|
|
}
|
|
|
|
func isRomanNumeral(w string) bool {
|
|
_, ok := sequelNumerals[strings.ToLower(w)]
|
|
return ok
|
|
}
|
|
|
|
// replaceVersions removes the existing lower-resolution files (and their NFO
|
|
// sidecars + DB rows) and transfers src into dst.
|
|
func (o *OrganizerService) replaceVersions(ctx context.Context, src string, existing []string, dst string, mode TransferMode) error {
|
|
for _, e := range existing {
|
|
if err := os.Remove(e); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("remove existing %s: %w", e, err)
|
|
}
|
|
if nfo := nfoPath(e); nfo != "" {
|
|
_ = os.Remove(nfo)
|
|
}
|
|
if o.repo != nil && o.repo.DB != nil {
|
|
_ = o.repo.DB.WithContext(ctx).Where("path = ?", e).Delete(&model.Media{}).Error
|
|
}
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users.
|
|
return err
|
|
}
|
|
if err := transferFile(src, dst, mode); err != nil {
|
|
return err
|
|
}
|
|
if err := transferSidecarNFO(src, dst, mode); err != nil {
|
|
o.log.Warn("organize sidecar nfo failed",
|
|
zap.String("from", src), zap.String("to", dst), zap.Error(err))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|