mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
690 lines
24 KiB
Go
690 lines
24 KiB
Go
// Package service — media file organizer (auto-rename + move).
|
|
//
|
|
// OrganizerService takes a source file (typically from a download
|
|
// completion) and moves/renames it into the configured library directory
|
|
// using a Jinja2-like template. The default templates:
|
|
//
|
|
// movie: {Title} ({Year})/{Title} ({Year}).{Ext}
|
|
// tv: {Title}/Season {Season:02d}/{Title} - S{Season:02d}E{Episode:02d}.{Ext}
|
|
//
|
|
// Organizing moves the source file. On the same filesystem this is an
|
|
// os.Rename; across filesystems it falls back to streaming copy + source
|
|
// removal, so operators should keep downloads and media on the same volume
|
|
// when they want instant moves and no temporary duplicate disk usage.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
|
)
|
|
|
|
// OrganizerService moves/renames files into library structures.
|
|
type OrganizerService struct {
|
|
cfg *config.Config
|
|
log *zap.Logger
|
|
repo *repository.Container
|
|
probe *FFprobeService // optional; used for 洗版 resolution comparison
|
|
scraper *ScraperService // optional; used to identify metadata before rename
|
|
}
|
|
|
|
// NewOrganizerService is the constructor.
|
|
func NewOrganizerService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *OrganizerService {
|
|
return &OrganizerService{cfg: cfg, log: log, repo: repo}
|
|
}
|
|
|
|
// SetProbe wires an FFprobe service so directory organize can compare real
|
|
// pixel dimensions when deciding whether to 洗版 (replace by higher resolution).
|
|
// Optional: when nil the organizer falls back to filename resolution tokens.
|
|
func (o *OrganizerService) SetProbe(p *FFprobeService) { o.probe = p }
|
|
|
|
// SetScraper wires the scraper so directory organize can resolve TMDb/Bangumi
|
|
// metadata before it decides the final folder and filename.
|
|
func (o *OrganizerService) SetScraper(s *ScraperService) { o.scraper = s }
|
|
|
|
// OrganizeResult reports what happened.
|
|
type OrganizeResult struct {
|
|
Organized int `json:"organized"`
|
|
Skipped int `json:"skipped"`
|
|
Replaced int `json:"replaced,omitempty"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
SourcePath string `json:"source_path,omitempty"`
|
|
DestPath string `json:"dest_path,omitempty"`
|
|
DryRun bool `json:"dry_run,omitempty"`
|
|
Items []OrganizePreviewItem `json:"items,omitempty"`
|
|
Scans []OrganizeScanSummary `json:"scans,omitempty"`
|
|
Scrapes []OrganizeScrapeSummary `json:"scrapes,omitempty"`
|
|
}
|
|
|
|
type OrganizePreviewItem struct {
|
|
Source string `json:"source"`
|
|
Target string `json:"target,omitempty"`
|
|
Action string `json:"action"` // organize / skip / replace / error
|
|
Reason string `json:"reason,omitempty"`
|
|
MediaType string `json:"media_type,omitempty"`
|
|
Category string `json:"category,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
}
|
|
|
|
// OrganizeOptions carries per-request overrides for an organize operation.
|
|
// 空值表示沿用系统设置中的默认值。
|
|
//
|
|
// 整理是「从源目录整理到目的地目录」:SourcePath 指定待整理文件所在的源目录,
|
|
// DestPath 指定整理输出的目的地目录。两者相互独立,不再混用同一个目录。
|
|
type OrganizeOptions struct {
|
|
// SourcePath 本次整理的源目录(待整理文件所在目录),覆盖 organize.source_dir
|
|
// 设置与媒体库路径。仅整理位于该目录下的媒体;留空表示整个媒体库。
|
|
SourcePath string
|
|
// DestPath 本次整理的目的地根路径(整理输出到哪里),覆盖 organize.target_dir 设置。
|
|
// 留空则使用设置中的默认目的地目录,再退回媒体库路径。
|
|
DestPath string
|
|
// TransferMode 本次整理的转移方式,覆盖 organize.transfer_mode 设置。
|
|
TransferMode TransferMode
|
|
// MediaType 手动整理时由 UI 指定的媒体类型。空值时按文件名/目录推断。
|
|
MediaType string
|
|
// MediaCategory 由订阅/下载任务或 UI 指定的分类。空值时按目录/NFO/规则推断。
|
|
MediaCategory string
|
|
// DryRun 仅生成整理预览,不实际移动/复制/硬链接文件。
|
|
DryRun bool
|
|
// AllowReplaceExisting 允许用本次来源替换目标库中已存在的同一媒体。
|
|
// 默认 false:只去重不洗版,避免未开启洗版的订阅/手动整理留下或替换出多份版本。
|
|
AllowReplaceExisting bool
|
|
}
|
|
|
|
// OrganizeMedia moves a single media file into the target library directory.
|
|
// It auto-detects whether the media is a movie or TV episode based on the
|
|
// parsed season/episode numbers and builds the destination path accordingly.
|
|
// When smart classify is enabled, it adds a category subfolder (e.g., "华语电影").
|
|
func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (string, error) {
|
|
return o.OrganizeMediaWithOptions(ctx, mediaID, OrganizeOptions{})
|
|
}
|
|
|
|
// OrganizeMediaWithOptions is OrganizeMedia with per-request overrides for the
|
|
// target path and transfer mode.
|
|
func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID string, opts OrganizeOptions) (string, error) {
|
|
m, err := o.repo.Media.FindByID(ctx, mediaID)
|
|
if err != nil || m == nil {
|
|
return "", errors.New("media not found")
|
|
}
|
|
lib, err := o.repo.Library.FindByID(ctx, m.LibraryID)
|
|
if err != nil || lib == nil {
|
|
return "", errors.New("library not found")
|
|
}
|
|
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
|
return "", errors.New("local organize cannot use cloud libraries directly; use external storage scan/mount for cloud media or enable cloud transfer to write to cloud")
|
|
}
|
|
baseRoot := redirectOrganizeStagingRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath))
|
|
if _, ok := ParseCloudLibraryMount(baseRoot); ok {
|
|
return "", errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
|
|
}
|
|
if !opts.DryRun {
|
|
if err := ensureOrganizeDestinationWritable(baseRoot); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
mode := o.resolveTransferMode(ctx, opts.TransferMode)
|
|
if isSeriesLibraryType(lib.Type) {
|
|
if err := o.refreshEpisodeIdentity(m, lib); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
ext := filepath.Ext(m.Path)
|
|
title := sanitizeFilename(m.Title)
|
|
if title == "" {
|
|
title = "Unknown"
|
|
}
|
|
|
|
// Determine category folder (if smart classify is enabled)
|
|
category := o.SmartClassify(ctx, m)
|
|
|
|
var dst string
|
|
if isSeriesLibraryType(lib.Type) {
|
|
root := o.organizeRoot(baseRoot, lib.Type, category)
|
|
target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
|
|
Root: categoryRoot(root, category),
|
|
MediaType: lib.Type,
|
|
Category: category,
|
|
Title: title,
|
|
Source: m.Path,
|
|
Ext: ext,
|
|
Year: m.Year,
|
|
Season: m.SeasonNum,
|
|
Episode: m.EpisodeNum,
|
|
Series: true,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dst = target.Path
|
|
} else {
|
|
root := o.organizeRoot(baseRoot, lib.Type, category)
|
|
target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
|
|
Root: categoryRoot(root, category),
|
|
MediaType: lib.Type,
|
|
Category: category,
|
|
Title: title,
|
|
Source: m.Path,
|
|
Ext: ext,
|
|
Year: m.Year,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dst = target.Path
|
|
}
|
|
|
|
// Skip if already in place.
|
|
if m.Path == dst {
|
|
return dst, nil
|
|
}
|
|
|
|
// Refuse to overwrite an existing different file. 当多个 release(如
|
|
// 不同字幕组、不同源)刮削后被统一改名,原本不重复的文件会被映射到
|
|
// 同一个目标路径,盲目 move 会导致后者覆盖前者,造成数据丢失。
|
|
if _, err := os.Stat(dst); err == nil {
|
|
o.log.Warn("organize skipped: destination already exists",
|
|
zap.String("media", m.ID),
|
|
zap.String("from", m.Path),
|
|
zap.String("to", dst))
|
|
return dst, nil
|
|
}
|
|
|
|
// Create directories.
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users.
|
|
return "", err
|
|
}
|
|
|
|
// Transfer the file according to the resolved mode. move 删除源;
|
|
// copy/hardlink/symlink 保留源文件,从而让下载器可继续做种。
|
|
if err := transferFile(m.Path, dst, mode); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Update the database row.
|
|
if err := o.repo.DB.WithContext(ctx).
|
|
Model(&model.Media{}).
|
|
Where("id = ?", m.ID).
|
|
Updates(map[string]any{
|
|
"path": dst,
|
|
"season_num": m.SeasonNum,
|
|
"episode_num": m.EpisodeNum,
|
|
}).Error; err != nil {
|
|
return dst, err
|
|
}
|
|
if err := transferSidecarNFO(m.Path, dst, mode); err != nil {
|
|
o.log.Warn("organize sidecar nfo failed",
|
|
zap.String("media", m.ID),
|
|
zap.String("from", nfoPath(m.Path)),
|
|
zap.String("to", nfoPath(dst)),
|
|
zap.Error(err))
|
|
}
|
|
o.log.Info("organized",
|
|
zap.String("media", m.ID),
|
|
zap.String("from", m.Path),
|
|
zap.String("to", dst),
|
|
zap.String("category", category),
|
|
zap.String("mode", string(mode)),
|
|
)
|
|
return dst, nil
|
|
}
|
|
|
|
// resolveBaseRoot picks the organize destination root (目的地目录): a
|
|
// per-request override wins, then the organize.target_dir setting, then the
|
|
// library's own path.
|
|
func (o *OrganizerService) resolveBaseRoot(ctx context.Context, lib *model.Library, 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 lib.Path
|
|
}
|
|
|
|
// resolveSourceRoot picks the organize source root (源目录,待整理文件所在目录):
|
|
// a per-request override wins, then the organize.source_dir setting, then the
|
|
// library's own path. Library organize only touches media located under this
|
|
// root, so operators can point at a specific download/staging folder.
|
|
func (o *OrganizerService) resolveSourceRoot(ctx context.Context, lib *model.Library, 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.source_dir"); err == nil && strings.TrimSpace(v) != "" {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return lib.Path
|
|
}
|
|
|
|
// resolveTransferMode picks the transfer mode: a per-request override wins,
|
|
// otherwise the organize.transfer_mode setting (default move). When the
|
|
// effective mode is move and 做种保种 (organize.keep_seeding) is enabled, it is
|
|
// upgraded to hardlink so the source stays in place for the torrent client.
|
|
func (o *OrganizerService) resolveTransferMode(ctx context.Context, override TransferMode) TransferMode {
|
|
mode := override
|
|
if mode == "" {
|
|
mode = TransferMove
|
|
if o.repo != nil && o.repo.Setting != nil {
|
|
if v, err := o.repo.Setting.Get(ctx, "organize.transfer_mode"); err == nil && strings.TrimSpace(v) != "" {
|
|
mode = parseTransferMode(v)
|
|
}
|
|
}
|
|
}
|
|
if mode == TransferMove && o.keepSeedingEnabled(ctx) {
|
|
// 移动会删除源文件导致 qBittorrent 停止做种;保种开启时改用硬链接
|
|
// 既规范命名又保留源文件继续做种上传。硬链接失败时会报错,避免静默
|
|
// 退化复制后占用双份磁盘空间。
|
|
return TransferHardlink
|
|
}
|
|
return mode
|
|
}
|
|
|
|
// keepSeedingEnabled reports whether 做种保种 is on. Defaults to true so an
|
|
// unconfigured instance never silently breaks seeding on organize.
|
|
func (o *OrganizerService) keepSeedingEnabled(ctx context.Context) bool {
|
|
if o.repo == nil || o.repo.Setting == nil {
|
|
return true
|
|
}
|
|
v, err := o.repo.Setting.Get(ctx, "organize.keep_seeding")
|
|
if err != nil || strings.TrimSpace(v) == "" {
|
|
return true
|
|
}
|
|
return v == "true" || v == "1" || v == "on"
|
|
}
|
|
|
|
// OrganizeLibrary organizes every media row in a library whose file is
|
|
// not already in the expected path structure.
|
|
func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string) (*OrganizeResult, error) {
|
|
return o.OrganizeLibraryWithOptions(ctx, libraryID, OrganizeOptions{})
|
|
}
|
|
|
|
// OrganizeLibraryWithOptions is OrganizeLibrary with per-request overrides for
|
|
// the target path and transfer mode.
|
|
func (o *OrganizerService) OrganizeLibraryWithOptions(ctx context.Context, libraryID string, opts OrganizeOptions) (*OrganizeResult, error) {
|
|
lib, err := o.repo.Library.FindByID(ctx, libraryID)
|
|
if err != nil || lib == nil {
|
|
return nil, errors.New("library not found")
|
|
}
|
|
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
|
return nil, errors.New("local organize cannot use cloud libraries directly; use external storage scan/mount for cloud media or enable cloud transfer to write to cloud")
|
|
}
|
|
var rows []model.Media
|
|
if err := o.repo.DB.WithContext(ctx).
|
|
Where("library_id = ? AND deleted_at IS NULL", libraryID).
|
|
Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
// 源目录(待整理):仅整理位于该目录下的媒体;留空 = 整个媒体库。
|
|
sourceRoot := o.resolveSourceRoot(ctx, lib, opts.SourcePath)
|
|
if _, ok := ParseCloudLibraryMount(sourceRoot); ok {
|
|
return nil, errors.New("organize source must be a local directory; cloud libraries should be managed from external storage scan/mount")
|
|
}
|
|
// 目的地目录:已位于该根下的文件视为已整理;受 dest_path 覆盖与设置影响。
|
|
baseRoot := redirectOrganizeStagingRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath))
|
|
if _, ok := ParseCloudLibraryMount(baseRoot); ok {
|
|
return nil, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
|
|
}
|
|
if !opts.DryRun {
|
|
if err := ensureOrganizeDestinationWritable(baseRoot); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
res := &OrganizeResult{SourcePath: sourceRoot, DestPath: baseRoot, DryRun: opts.DryRun}
|
|
for i := range rows {
|
|
// 不在源目录内的文件跳过(不属于本次「从源目录整理」的范围)。
|
|
if !pathWithin(rows[i].Path, sourceRoot) {
|
|
res.Skipped++
|
|
continue
|
|
}
|
|
if pathWithin(rows[i].Path, baseRoot) {
|
|
res.Skipped++
|
|
continue
|
|
}
|
|
dst, err := o.OrganizeMediaWithOptions(ctx, rows[i].ID, opts)
|
|
if err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", rows[i].Title, err.Error()))
|
|
continue
|
|
}
|
|
if dst == rows[i].Path {
|
|
res.Skipped++
|
|
} else {
|
|
res.Organized++
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (o *OrganizerService) refreshEpisodeIdentity(m *model.Media, lib *model.Library) error {
|
|
season, episode := m.SeasonNum, m.EpisodeNum
|
|
|
|
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedSeason > 0 || parsedEpisode > 0 {
|
|
season = parsedSeason
|
|
if parsedEpisode > 0 {
|
|
episode = parsedEpisode
|
|
}
|
|
}
|
|
|
|
if local, err := ReadLocalMetadata(m.Path, lib.Path, true); err == nil && local != nil {
|
|
if local.SeasonNum > 0 || local.EpisodeNum > 0 {
|
|
season = local.SeasonNum
|
|
}
|
|
if local.EpisodeNum > 0 {
|
|
episode = local.EpisodeNum
|
|
}
|
|
} else if err != nil {
|
|
o.log.Warn("organize read local metadata failed", zap.String("path", m.Path), zap.Error(err))
|
|
}
|
|
|
|
if season < 0 || episode <= 0 {
|
|
return fmt.Errorf("cannot determine season/episode for %s", m.Path)
|
|
}
|
|
m.SeasonNum = season
|
|
m.EpisodeNum = episode
|
|
return nil
|
|
}
|
|
|
|
// moveFile tries os.Rename first (instant on same fs), then falls back
|
|
// to copy + remove for cross-device moves.
|
|
//
|
|
// 重要:如果 dst 已经存在,moveFile 会直接报错而不是覆盖。OrganizeMedia
|
|
// 已经在调用前做过 stat 检查,这里是第二道防线。
|
|
func moveFile(src, dst string) error {
|
|
if _, err := os.Stat(dst); err == nil {
|
|
return fmt.Errorf("destination already exists: %s", dst)
|
|
}
|
|
if err := os.Rename(src, dst); err == nil {
|
|
return nil
|
|
}
|
|
// Cross-device: stream copy → remove. This can temporarily consume the
|
|
// destination file size while copying, but the source is removed after the
|
|
// copy succeeds.
|
|
in, err := os.Open(src) // #nosec G304 -- src is selected from configured media/download roots by the organizer.
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
// O_EXCL 保证不会覆盖已存在的目标。
|
|
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) // #nosec G304,G302 -- dst is organizer-generated; media files must remain readable by local players.
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, werr := io.Copy(f, in); werr != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(dst)
|
|
return werr
|
|
}
|
|
if cerr := f.Close(); cerr != nil {
|
|
return cerr
|
|
}
|
|
return os.Remove(src)
|
|
}
|
|
|
|
// transferSidecarNFO moves/copies/links the .nfo sidecar alongside its media
|
|
// using the same transfer mode, so metadata follows the organized file.
|
|
func transferSidecarNFO(srcMedia, dstMedia string, mode TransferMode) error {
|
|
src := nfoPath(srcMedia)
|
|
dst := nfoPath(dstMedia)
|
|
if src == dst {
|
|
return nil
|
|
}
|
|
if _, err := os.Stat(src); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if _, err := os.Stat(dst); err == nil {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users.
|
|
return err
|
|
}
|
|
return transferFile(src, dst, mode)
|
|
}
|
|
|
|
// sanitizeFilename removes characters not safe for filesystem names.
|
|
func sanitizeFilename(s string) string {
|
|
r := strings.NewReplacer(
|
|
"/", " ", "\\", " ", ":", " ", "*", "", "?", "",
|
|
"\"", "", "<", "", ">", "", "|", "",
|
|
)
|
|
return strings.TrimSpace(r.Replace(s))
|
|
}
|
|
|
|
func (o *OrganizerService) organizeRoot(libraryPath, mediaType, category string) string {
|
|
typeDir := o.mediaTypeRootDirForCategory(mediaType, category)
|
|
if typeDir == "" || pathAlreadyEndsWith(libraryPath, typeDir) {
|
|
return libraryPath
|
|
}
|
|
if isGenericMediaRoot(libraryPath) {
|
|
return filepath.Join(libraryPath, typeDir)
|
|
}
|
|
return libraryPath
|
|
}
|
|
|
|
func (o *OrganizerService) mediaTypeRootDirForCategory(mediaType, category string) string {
|
|
if root := o.categoryPhysicalRootDir(category); root != "" {
|
|
return root
|
|
}
|
|
return mediaTypeRootDir(mediaType)
|
|
}
|
|
|
|
func (o *OrganizerService) categoryPhysicalRootDir(category string) string {
|
|
key := normalizeOrganizeCategoryKey(category)
|
|
if key == "" {
|
|
return ""
|
|
}
|
|
categories := o.categoryMap()
|
|
match := func(values ...string) bool {
|
|
for _, value := range values {
|
|
if key == normalizeOrganizeCategoryKey(value) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
switch {
|
|
case match(
|
|
categoryName(categories, "cn_anime", "国漫"),
|
|
categoryName(categories, "jp_anime", "日番"),
|
|
categoryName(categories, "children", "儿童"),
|
|
"国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "儿童", "少儿",
|
|
):
|
|
return "动漫"
|
|
case match(
|
|
categoryName(categories, "domestic_tv", "国产剧"),
|
|
categoryName(categories, "euus_tv", "欧美剧"),
|
|
categoryName(categories, "jk_tv", "日韩剧"),
|
|
categoryName(categories, "variety", "综艺"),
|
|
categoryName(categories, "documentary", "纪录片"),
|
|
categoryName(categories, "uncategorized_tv", "未分类"),
|
|
"国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "纪录片", "纪录", "未分类",
|
|
):
|
|
return "电视剧"
|
|
case match(
|
|
categoryName(categories, "animation_movie", "动画电影"),
|
|
categoryName(categories, "chinese_movie", "华语电影"),
|
|
categoryName(categories, "foreign_movie", "外语电影"),
|
|
categoryName(categories, "euus_movie", "欧美电影"),
|
|
categoryName(categories, "jk_movie", "日韩电影"),
|
|
"动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "欧美电影", "日韩电影",
|
|
):
|
|
return "电影"
|
|
case match(categoryName(categories, "adult", "成人"), categoryName(categories, "adult_9kg", "9KG"), categoryName(categories, "adult_jav", "番号"), "成人", "9kg", "番号", "jav"):
|
|
return "成人"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func categoryRoot(root, category string) string {
|
|
if strings.TrimSpace(category) == "" || pathAlreadyEndsWith(root, category) {
|
|
return root
|
|
}
|
|
return filepath.Join(root, category)
|
|
}
|
|
|
|
func pathWithin(path, root string) bool {
|
|
cleanPath := filepath.Clean(path)
|
|
cleanRoot := filepath.Clean(root)
|
|
if strings.EqualFold(cleanPath, cleanRoot) {
|
|
return true
|
|
}
|
|
rel, err := filepath.Rel(cleanRoot, cleanPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
func mediaTypeRootDir(mediaType string) string {
|
|
switch normalizeMediaType(mediaType, "", "") {
|
|
case "movie":
|
|
return "电影"
|
|
case "anime":
|
|
return "动漫"
|
|
case "tv", "variety":
|
|
return "电视剧"
|
|
case "adult":
|
|
return "成人"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func isGenericMediaRoot(path string) bool {
|
|
base := strings.ToLower(strings.TrimSpace(filepath.Base(filepath.Clean(path))))
|
|
switch base {
|
|
case "media", "medias", "library", "libraries", "organized", "整理":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// organizeStagingFolderNames 列出"手动整理"类暂存目录名。这些目录只是修正错误
|
|
// 入库时的中转工作区,不能作为一级分类目录留存。整理时若目标根落在这类目录
|
|
// 内,应重定向到其父级媒体根,让媒体真正归入 电影/电视剧/动漫/成人 的二级分类
|
|
// 目录中(如 媒体/电影/华语电影/片名),而不是停留在暂存目录下。
|
|
func organizeStagingFolderNames() map[string]struct{} {
|
|
return map[string]struct{}{
|
|
"手动整理": {}, "手动整理入库": {}, "待整理": {}, "待分类": {},
|
|
"manual": {}, "manual_organize": {}, "manualorganize": {}, "staging": {}, "inbox": {},
|
|
}
|
|
}
|
|
|
|
func isOrganizeStagingDir(path string) bool {
|
|
base := strings.ToLower(strings.TrimSpace(filepath.Base(filepath.Clean(path))))
|
|
if base == "" {
|
|
return false
|
|
}
|
|
_, ok := organizeStagingFolderNames()[base]
|
|
return ok
|
|
}
|
|
|
|
// redirectOrganizeStagingRoot 把"手动整理"类暂存目录的目标根重定向到父级媒体根。
|
|
// 连续多层暂存目录(如 .../media/手动整理/待整理)会被逐层上提到真正的媒体根,
|
|
// 随后分类逻辑会补上 电影/电视剧/动漫/成人 等顶层与二级分类。
|
|
func redirectOrganizeStagingRoot(root string) string {
|
|
cleaned := filepath.Clean(strings.TrimSpace(root))
|
|
if cleaned == "" || cleaned == "." {
|
|
return root
|
|
}
|
|
for isOrganizeStagingDir(cleaned) {
|
|
parent := filepath.Dir(cleaned)
|
|
if parent == cleaned || parent == "." || parent == string(filepath.Separator) {
|
|
break
|
|
}
|
|
cleaned = parent
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
func pathAlreadyEndsWith(path, suffix string) bool {
|
|
base := strings.TrimSpace(filepath.Base(filepath.Clean(path)))
|
|
return strings.EqualFold(base, suffix)
|
|
}
|
|
|
|
func isSeriesLibraryType(mediaType string) bool {
|
|
switch normalizeMediaType(mediaType, "", "") {
|
|
case "tv", "anime", "variety":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// isSmartClassifyEnabled checks if smart classify is enabled.
|
|
// It first checks the database setting, then falls back to config.yaml.
|
|
func (o *OrganizerService) isSmartClassifyEnabled(ctx context.Context) bool {
|
|
// Try database first
|
|
if o.repo != nil && o.repo.Setting != nil {
|
|
val, err := o.repo.Setting.Get(ctx, "organizer.smart_classify")
|
|
if err == nil && val != "" {
|
|
return val == "true" || val == "1" || val == "on"
|
|
}
|
|
}
|
|
// Fallback to config.yaml
|
|
if o == nil || o.cfg == nil {
|
|
return false
|
|
}
|
|
return o.cfg.Organizer.SmartClassify
|
|
}
|
|
|
|
// SmartClassify determines the subcategory folder based on media metadata.
|
|
// It returns the category folder name (e.g., "华语电影", "欧美剧", "日番").
|
|
// Returns empty string if smart classify is disabled or metadata is insufficient.
|
|
func (o *OrganizerService) SmartClassify(ctx context.Context, m *model.Media) string {
|
|
// Check if smart classify is enabled (from database first, then config)
|
|
smartClassify := o.isSmartClassifyEnabled(ctx)
|
|
if !smartClassify {
|
|
return ""
|
|
}
|
|
|
|
// Determine media type from library
|
|
lib, err := o.repo.Library.FindByID(ctx, m.LibraryID)
|
|
if err != nil || lib == nil {
|
|
return ""
|
|
}
|
|
return o.classifyMedia(ctx, m, lib.Type)
|
|
}
|
|
|
|
// parseCommaList splits a comma-separated string into a slice of trimmed strings.
|
|
func parseCommaList(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
trimmed := strings.TrimSpace(p)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// contains checks if a string slice contains a specific string.
|
|
func contains(slice []string, s string) bool {
|
|
for _, v := range slice {
|
|
if v == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|