Files
MeBox/internal/service/organizer_directory_source.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

168 lines
4.1 KiB
Go

package service
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/truewhile/MeBox/internal/model"
)
type organizeDirectoryLayout struct {
MediaType string
Category string
}
type organizeSourceFileRequest struct {
Source string
SourceRoot string
DestRoot string
Mode TransferMode
MediaTypeOverride string
MediaCategoryOverride string
DryRun bool
AllowReplaceExisting bool
MetadataCache map[string]*Match
Result *OrganizeResult
}
type organizeSourceIdentity struct {
Ext string
Title string
ParsedTitle string
Year int
Season int
Episode int
SourceMedia *model.Media
}
type organizeSourceFilePlan struct {
Identity organizeSourceIdentity
Layout organizeDirectoryLayout
LayoutRoot string
TargetLibraryID string
MetadataMatch *Match
Target organizeTargetPath
}
type organizeExistingSourceVersions struct {
External []string
Identity []string
Folder []string
All []string
}
// organizeSourceFile organizes a single video file from the source directory
// into destRoot, applying dedup + 洗版.
func (o *OrganizerService) organizeSourceFile(ctx context.Context, req organizeSourceFileRequest) error {
plan, err := o.buildOrganizeSourceFilePlan(ctx, req)
if err != nil {
return err
}
if filepath.Clean(req.Source) == filepath.Clean(plan.Target.Path) {
skipAlreadyOrganizedSource(req, plan)
return nil
}
existing := o.collectExistingSourceVersions(ctx, req, plan)
if len(existing.All) > 0 {
return o.handleExistingSourceVersions(ctx, req, plan, existing)
}
return o.writeOrganizedSourceFile(ctx, req, plan)
}
func organizeTitleFromParentFolder(src, sourceRoot string, seriesLike bool) (string, int) {
if !seriesLike {
return "", 0
}
raw := seriesFolderTitle(src, sourceRoot)
if strings.TrimSpace(raw) == "" {
return "", 0
}
title, year := CleanQuery(raw)
if title == "" {
title = strings.TrimSpace(raw)
}
return title, year
}
func shouldSkipOrganizeSourceVideo(path, sourceRoot string) (bool, string) {
cleanPath := filepath.Clean(path)
cleanRoot := filepath.Clean(sourceRoot)
if rel, err := filepath.Rel(cleanRoot, cleanPath); err == nil && rel != "." && !strings.HasPrefix(rel, "..") {
dir := filepath.Dir(rel)
if dir != "." {
for _, part := range strings.Split(dir, string(os.PathSeparator)) {
switch normalizeOrganizeCategoryKey(part) {
case "sample", "samples", "trailer", "trailers", "preview", "previews", "teaser", "teasers":
return true, organizeSkipSampleClip
}
}
}
}
base := strings.ToLower(strings.TrimSuffix(filepath.Base(cleanPath), filepath.Ext(cleanPath)))
normalized := strings.NewReplacer("_", " ", "-", " ", ".", " ").Replace(base)
fields := strings.Fields(normalized)
if len(fields) == 0 {
return false, ""
}
if len(fields) == 1 && strings.HasPrefix(fields[0], "sample") {
return true, organizeSkipSampleClip
}
last := fields[len(fields)-1]
switch last {
case "sample", "trailer", "preview", "teaser":
return true, organizeSkipSampleClip
}
return false, ""
}
func normalizeOrganizeMediaType(mediaType string) string {
switch strings.ToLower(strings.TrimSpace(mediaType)) {
case "movie", "film":
return "movie"
case "tv", "series", "show", "drama":
return "tv"
case "anime", "animation":
return "anime"
case "variety":
return "variety"
case "adult", "nsfw":
return "adult"
default:
return ""
}
}
func organizeWeakFileTitle(title string) bool {
title = strings.TrimSpace(title)
if title == "" {
return true
}
fields := strings.Fields(strings.ToLower(title))
if len(fields) == 0 {
return true
}
meaningful := 0
for _, field := range fields {
if _, ok := noiseTokenSet[field]; ok {
continue
}
if _, ok := releaseBoundaryTokenSet[field]; ok {
continue
}
if len(field) == 4 && strings.HasPrefix(field, "20") {
continue
}
meaningful++
}
return meaningful == 0
}
func (o *OrganizerService) inferMediaTypeForSourceFile(src, title string, season, episode int) string {
if season > 0 || episode > 0 {
return "tv"
}
return normalizeMediaType("", title, src)
}