mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 13:06:36 +08:00
refactor: split modules and harden scraping workflows
This commit is contained in:
+18
-530
@@ -17,10 +17,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -52,55 +48,6 @@ func (o *OrganizerService) SetProbe(p *FFprobeService) { o.probe = p }
|
||||
// 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.
|
||||
@@ -112,197 +59,24 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s
|
||||
// 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")
|
||||
req, err := o.resolveOrganizeMediaRequest(ctx, mediaID, opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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
|
||||
dst, err := o.buildOrganizeMediaDestination(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Skip if already in place.
|
||||
if m.Path == dst {
|
||||
return dst, nil
|
||||
if req.media.Path == dst.path {
|
||||
return dst.path, nil
|
||||
}
|
||||
if req.dryRun {
|
||||
return dst.path, 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"
|
||||
return o.applyOrganizeMedia(ctx, req, dst)
|
||||
}
|
||||
|
||||
// OrganizeLibrary organizes every media row in a library whose file is
|
||||
@@ -344,6 +118,12 @@ func (o *OrganizerService) OrganizeLibraryWithOptions(ctx context.Context, libra
|
||||
}
|
||||
res := &OrganizeResult{SourcePath: sourceRoot, DestPath: baseRoot, DryRun: opts.DryRun}
|
||||
for i := range rows {
|
||||
if changed, err := o.reclassifyScannedMedia(ctx, rows[i], *lib, opts.DryRun, res); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", rows[i].Title, err.Error()))
|
||||
continue
|
||||
} else if changed {
|
||||
continue
|
||||
}
|
||||
// 不在源目录内的文件跳过(不属于本次「从源目录整理」的范围)。
|
||||
if !pathWithin(rows[i].Path, sourceRoot) {
|
||||
res.Skipped++
|
||||
@@ -395,295 +175,3 @@ func (o *OrganizerService) refreshEpisodeIdentity(m *model.Media, lib *model.Lib
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user