mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
b0fe40142a
* 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>
168 lines
4.1 KiB
Go
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)
|
|
}
|