Files
MeBox/internal/service/organize_pipeline.go
truewhile b0fe40142a Rebrand MMTL to MeBox (name, logo, Docker image) (#17)
* Rebrand MMTL to MeBox across codebase and assets

Rename the project display name, Go module path, environment variable
prefix (MEBOX_*), Docker image references, and UI branding from MMTL/mmtl
to MeBox/mebox. Replace logo assets with the new MeBox icon and keep
legacy SQLite migration support for existing mmtl.db deployments.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix logo icons: use cube-only crop without truncated text

Previous icon generation cropped too much of the source image, including
partial MeBox wordmark text that was cut off in square icon containers.
Regenerate logo-64/192/512, favicon, and SVG from cube-only region.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 16:26:28 +08:00

238 lines
7.2 KiB
Go

package service
import (
"context"
"errors"
"path/filepath"
"strings"
"go.uber.org/zap"
"github.com/truewhile/MeBox/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),
Items: organizeItemsFromResult(res),
})
}
if p.shouldScan(req, res) {
if task != nil {
task.Update(TaskUpdate{
Stage: "scan_scrape",
Message: "正在扫描入库并按设置刮削",
Metrics: OrganizeTaskMetrics(res),
Details: OrganizeTaskDetails(res, 8),
Items: combineOrganizeItems(res),
})
}
scanRoot := organizeScanRoot(res, path)
if scanRoot == "" && strings.TrimSpace(path) != "" {
scanRoot = filepath.Dir(path)
}
preferredLibraryID := p.scanPreferredLibraryID(ctx, req)
scrapeAfter := p.scrapeAfter(ctx, req)
res.Scans, res.Scrapes = p.scanner.ScanAndScrapeLibrariesForPath(ctx, scanRoot, preferredLibraryID, scrapeAfter)
if scrapeAfter {
p.syncScrapedOrganizedNames(ctx, opts, res, path)
}
} 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 (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 (p *OrganizePipelineService) scrapeAfter(ctx context.Context, req OrganizePipelineRequest) bool {
if req.ScrapeAfter != nil {
return *req.ScrapeAfter
}
return OrganizeScrapeAfterEnabled(ctx, p.repo)
}
func (p *OrganizePipelineService) scanPreferredLibraryID(ctx context.Context, req OrganizePipelineRequest) string {
if preferred := strings.TrimSpace(req.PreferredLibraryID); preferred != "" {
return preferred
}
switch req.Scope {
case OrganizeScopeMedia:
if p == nil || p.repo == nil || p.repo.Media == nil {
return ""
}
media, err := p.repo.Media.FindByID(ctx, strings.TrimSpace(req.MediaID))
if err != nil || media == nil {
return ""
}
return strings.TrimSpace(media.LibraryID)
case OrganizeScopeLibrary:
return strings.TrimSpace(req.LibraryID)
default:
return ""
}
}