Files
MeBox/internal/service/organizer.go
T
2026-06-27 00:40:39 +08:00

196 lines
7.4 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"
"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
activeDownloadPaths func(context.Context) []string
}
// 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 }
// SetActiveDownloadPathProvider wires a live downloader snapshot. Directory
// organize must never move/copy files that still belong to an unfinished
// torrent, regardless of which UI switch triggered the organize operation.
func (o *OrganizerService) SetActiveDownloadPathProvider(provider func(context.Context) []string) {
o.activeDownloadPaths = provider
}
func (o *OrganizerService) SetActiveDownloadProvider(provider func(context.Context) []QBitTorrent) {
if provider == nil {
o.activeDownloadPaths = nil
return
}
o.activeDownloadPaths = func(ctx context.Context) []string {
return activeDownloadPathCandidates(provider(ctx), nil)
}
}
// 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) {
req, err := o.resolveOrganizeMediaRequest(ctx, mediaID, opts)
if err != nil {
return "", err
}
dst, err := o.buildOrganizeMediaDestination(ctx, req)
if err != nil {
return "", err
}
// Skip if already in place.
if req.media.Path == dst.path {
return dst.path, nil
}
if req.dryRun {
return dst.path, nil
}
return o.applyOrganizeMedia(ctx, req, dst)
}
// 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 := normalizeOrganizeDestinationRoot(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 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++
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
}