mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +08:00
494 lines
14 KiB
Go
494 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
|
)
|
|
|
|
type OrganizeScope string
|
|
|
|
const (
|
|
OrganizeScopeDirectory OrganizeScope = "directory"
|
|
OrganizeScopeMedia OrganizeScope = "media"
|
|
OrganizeScopeLibrary OrganizeScope = "library"
|
|
)
|
|
|
|
type OrganizeTrigger string
|
|
|
|
const (
|
|
OrganizeTriggerManual OrganizeTrigger = "manual"
|
|
OrganizeTriggerScheduled OrganizeTrigger = "scheduled"
|
|
OrganizeTriggerDownload OrganizeTrigger = "download"
|
|
)
|
|
|
|
// OrganizePipelineRequest is the single service-facing entry point for every
|
|
// organize → rename → scan → scrape ingest workflow. Handlers, scheduler jobs
|
|
// and download-complete hooks should only decide the trigger/scope/options;
|
|
// the pipeline owns the execution order and task reporting.
|
|
type OrganizePipelineRequest struct {
|
|
Scope OrganizeScope
|
|
Trigger OrganizeTrigger
|
|
TaskName string
|
|
MediaID string
|
|
LibraryID string
|
|
PreferredLibraryID string
|
|
SourcePath string
|
|
DestPath string
|
|
TransferMode string
|
|
MediaType string
|
|
MediaCategory string
|
|
ScanAfter *bool
|
|
ScrapeAfter *bool
|
|
DryRun bool
|
|
AllowReplace bool
|
|
}
|
|
|
|
type OrganizePipelineResponse struct {
|
|
Path string
|
|
Result *OrganizeResult
|
|
}
|
|
|
|
type OrganizePipelineService struct {
|
|
log *zap.Logger
|
|
repo *repository.Container
|
|
organizer *OrganizerService
|
|
scanner *ScannerService
|
|
tasks *TaskTrackerService
|
|
}
|
|
|
|
func NewOrganizePipelineService(log *zap.Logger, repo *repository.Container, organizer *OrganizerService, scanner *ScannerService, tasks *TaskTrackerService) *OrganizePipelineService {
|
|
return &OrganizePipelineService{
|
|
log: log,
|
|
repo: repo,
|
|
organizer: organizer,
|
|
scanner: scanner,
|
|
tasks: tasks,
|
|
}
|
|
}
|
|
|
|
func (p *OrganizePipelineService) Run(ctx context.Context, req OrganizePipelineRequest) (*OrganizePipelineResponse, error) {
|
|
if p == nil || p.organizer == nil {
|
|
return nil, errors.New("organize pipeline unavailable")
|
|
}
|
|
opts := OrganizeOptions{
|
|
SourcePath: strings.TrimSpace(req.SourcePath),
|
|
DestPath: strings.TrimSpace(req.DestPath),
|
|
MediaType: strings.TrimSpace(req.MediaType),
|
|
MediaCategory: strings.TrimSpace(req.MediaCategory),
|
|
DryRun: req.DryRun,
|
|
AllowReplaceExisting: req.AllowReplace,
|
|
}
|
|
if mode := strings.TrimSpace(req.TransferMode); mode != "" {
|
|
opts.TransferMode = TransferMode(mode)
|
|
}
|
|
task := p.startTask(ctx, req, opts)
|
|
|
|
response := &OrganizePipelineResponse{}
|
|
res, path, err := p.runOrganize(ctx, req, opts)
|
|
if err != nil {
|
|
p.finishTask(task, err, "organize", p.failureMessage(req), nil)
|
|
p.recordProblem(ctx, req, nil, err)
|
|
return nil, err
|
|
}
|
|
response.Path = path
|
|
response.Result = res
|
|
|
|
if fatalErr := organizeFatalResultError(res); fatalErr != nil {
|
|
p.logOrganizeProblem(req, res, fatalErr)
|
|
p.finishTask(task, fatalErr, "organize", p.failureMessage(req), res)
|
|
p.recordProblem(ctx, req, res, fatalErr)
|
|
return nil, fatalErr
|
|
}
|
|
if res != nil && len(res.Errors) > 0 {
|
|
p.logOrganizeProblem(req, res, nil)
|
|
p.recordProblem(ctx, req, res, nil)
|
|
}
|
|
|
|
if task != nil {
|
|
task.Update(TaskUpdate{
|
|
Stage: "organize",
|
|
SourcePath: res.SourcePath,
|
|
DestPath: firstNonEmpty(res.DestPath, filepath.Dir(path)),
|
|
Message: "整理/重命名完成,准备扫描入库",
|
|
Metrics: OrganizeTaskMetrics(res),
|
|
Details: OrganizeTaskDetails(res, 8),
|
|
})
|
|
}
|
|
|
|
if p.shouldScan(req, res) {
|
|
if task != nil {
|
|
task.Update(TaskUpdate{
|
|
Stage: "scan_scrape",
|
|
Message: "正在扫描入库并按设置刮削",
|
|
Metrics: OrganizeTaskMetrics(res),
|
|
Details: OrganizeTaskDetails(res, 8),
|
|
})
|
|
}
|
|
scanRoot := organizeScanRoot(res, path)
|
|
if scanRoot == "" && strings.TrimSpace(path) != "" {
|
|
scanRoot = filepath.Dir(path)
|
|
}
|
|
preferredLibraryID := strings.TrimSpace(req.PreferredLibraryID)
|
|
if preferredLibraryID == "" && req.Scope == OrganizeScopeLibrary {
|
|
preferredLibraryID = strings.TrimSpace(req.LibraryID)
|
|
}
|
|
res.Scans, res.Scrapes = p.scanner.ScanAndScrapeLibrariesForPath(ctx, scanRoot, preferredLibraryID, p.scrapeAfter(ctx, req))
|
|
} else if p.log != nil && res != nil && !req.DryRun {
|
|
p.log.Info("organize pipeline skipped scan; no destination changes",
|
|
zap.String("trigger", string(req.Trigger)),
|
|
zap.String("scope", string(req.Scope)),
|
|
zap.String("source", res.SourcePath),
|
|
zap.String("dest", res.DestPath),
|
|
zap.Int("organized", res.Organized),
|
|
zap.Int("replaced", res.Replaced),
|
|
zap.Int("reclassified", res.Reclassified),
|
|
zap.Int("skipped", res.Skipped))
|
|
}
|
|
|
|
p.finishTask(task, nil, "completed", p.completedMessage(req), res)
|
|
if p.log != nil && res != nil {
|
|
p.log.Info("organize pipeline finished",
|
|
zap.String("trigger", string(req.Trigger)),
|
|
zap.String("scope", string(req.Scope)),
|
|
zap.String("source", res.SourcePath),
|
|
zap.String("dest", firstNonEmpty(res.DestPath, filepath.Dir(path))),
|
|
zap.Int("organized", res.Organized),
|
|
zap.Int("replaced", res.Replaced),
|
|
zap.Int("reclassified", res.Reclassified),
|
|
zap.Int("skipped", res.Skipped),
|
|
zap.Int("scrapes", len(res.Scrapes)),
|
|
zap.Int("errors", len(res.Errors)))
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func organizeFatalResultError(res *OrganizeResult) error {
|
|
if res == nil || len(res.Errors) == 0 {
|
|
return nil
|
|
}
|
|
if res.Organized > 0 || res.Replaced > 0 || res.Reclassified > 0 {
|
|
return nil
|
|
}
|
|
samples := organizeErrorSamples(res.Errors, 3)
|
|
detail := strings.Join(samples, "; ")
|
|
if detail == "" {
|
|
detail = "unknown error"
|
|
}
|
|
return fmt.Errorf("organize failed: %d error(s), organized=0 replaced=0 skipped=%d: %s", len(res.Errors), res.Skipped, detail)
|
|
}
|
|
|
|
func organizeErrorSamples(errors []string, limit int) []string {
|
|
if limit <= 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, limit)
|
|
for _, line := range errors {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
out = append(out, line)
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *OrganizePipelineService) logOrganizeProblem(req OrganizePipelineRequest, res *OrganizeResult, err error) {
|
|
if p == nil || p.log == nil || res == nil {
|
|
return
|
|
}
|
|
fields := []zap.Field{
|
|
zap.String("trigger", string(req.Trigger)),
|
|
zap.String("scope", string(req.Scope)),
|
|
zap.String("source", res.SourcePath),
|
|
zap.String("dest", res.DestPath),
|
|
zap.Int("organized", res.Organized),
|
|
zap.Int("replaced", res.Replaced),
|
|
zap.Int("reclassified", res.Reclassified),
|
|
zap.Int("skipped", res.Skipped),
|
|
zap.Int("errors", len(res.Errors)),
|
|
zap.Strings("error_samples", organizeErrorSamples(res.Errors, 5)),
|
|
}
|
|
if err != nil {
|
|
fields = append(fields, zap.Error(err))
|
|
}
|
|
p.log.Warn("organize pipeline completed with errors", fields...)
|
|
}
|
|
|
|
func (p *OrganizePipelineService) recordProblem(ctx context.Context, req OrganizePipelineRequest, res *OrganizeResult, err error) {
|
|
if p == nil || p.repo == nil || p.repo.Log == nil {
|
|
return
|
|
}
|
|
action := "organize.warning"
|
|
if err != nil {
|
|
action = "organize.failed"
|
|
}
|
|
target := strings.TrimSpace(req.SourcePath)
|
|
detail := organizeAuditDetail(req, res, err)
|
|
if res != nil {
|
|
if strings.TrimSpace(res.SourcePath) != "" {
|
|
target = res.SourcePath
|
|
}
|
|
} else if target == "" {
|
|
target = firstNonEmpty(req.MediaID, req.LibraryID, req.DestPath)
|
|
}
|
|
row := &model.AccessLog{
|
|
Action: action,
|
|
Target: truncateForAccessLog(target, 255),
|
|
Detail: truncateForAccessLog(detail, 4000),
|
|
}
|
|
if writeErr := p.repo.Log.Create(ctx, row); writeErr != nil && p.log != nil {
|
|
p.log.Debug("organize audit log write failed", zap.Error(writeErr))
|
|
}
|
|
}
|
|
|
|
func organizeAuditDetail(req OrganizePipelineRequest, res *OrganizeResult, err error) string {
|
|
parts := []string{
|
|
"trigger=" + string(req.Trigger),
|
|
"scope=" + string(req.Scope),
|
|
}
|
|
if res != nil {
|
|
parts = append(parts,
|
|
fmt.Sprintf("source=%s", res.SourcePath),
|
|
fmt.Sprintf("dest=%s", res.DestPath),
|
|
fmt.Sprintf("organized=%d", res.Organized),
|
|
fmt.Sprintf("replaced=%d", res.Replaced),
|
|
fmt.Sprintf("reclassified=%d", res.Reclassified),
|
|
fmt.Sprintf("skipped=%d", res.Skipped),
|
|
fmt.Sprintf("errors=%d", len(res.Errors)),
|
|
)
|
|
if samples := organizeErrorSamples(res.Errors, 5); len(samples) > 0 {
|
|
parts = append(parts, "error_samples="+strings.Join(samples, " | "))
|
|
}
|
|
}
|
|
if err != nil {
|
|
parts = append(parts, "error="+err.Error())
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func truncateForAccessLog(value string, limit int) string {
|
|
value = strings.TrimSpace(value)
|
|
if limit <= 0 || len(value) <= limit {
|
|
return value
|
|
}
|
|
if limit <= 3 {
|
|
return value[:limit]
|
|
}
|
|
return value[:limit-3] + "..."
|
|
}
|
|
|
|
func (p *OrganizePipelineService) runOrganize(ctx context.Context, req OrganizePipelineRequest, opts OrganizeOptions) (*OrganizeResult, string, error) {
|
|
switch req.Scope {
|
|
case OrganizeScopeMedia:
|
|
path, err := p.organizer.OrganizeMediaWithOptions(ctx, strings.TrimSpace(req.MediaID), opts)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
res := &OrganizeResult{
|
|
Organized: 1,
|
|
SourcePath: opts.SourcePath,
|
|
DestPath: filepath.Dir(path),
|
|
DryRun: opts.DryRun,
|
|
Items: []OrganizePreviewItem{{
|
|
Target: path,
|
|
Action: "organize",
|
|
}},
|
|
}
|
|
return res, path, nil
|
|
case OrganizeScopeLibrary:
|
|
res, err := p.organizer.OrganizeLibraryWithOptions(ctx, strings.TrimSpace(req.LibraryID), opts)
|
|
return res, "", err
|
|
default:
|
|
res, err := p.organizer.OrganizeDirectory(ctx, opts)
|
|
return res, "", err
|
|
}
|
|
}
|
|
|
|
func (p *OrganizePipelineService) shouldScan(req OrganizePipelineRequest, res *OrganizeResult) bool {
|
|
if req.DryRun || p == nil || p.scanner == nil || res == nil {
|
|
return false
|
|
}
|
|
if req.ScanAfter != nil && !*req.ScanAfter {
|
|
return false
|
|
}
|
|
return OrganizeResultNeedsVisibilitySync(res)
|
|
}
|
|
|
|
func organizeScanRoot(res *OrganizeResult, path string) string {
|
|
if res == nil {
|
|
if strings.TrimSpace(path) == "" {
|
|
return ""
|
|
}
|
|
return filepath.Dir(path)
|
|
}
|
|
var root string
|
|
for _, item := range res.Items {
|
|
if !organizeItemNeedsVisibilitySync(item) {
|
|
continue
|
|
}
|
|
target := strings.TrimSpace(item.Target)
|
|
if target == "" {
|
|
continue
|
|
}
|
|
dir := filepath.Dir(target)
|
|
if root == "" {
|
|
root = dir
|
|
continue
|
|
}
|
|
root = commonPathRoot(root, dir)
|
|
}
|
|
if root != "" {
|
|
return root
|
|
}
|
|
if strings.TrimSpace(path) != "" {
|
|
return filepath.Dir(path)
|
|
}
|
|
return strings.TrimSpace(res.DestPath)
|
|
}
|
|
|
|
func organizeItemNeedsVisibilitySync(item OrganizePreviewItem) bool {
|
|
switch item.Action {
|
|
case "organize", "replace", "reclassify", "cleanup":
|
|
return true
|
|
case "skip":
|
|
switch item.Reason {
|
|
case organizeSkipAlreadyOrganized, organizeSkipTargetExists, "duplicate exists", "target exists":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func commonPathRoot(a, b string) string {
|
|
a = filepath.Clean(strings.TrimSpace(a))
|
|
b = filepath.Clean(strings.TrimSpace(b))
|
|
if a == "" || a == "." {
|
|
return b
|
|
}
|
|
if b == "" || b == "." {
|
|
return a
|
|
}
|
|
if pathWithin(a, b) {
|
|
return b
|
|
}
|
|
if pathWithin(b, a) {
|
|
return a
|
|
}
|
|
for {
|
|
parent := filepath.Dir(a)
|
|
if parent == a || parent == "." {
|
|
return parent
|
|
}
|
|
if pathWithin(b, parent) {
|
|
return parent
|
|
}
|
|
a = parent
|
|
}
|
|
}
|
|
|
|
func (p *OrganizePipelineService) scrapeAfter(ctx context.Context, req OrganizePipelineRequest) bool {
|
|
if req.ScrapeAfter != nil {
|
|
return *req.ScrapeAfter
|
|
}
|
|
return OrganizeScrapeAfterEnabled(ctx, p.repo)
|
|
}
|
|
|
|
func (p *OrganizePipelineService) startTask(ctx context.Context, req OrganizePipelineRequest, opts OrganizeOptions) *TaskHandle {
|
|
if p == nil || p.tasks == nil {
|
|
return nil
|
|
}
|
|
name := strings.TrimSpace(req.TaskName)
|
|
if name == "" {
|
|
name = p.defaultTaskName(req)
|
|
}
|
|
message := "正在整理/重命名/入库"
|
|
if req.DryRun {
|
|
message = "正在预览整理/重命名"
|
|
}
|
|
return p.tasks.Start(TaskKindOrganize, name, TaskUpdate{
|
|
Stage: "organize",
|
|
SourcePath: firstNonEmpty(opts.SourcePath, p.defaultSourcePath(ctx, req)),
|
|
DestPath: firstNonEmpty(opts.DestPath, p.defaultDestPath(ctx, req)),
|
|
Message: message,
|
|
})
|
|
}
|
|
|
|
func (p *OrganizePipelineService) finishTask(task *TaskHandle, err error, stage, message string, res *OrganizeResult) {
|
|
if task == nil {
|
|
return
|
|
}
|
|
task.Finish(err, TaskUpdate{
|
|
Stage: stage,
|
|
Message: message,
|
|
Metrics: OrganizeTaskMetrics(res),
|
|
Details: OrganizeTaskDetails(res, 8),
|
|
})
|
|
}
|
|
|
|
func (p *OrganizePipelineService) defaultTaskName(req OrganizePipelineRequest) string {
|
|
switch req.Trigger {
|
|
case OrganizeTriggerScheduled:
|
|
return "自动整理重命名刮削入库"
|
|
case OrganizeTriggerDownload:
|
|
return "下载完成自动整理重命名刮削入库"
|
|
default:
|
|
if req.DryRun {
|
|
return "预览整理重命名入库"
|
|
}
|
|
return "手动整理重命名刮削入库"
|
|
}
|
|
}
|
|
|
|
func (p *OrganizePipelineService) failureMessage(req OrganizePipelineRequest) string {
|
|
switch req.Trigger {
|
|
case OrganizeTriggerScheduled:
|
|
return "自动整理重命名入库失败"
|
|
case OrganizeTriggerDownload:
|
|
return "下载完成自动整理失败"
|
|
default:
|
|
return "手动整理重命名入库失败"
|
|
}
|
|
}
|
|
|
|
func (p *OrganizePipelineService) completedMessage(req OrganizePipelineRequest) string {
|
|
switch req.Trigger {
|
|
case OrganizeTriggerScheduled:
|
|
return "自动整理重命名刮削入库结束"
|
|
case OrganizeTriggerDownload:
|
|
return "下载完成自动整理入库结束"
|
|
default:
|
|
return "手动整理重命名刮削入库结束"
|
|
}
|
|
}
|
|
|
|
func (p *OrganizePipelineService) defaultSourcePath(ctx context.Context, req OrganizePipelineRequest) string {
|
|
if p == nil || p.organizer == nil {
|
|
return ""
|
|
}
|
|
if req.Scope == OrganizeScopeDirectory {
|
|
return p.organizer.defaultSourceRoot(ctx, "")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (p *OrganizePipelineService) defaultDestPath(ctx context.Context, req OrganizePipelineRequest) string {
|
|
if p == nil || p.organizer == nil {
|
|
return ""
|
|
}
|
|
if req.Scope == OrganizeScopeDirectory {
|
|
return p.organizer.defaultDestRoot(ctx, "")
|
|
}
|
|
return ""
|
|
}
|