mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
176 lines
5.5 KiB
Go
176 lines
5.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
|
)
|
|
|
|
type organizeMediaRequest struct {
|
|
media *model.Media
|
|
library *model.Library
|
|
baseRoot string
|
|
mediaType string
|
|
mediaCategory string
|
|
dryRun bool
|
|
transferMode TransferMode
|
|
}
|
|
|
|
type organizeMediaDestination struct {
|
|
path string
|
|
libraryID string
|
|
mediaType string
|
|
category string
|
|
}
|
|
|
|
func (o *OrganizerService) resolveOrganizeMediaRequest(ctx context.Context, mediaID string, opts OrganizeOptions) (organizeMediaRequest, error) {
|
|
m, err := o.repo.Media.FindByID(ctx, mediaID)
|
|
if err != nil || m == nil {
|
|
return organizeMediaRequest{}, errors.New("media not found")
|
|
}
|
|
lib, err := o.repo.Library.FindByID(ctx, m.LibraryID)
|
|
if err != nil || lib == nil {
|
|
return organizeMediaRequest{}, errors.New("library not found")
|
|
}
|
|
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
|
return organizeMediaRequest{}, 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 organizeMediaRequest{}, 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 organizeMediaRequest{}, err
|
|
}
|
|
}
|
|
if isSeriesLibraryType(lib.Type) {
|
|
if err := o.refreshEpisodeIdentity(m, lib); err != nil {
|
|
return organizeMediaRequest{}, err
|
|
}
|
|
}
|
|
return organizeMediaRequest{
|
|
media: m,
|
|
library: lib,
|
|
baseRoot: baseRoot,
|
|
mediaType: strings.TrimSpace(opts.MediaType),
|
|
mediaCategory: strings.TrimSpace(opts.MediaCategory),
|
|
dryRun: opts.DryRun,
|
|
transferMode: o.resolveTransferMode(ctx, opts.TransferMode),
|
|
}, nil
|
|
}
|
|
|
|
func (o *OrganizerService) buildOrganizeMediaDestination(ctx context.Context, req organizeMediaRequest) (organizeMediaDestination, error) {
|
|
m := req.media
|
|
lib := req.library
|
|
title := sanitizeFilename(m.Title)
|
|
if title == "" {
|
|
title = "Unknown"
|
|
}
|
|
|
|
mediaType := normalizeOrganizeMediaType(req.mediaType)
|
|
if mediaType == "" {
|
|
mediaType = normalizeOrganizeMediaType(lib.Type)
|
|
}
|
|
category := sanitizeFilename(strings.TrimSpace(req.mediaCategory))
|
|
if category == "" && o.isSmartClassifyEnabled(ctx) {
|
|
category = o.classifyMedia(ctx, m, mediaType)
|
|
}
|
|
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" {
|
|
mediaType = impliedType
|
|
category = normalizedCategory
|
|
}
|
|
root := o.organizeRoot(req.baseRoot, mediaType, category)
|
|
targetLibraryID := ""
|
|
matchedLibrary := false
|
|
if category != "" {
|
|
targetLibrary, ok := o.organizeLibraryForLayout(ctx, req.baseRoot, mediaType, category)
|
|
if ok {
|
|
root = targetLibrary.Path
|
|
targetLibraryID = targetLibrary.ID
|
|
matchedLibrary = true
|
|
}
|
|
}
|
|
if !matchedLibrary && category != "" {
|
|
root = categoryRoot(root, category)
|
|
}
|
|
if !matchedLibrary && !req.dryRun {
|
|
o.ensureOrganizeLibraryForRoot(ctx, root, mediaType, category)
|
|
}
|
|
target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
|
|
Root: root,
|
|
MediaType: mediaType,
|
|
Category: category,
|
|
Title: title,
|
|
Source: m.Path,
|
|
Ext: filepath.Ext(m.Path),
|
|
Year: m.Year,
|
|
Season: m.SeasonNum,
|
|
Episode: m.EpisodeNum,
|
|
Series: isSeriesLibraryType(mediaType),
|
|
})
|
|
if err != nil {
|
|
return organizeMediaDestination{}, err
|
|
}
|
|
return organizeMediaDestination{path: target.Path, libraryID: targetLibraryID, mediaType: mediaType, category: category}, nil
|
|
}
|
|
|
|
func (o *OrganizerService) applyOrganizeMedia(ctx context.Context, req organizeMediaRequest, dst organizeMediaDestination) (string, error) {
|
|
m := req.media
|
|
|
|
// Refuse to overwrite an existing different file. 当多个 release(如
|
|
// 不同字幕组、不同源)刮削后被统一改名,原本不重复的文件会被映射到
|
|
// 同一个目标路径,盲目 move 会导致后者覆盖前者,造成数据丢失。
|
|
if _, err := os.Stat(dst.path); err == nil {
|
|
o.log.Warn("organize skipped: destination already exists",
|
|
zap.String("media", m.ID),
|
|
zap.String("from", m.Path),
|
|
zap.String("to", dst.path))
|
|
return dst.path, nil
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dst.path), 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users.
|
|
return "", err
|
|
}
|
|
if err := transferFile(m.Path, dst.path, req.transferMode); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
updates := map[string]any{
|
|
"path": dst.path,
|
|
"season_num": m.SeasonNum,
|
|
"episode_num": m.EpisodeNum,
|
|
}
|
|
if strings.TrimSpace(dst.libraryID) != "" {
|
|
updates["library_id"] = strings.TrimSpace(dst.libraryID)
|
|
}
|
|
if err := o.repo.DB.WithContext(ctx).
|
|
Model(&model.Media{}).
|
|
Where("id = ?", m.ID).
|
|
Updates(updates).Error; err != nil {
|
|
return dst.path, err
|
|
}
|
|
if err := transferSidecarNFO(m.Path, dst.path, req.transferMode); 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.path)),
|
|
zap.Error(err))
|
|
}
|
|
o.log.Info("organized",
|
|
zap.String("media", m.ID),
|
|
zap.String("from", m.Path),
|
|
zap.String("to", dst.path),
|
|
zap.String("category", dst.category),
|
|
zap.String("media_type", dst.mediaType),
|
|
zap.String("mode", string(req.transferMode)),
|
|
)
|
|
return dst.path, nil
|
|
}
|