From 5450bc7c494ea4b01012a9106dc8e88331045cb0 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:03:20 +0800 Subject: [PATCH] refactor organizer source flow by responsibility --- .../service/organizer_directory_source.go | 356 ------------------ .../organizer_directory_source_existing.go | 132 +++++++ .../organizer_directory_source_plan.go | 239 ++++++++++++ 3 files changed, 371 insertions(+), 356 deletions(-) create mode 100644 internal/service/organizer_directory_source_existing.go create mode 100644 internal/service/organizer_directory_source_plan.go diff --git a/internal/service/organizer_directory_source.go b/internal/service/organizer_directory_source.go index 9bda321..15c4c37 100644 --- a/internal/service/organizer_directory_source.go +++ b/internal/service/organizer_directory_source.go @@ -6,8 +6,6 @@ import ( "path/filepath" "strings" - "go.uber.org/zap" - "github.com/ShukeBta/MediaStationGo/internal/model" ) @@ -73,360 +71,6 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, req organizeS return o.writeOrganizedSourceFile(ctx, req, plan) } -func (o *OrganizerService) buildOrganizeSourceFilePlan(ctx context.Context, req organizeSourceFileRequest) (organizeSourceFilePlan, error) { - identity := o.resolveOrganizeSourceIdentity(ctx, req) - pathLayout := o.inferOrganizeDirectoryLayout(req.Source, req.SourceRoot) - layout := pathLayout - forcedType := normalizeOrganizeMediaType(req.MediaTypeOverride) - inferredType := o.inferMediaTypeForSourceFile(req.Source, identity.Title, identity.Season, identity.Episode) - layout = applyOrganizeSourceMediaType(layout, forcedType, inferredType) - lookupType := organizeSourceMetadataLookupType(req.Source, pathLayout, layout, forcedType, inferredType, identity) - metadataMatch := o.lookupOrganizeSourceMetadata(ctx, req, lookupType, &identity) - if metadataMatch == nil && lookupType != layout.MediaType { - metadataMatch = o.lookupOrganizeSourceMetadata(ctx, req, layout.MediaType, &identity) - } - if forcedType == "" && metadataMatch != nil { - if matchType := normalizeOrganizeMediaType(metadataMatch.MediaType); matchType != "" && matchType != layout.MediaType { - if categoryType, _ := o.mediaTypeForDirectoryCategory(layout.Category); categoryType != "" && !sourceCategoryCompatible(matchType, categoryType) { - layout.Category = "" - } - if o.log != nil { - o.log.Info("organize media type corrected by metadata", - zap.String("source", req.Source), - zap.String("from", layout.MediaType), - zap.String("to", matchType), - zap.String("title", metadataMatch.Title), - zap.Int("tmdb_id", metadataMatch.TMDbID)) - } - layout.MediaType = matchType - } - } - layout = o.applyOrganizeSourceCategory(ctx, req, pathLayout, layout, forcedType, identity, metadataMatch) - layoutRoot, targetLibraryID := o.resolveOrganizeSourceLayoutRoot(ctx, req, layout) - target, err := o.buildOrganizeSourceTarget(ctx, req, layout, layoutRoot, identity) - if err != nil { - return organizeSourceFilePlan{}, err - } - return organizeSourceFilePlan{ - Identity: identity, - Layout: layout, - LayoutRoot: layoutRoot, - TargetLibraryID: targetLibraryID, - MetadataMatch: metadataMatch, - Target: target, - }, nil -} - -func (o *OrganizerService) resolveOrganizeSourceIdentity(ctx context.Context, req organizeSourceFileRequest) organizeSourceIdentity { - src := req.Source - ext := filepath.Ext(src) - season, episode := ParseEpisode(src) - title, year := CleanQuery(src) - if organizeWeakFileTitle(title) { - if folderTitle, folderYear := organizeTitleFromParentFolder(src, req.SourceRoot, season > 0 || episode > 0); folderTitle != "" { - title = folderTitle - if year <= 0 { - year = folderYear - } - } else { - title = strings.TrimSuffix(filepath.Base(src), ext) - } - } - parsedTitle := title - sourceMedia := o.lookupOrganizeSourceMedia(ctx, src) - title = sanitizeFilename(titleCaseWords(title)) - if sourceMedia != nil { - if mediaTitle := sanitizeFilename(strings.TrimSpace(sourceMedia.Title)); mediaTitle != "" { - title = mediaTitle - parsedTitle = strings.TrimSpace(sourceMedia.Title) - } - if sourceMedia.Year > 0 { - year = sourceMedia.Year - } - if sourceMedia.SeasonNum > 0 { - season = sourceMedia.SeasonNum - } - if sourceMedia.EpisodeNum > 0 { - episode = sourceMedia.EpisodeNum - } - } - if title == "" { - title = "Unknown" - } - return organizeSourceIdentity{ - Ext: ext, - Title: title, - ParsedTitle: parsedTitle, - Year: year, - Season: season, - Episode: episode, - SourceMedia: sourceMedia, - } -} - -func applyOrganizeSourceMediaType(layout organizeDirectoryLayout, forcedType, inferredType string) organizeDirectoryLayout { - if forcedType != "" { - if layout.Category != "" && layout.MediaType != "" && layout.MediaType != forcedType { - layout.Category = "" - } - layout.MediaType = forcedType - return layout - } - if inferredType == "" { - return layout - } - if inferredType == "tv" && layout.MediaType == "movie" { - return organizeDirectoryLayout{MediaType: inferredType} - } - if layout.MediaType == "" { - layout.MediaType = inferredType - } - return layout -} - -func organizeSourceMetadataLookupType(src string, pathLayout, layout organizeDirectoryLayout, forcedType, inferredType string, identity organizeSourceIdentity) string { - if forcedType != "" { - return layout.MediaType - } - if pathLayout.MediaType != "" && pathLayout.MediaType != "movie" && organizeStandaloneMovieSourceHint(src, identity) { - return "movie" - } - return layout.MediaType -} - -func organizeStandaloneMovieSourceHint(src string, identity organizeSourceIdentity) bool { - if identity.Season > 0 || identity.Episode > 0 { - return organizeEpisodeLooksSourcedFromMovieYear(src, identity) - } - _, year := CleanQuery(filepath.Base(src)) - if year <= 0 { - year = identity.Year - } - if year <= 0 { - return false - } - text := strings.ToLower(strings.Join([]string{filepath.Base(src), identity.Title, identity.ParsedTitle}, " ")) - return !classifierEpisodeRE.MatchString(text) && !classifierSeasonRE.MatchString(text) -} - -func organizeEpisodeLooksSourcedFromMovieYear(src string, identity organizeSourceIdentity) bool { - year := identity.Year - if identity.SourceMedia != nil && identity.SourceMedia.Year > 0 { - year = identity.SourceMedia.Year - } - if year < 1900 || year > 2099 || identity.Season != 1 || identity.Episode != year/10 { - return false - } - showDir := showDirFromEpisodePath(src) - if showDir == "" { - return false - } - _, folderYear := CleanQuery(filepath.Base(showDir)) - return folderYear == year -} - -func (o *OrganizerService) lookupOrganizeSourceMetadata(ctx context.Context, req organizeSourceFileRequest, mediaType string, identity *organizeSourceIdentity) *Match { - match := o.lookupOrganizeMetadata(ctx, req.Source, req.SourceRoot, mediaType, identity.Title, identity.Year, identity.Season, identity.Episode, req.MetadataCache) - if match == nil && identity.SourceMedia != nil { - match = organizeMatchFromMedia(identity.SourceMedia) - } - if match != nil { - applyOrganizeMetadataMatch(match, &identity.Title, &identity.ParsedTitle, &identity.Year) - } - return match -} - -func (o *OrganizerService) applyOrganizeSourceCategory( - ctx context.Context, - req organizeSourceFileRequest, - pathLayout, layout organizeDirectoryLayout, - forcedType string, - identity organizeSourceIdentity, - metadataMatch *Match, -) organizeDirectoryLayout { - if category := strings.TrimSpace(req.MediaCategoryOverride); category != "" { - layout.Category = sanitizeFilename(category) - } else if category := o.smartClassifySourceFile(ctx, req.Source, req.SourceRoot, layout.MediaType, identity.Title, identity.ParsedTitle, metadataMatch); category != "" { - layout.Category = category - } - if forcedType == "" { - if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" { - layout.Category = normalizedCategory - if layout.MediaType == "" || layout.MediaType == "tv" || layout.MediaType == "anime" || pathLayout.Category != layout.Category { - layout.MediaType = impliedType - } - } - } - return layout -} - -func (o *OrganizerService) resolveOrganizeSourceLayoutRoot(ctx context.Context, req organizeSourceFileRequest, layout organizeDirectoryLayout) (string, string) { - targetLibrary, matchedLibrary := o.organizeLibraryForLayout(ctx, req.DestRoot, layout.MediaType, layout.Category) - layoutRoot := targetLibrary.Path - targetLibraryID := targetLibrary.ID - if !matchedLibrary && layout.MediaType != "" { - layoutRoot = o.organizeRoot(req.DestRoot, layout.MediaType, layout.Category) - } - if !matchedLibrary && layout.Category != "" { - layoutRoot = categoryRoot(layoutRoot, sanitizeFilename(layout.Category)) - } - if !matchedLibrary && !req.DryRun { - if targetLibrary, ok := o.ensureOrganizeLibraryForRoot(ctx, layoutRoot, layout.MediaType, layout.Category); ok { - targetLibraryID = targetLibrary.ID - } - } - return layoutRoot, targetLibraryID -} - -func (o *OrganizerService) buildOrganizeSourceTarget( - ctx context.Context, - req organizeSourceFileRequest, - layout organizeDirectoryLayout, - layoutRoot string, - identity organizeSourceIdentity, -) (organizeTargetPath, error) { - isSeries := identity.Season > 0 || identity.Episode > 0 - if layout.MediaType != "" { - isSeries = isSeriesLibraryType(layout.MediaType) && isSeries - } - return o.buildOrganizeTargetPath(ctx, organizeTargetInput{ - Root: layoutRoot, - MediaType: layout.MediaType, - Category: layout.Category, - Title: identity.Title, - Source: req.Source, - Ext: identity.Ext, - Year: identity.Year, - Season: identity.Season, - Episode: identity.Episode, - Series: isSeries, - }) -} - -func skipAlreadyOrganizedSource(req organizeSourceFileRequest, plan organizeSourceFilePlan) { - req.Result.Skipped++ - req.Result.Items = append(req.Result.Items, OrganizePreviewItem{ - Source: req.Source, Target: plan.Target.Path, Action: "skip", Reason: organizeSkipAlreadyOrganized, - MediaType: plan.Layout.MediaType, Category: plan.Layout.Category, Title: plan.Identity.Title, - }) -} - -func (o *OrganizerService) collectExistingSourceVersions(ctx context.Context, req organizeSourceFileRequest, plan organizeSourceFilePlan) organizeExistingSourceVersions { - externalExisting := o.existingByExternalIdentity(ctx, req.DestRoot, plan.MetadataMatch, plan.Identity.Season, plan.Identity.Episode) - identityExisting := o.existingByIdentity(ctx, req.DestRoot, plan.Identity.ParsedTitle, plan.Identity.Year, plan.Identity.Season, plan.Identity.Episode) - folderExisting := o.existingByFolder(plan.Target.Dir, plan.Target.EpisodeTag) - return organizeExistingSourceVersions{ - External: externalExisting, - Identity: identityExisting, - Folder: folderExisting, - All: mergeExistingVersionPaths(externalExisting, identityExisting, folderExisting), - } -} - -func (o *OrganizerService) handleExistingSourceVersions(ctx context.Context, req organizeSourceFileRequest, plan organizeSourceFilePlan, existing organizeExistingSourceVersions) error { - reclassified, err := o.reclassifyExistingMedia(ctx, organizeExistingReclassifyRequest{ - Source: req.Source, - Target: plan.Target.Path, - DestRoot: req.DestRoot, - TargetLibraryID: plan.TargetLibraryID, - Existing: existing.All, - DryRun: req.DryRun, - MediaType: plan.Layout.MediaType, - Category: plan.Layout.Category, - Title: plan.Identity.Title, - Year: plan.Identity.Year, - Season: plan.Identity.Season, - Episode: plan.Identity.Episode, - MetadataMatch: plan.MetadataMatch, - Result: req.Result, - }) - if err != nil || reclassified { - return err - } - if replaced, err := o.replaceSourceWithBetterVersion(ctx, req, plan, existing.All); replaced || err != nil { - return err - } - o.skipExistingSourceDuplicate(ctx, req, plan, existing) - return nil -} - -func (o *OrganizerService) replaceSourceWithBetterVersion(ctx context.Context, req organizeSourceFileRequest, plan organizeSourceFilePlan, existing []string) (bool, error) { - srcArea := o.resolutionArea(ctx, req.Source) - bestArea := 0 - for _, e := range existing { - if a := o.resolutionArea(ctx, e); a > bestArea { - bestArea = a - } - } - if !req.AllowReplaceExisting || srcArea <= 0 || bestArea <= 0 || srcArea <= bestArea { - return false, nil - } - req.Result.Items = append(req.Result.Items, OrganizePreviewItem{ - Source: req.Source, Target: plan.Target.Path, Action: "replace", Reason: "higher resolution", - MediaType: plan.Layout.MediaType, Category: plan.Layout.Category, Title: plan.Identity.Title, - }) - if req.DryRun { - req.Result.Replaced++ - return true, nil - } - if err := o.replaceVersions(ctx, req.Source, existing, plan.Target.Path, req.Mode); err != nil { - return true, err - } - o.log.Info("organize replaced lower-resolution media", - zap.String("from", req.Source), - zap.String("to", plan.Target.Path), - zap.Int("src_area", srcArea), - zap.Int("existing_area", bestArea), - ) - req.Result.Replaced++ - return true, nil -} - -func (o *OrganizerService) skipExistingSourceDuplicate(ctx context.Context, req organizeSourceFileRequest, plan organizeSourceFilePlan, existing organizeExistingSourceVersions) { - reason := organizeSkipTargetExists - if len(existing.External) > 0 || len(existing.Identity) > 0 || o.allExistingPathsInDB(ctx, existing.All) { - reason = organizeSkipDuplicateLibrary - } - o.log.Debug("organize skip duplicate", - zap.String("src", req.Source), zap.String("dest_dir", plan.Target.Dir), zap.String("reason", reason)) - req.Result.Skipped++ - req.Result.Items = append(req.Result.Items, OrganizePreviewItem{ - Source: req.Source, Target: plan.Target.Path, Action: "skip", Reason: reason, - MediaType: plan.Layout.MediaType, Category: plan.Layout.Category, Title: plan.Identity.Title, - }) -} - -func (o *OrganizerService) writeOrganizedSourceFile(ctx context.Context, req organizeSourceFileRequest, plan organizeSourceFilePlan) error { - req.Result.Items = append(req.Result.Items, OrganizePreviewItem{ - Source: req.Source, Target: plan.Target.Path, Action: "organize", - MediaType: plan.Layout.MediaType, Category: plan.Layout.Category, Title: plan.Identity.Title, - }) - if req.DryRun { - req.Result.Organized++ - return nil - } - if err := os.MkdirAll(plan.Target.Dir, 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users. - return err - } - if _, err := os.Stat(plan.Target.Path); err == nil { - req.Result.Skipped++ - if len(req.Result.Items) > 0 { - req.Result.Items[len(req.Result.Items)-1].Action = "skip" - req.Result.Items[len(req.Result.Items)-1].Reason = organizeSkipTargetExists - } - return nil - } - if err := transferFile(req.Source, plan.Target.Path, req.Mode); err != nil { - return err - } - if err := transferSidecarNFO(req.Source, plan.Target.Path, req.Mode); err != nil { - o.log.Warn("organize sidecar nfo failed", - zap.String("from", req.Source), zap.String("to", plan.Target.Path), zap.Error(err)) - } - req.Result.Organized++ - return nil -} - func organizeTitleFromParentFolder(src, sourceRoot string, seriesLike bool) (string, int) { if !seriesLike { return "", 0 diff --git a/internal/service/organizer_directory_source_existing.go b/internal/service/organizer_directory_source_existing.go new file mode 100644 index 0000000..29a01a2 --- /dev/null +++ b/internal/service/organizer_directory_source_existing.go @@ -0,0 +1,132 @@ +package service + +import ( + "context" + "os" + + "go.uber.org/zap" +) + +func skipAlreadyOrganizedSource(req organizeSourceFileRequest, plan organizeSourceFilePlan) { + req.Result.Skipped++ + req.Result.Items = append(req.Result.Items, OrganizePreviewItem{ + Source: req.Source, Target: plan.Target.Path, Action: "skip", Reason: organizeSkipAlreadyOrganized, + MediaType: plan.Layout.MediaType, Category: plan.Layout.Category, Title: plan.Identity.Title, + }) +} + +func (o *OrganizerService) collectExistingSourceVersions(ctx context.Context, req organizeSourceFileRequest, plan organizeSourceFilePlan) organizeExistingSourceVersions { + externalExisting := o.existingByExternalIdentity(ctx, req.DestRoot, plan.MetadataMatch, plan.Identity.Season, plan.Identity.Episode) + identityExisting := o.existingByIdentity(ctx, req.DestRoot, plan.Identity.ParsedTitle, plan.Identity.Year, plan.Identity.Season, plan.Identity.Episode) + folderExisting := o.existingByFolder(plan.Target.Dir, plan.Target.EpisodeTag) + return organizeExistingSourceVersions{ + External: externalExisting, + Identity: identityExisting, + Folder: folderExisting, + All: mergeExistingVersionPaths(externalExisting, identityExisting, folderExisting), + } +} + +func (o *OrganizerService) handleExistingSourceVersions(ctx context.Context, req organizeSourceFileRequest, plan organizeSourceFilePlan, existing organizeExistingSourceVersions) error { + reclassified, err := o.reclassifyExistingMedia(ctx, organizeExistingReclassifyRequest{ + Source: req.Source, + Target: plan.Target.Path, + DestRoot: req.DestRoot, + TargetLibraryID: plan.TargetLibraryID, + Existing: existing.All, + DryRun: req.DryRun, + MediaType: plan.Layout.MediaType, + Category: plan.Layout.Category, + Title: plan.Identity.Title, + Year: plan.Identity.Year, + Season: plan.Identity.Season, + Episode: plan.Identity.Episode, + MetadataMatch: plan.MetadataMatch, + Result: req.Result, + }) + if err != nil || reclassified { + return err + } + if replaced, err := o.replaceSourceWithBetterVersion(ctx, req, plan, existing.All); replaced || err != nil { + return err + } + o.skipExistingSourceDuplicate(ctx, req, plan, existing) + return nil +} + +func (o *OrganizerService) replaceSourceWithBetterVersion(ctx context.Context, req organizeSourceFileRequest, plan organizeSourceFilePlan, existing []string) (bool, error) { + srcArea := o.resolutionArea(ctx, req.Source) + bestArea := 0 + for _, e := range existing { + if a := o.resolutionArea(ctx, e); a > bestArea { + bestArea = a + } + } + if !req.AllowReplaceExisting || srcArea <= 0 || bestArea <= 0 || srcArea <= bestArea { + return false, nil + } + req.Result.Items = append(req.Result.Items, OrganizePreviewItem{ + Source: req.Source, Target: plan.Target.Path, Action: "replace", Reason: "higher resolution", + MediaType: plan.Layout.MediaType, Category: plan.Layout.Category, Title: plan.Identity.Title, + }) + if req.DryRun { + req.Result.Replaced++ + return true, nil + } + if err := o.replaceVersions(ctx, req.Source, existing, plan.Target.Path, req.Mode); err != nil { + return true, err + } + o.log.Info("organize replaced lower-resolution media", + zap.String("from", req.Source), + zap.String("to", plan.Target.Path), + zap.Int("src_area", srcArea), + zap.Int("existing_area", bestArea), + ) + req.Result.Replaced++ + return true, nil +} + +func (o *OrganizerService) skipExistingSourceDuplicate(ctx context.Context, req organizeSourceFileRequest, plan organizeSourceFilePlan, existing organizeExistingSourceVersions) { + reason := organizeSkipTargetExists + if len(existing.External) > 0 || len(existing.Identity) > 0 || o.allExistingPathsInDB(ctx, existing.All) { + reason = organizeSkipDuplicateLibrary + } + o.log.Debug("organize skip duplicate", + zap.String("src", req.Source), zap.String("dest_dir", plan.Target.Dir), zap.String("reason", reason)) + req.Result.Skipped++ + req.Result.Items = append(req.Result.Items, OrganizePreviewItem{ + Source: req.Source, Target: plan.Target.Path, Action: "skip", Reason: reason, + MediaType: plan.Layout.MediaType, Category: plan.Layout.Category, Title: plan.Identity.Title, + }) +} + +func (o *OrganizerService) writeOrganizedSourceFile(ctx context.Context, req organizeSourceFileRequest, plan organizeSourceFilePlan) error { + req.Result.Items = append(req.Result.Items, OrganizePreviewItem{ + Source: req.Source, Target: plan.Target.Path, Action: "organize", + MediaType: plan.Layout.MediaType, Category: plan.Layout.Category, Title: plan.Identity.Title, + }) + if req.DryRun { + req.Result.Organized++ + return nil + } + if err := os.MkdirAll(plan.Target.Dir, 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users. + return err + } + if _, err := os.Stat(plan.Target.Path); err == nil { + req.Result.Skipped++ + if len(req.Result.Items) > 0 { + req.Result.Items[len(req.Result.Items)-1].Action = "skip" + req.Result.Items[len(req.Result.Items)-1].Reason = organizeSkipTargetExists + } + return nil + } + if err := transferFile(req.Source, plan.Target.Path, req.Mode); err != nil { + return err + } + if err := transferSidecarNFO(req.Source, plan.Target.Path, req.Mode); err != nil { + o.log.Warn("organize sidecar nfo failed", + zap.String("from", req.Source), zap.String("to", plan.Target.Path), zap.Error(err)) + } + req.Result.Organized++ + return nil +} diff --git a/internal/service/organizer_directory_source_plan.go b/internal/service/organizer_directory_source_plan.go new file mode 100644 index 0000000..dffcacd --- /dev/null +++ b/internal/service/organizer_directory_source_plan.go @@ -0,0 +1,239 @@ +package service + +import ( + "context" + "path/filepath" + "strings" + + "go.uber.org/zap" +) + +func (o *OrganizerService) buildOrganizeSourceFilePlan(ctx context.Context, req organizeSourceFileRequest) (organizeSourceFilePlan, error) { + identity := o.resolveOrganizeSourceIdentity(ctx, req) + pathLayout := o.inferOrganizeDirectoryLayout(req.Source, req.SourceRoot) + layout := pathLayout + forcedType := normalizeOrganizeMediaType(req.MediaTypeOverride) + inferredType := o.inferMediaTypeForSourceFile(req.Source, identity.Title, identity.Season, identity.Episode) + layout = applyOrganizeSourceMediaType(layout, forcedType, inferredType) + lookupType := organizeSourceMetadataLookupType(req.Source, pathLayout, layout, forcedType, inferredType, identity) + metadataMatch := o.lookupOrganizeSourceMetadata(ctx, req, lookupType, &identity) + if metadataMatch == nil && lookupType != layout.MediaType { + metadataMatch = o.lookupOrganizeSourceMetadata(ctx, req, layout.MediaType, &identity) + } + if forcedType == "" && metadataMatch != nil { + if matchType := normalizeOrganizeMediaType(metadataMatch.MediaType); matchType != "" && matchType != layout.MediaType { + if categoryType, _ := o.mediaTypeForDirectoryCategory(layout.Category); categoryType != "" && !sourceCategoryCompatible(matchType, categoryType) { + layout.Category = "" + } + if o.log != nil { + o.log.Info("organize media type corrected by metadata", + zap.String("source", req.Source), + zap.String("from", layout.MediaType), + zap.String("to", matchType), + zap.String("title", metadataMatch.Title), + zap.Int("tmdb_id", metadataMatch.TMDbID)) + } + layout.MediaType = matchType + } + } + layout = o.applyOrganizeSourceCategory(ctx, req, pathLayout, layout, forcedType, identity, metadataMatch) + layoutRoot, targetLibraryID := o.resolveOrganizeSourceLayoutRoot(ctx, req, layout) + target, err := o.buildOrganizeSourceTarget(ctx, req, layout, layoutRoot, identity) + if err != nil { + return organizeSourceFilePlan{}, err + } + return organizeSourceFilePlan{ + Identity: identity, + Layout: layout, + LayoutRoot: layoutRoot, + TargetLibraryID: targetLibraryID, + MetadataMatch: metadataMatch, + Target: target, + }, nil +} + +func (o *OrganizerService) resolveOrganizeSourceIdentity(ctx context.Context, req organizeSourceFileRequest) organizeSourceIdentity { + src := req.Source + ext := filepath.Ext(src) + season, episode := ParseEpisode(src) + title, year := CleanQuery(src) + if organizeWeakFileTitle(title) { + if folderTitle, folderYear := organizeTitleFromParentFolder(src, req.SourceRoot, season > 0 || episode > 0); folderTitle != "" { + title = folderTitle + if year <= 0 { + year = folderYear + } + } else { + title = strings.TrimSuffix(filepath.Base(src), ext) + } + } + parsedTitle := title + sourceMedia := o.lookupOrganizeSourceMedia(ctx, src) + title = sanitizeFilename(titleCaseWords(title)) + if sourceMedia != nil { + if mediaTitle := sanitizeFilename(strings.TrimSpace(sourceMedia.Title)); mediaTitle != "" { + title = mediaTitle + parsedTitle = strings.TrimSpace(sourceMedia.Title) + } + if sourceMedia.Year > 0 { + year = sourceMedia.Year + } + if sourceMedia.SeasonNum > 0 { + season = sourceMedia.SeasonNum + } + if sourceMedia.EpisodeNum > 0 { + episode = sourceMedia.EpisodeNum + } + } + if title == "" { + title = "Unknown" + } + return organizeSourceIdentity{ + Ext: ext, + Title: title, + ParsedTitle: parsedTitle, + Year: year, + Season: season, + Episode: episode, + SourceMedia: sourceMedia, + } +} + +func applyOrganizeSourceMediaType(layout organizeDirectoryLayout, forcedType, inferredType string) organizeDirectoryLayout { + if forcedType != "" { + if layout.Category != "" && layout.MediaType != "" && layout.MediaType != forcedType { + layout.Category = "" + } + layout.MediaType = forcedType + return layout + } + if inferredType == "" { + return layout + } + if inferredType == "tv" && layout.MediaType == "movie" { + return organizeDirectoryLayout{MediaType: inferredType} + } + if layout.MediaType == "" { + layout.MediaType = inferredType + } + return layout +} + +func organizeSourceMetadataLookupType(src string, pathLayout, layout organizeDirectoryLayout, forcedType, inferredType string, identity organizeSourceIdentity) string { + if forcedType != "" { + return layout.MediaType + } + if pathLayout.MediaType != "" && pathLayout.MediaType != "movie" && organizeStandaloneMovieSourceHint(src, identity) { + return "movie" + } + return layout.MediaType +} + +func organizeStandaloneMovieSourceHint(src string, identity organizeSourceIdentity) bool { + if identity.Season > 0 || identity.Episode > 0 { + return organizeEpisodeLooksSourcedFromMovieYear(src, identity) + } + _, year := CleanQuery(filepath.Base(src)) + if year <= 0 { + year = identity.Year + } + if year <= 0 { + return false + } + text := strings.ToLower(strings.Join([]string{filepath.Base(src), identity.Title, identity.ParsedTitle}, " ")) + return !classifierEpisodeRE.MatchString(text) && !classifierSeasonRE.MatchString(text) +} + +func organizeEpisodeLooksSourcedFromMovieYear(src string, identity organizeSourceIdentity) bool { + year := identity.Year + if identity.SourceMedia != nil && identity.SourceMedia.Year > 0 { + year = identity.SourceMedia.Year + } + if year < 1900 || year > 2099 || identity.Season != 1 || identity.Episode != year/10 { + return false + } + showDir := showDirFromEpisodePath(src) + if showDir == "" { + return false + } + _, folderYear := CleanQuery(filepath.Base(showDir)) + return folderYear == year +} + +func (o *OrganizerService) lookupOrganizeSourceMetadata(ctx context.Context, req organizeSourceFileRequest, mediaType string, identity *organizeSourceIdentity) *Match { + match := o.lookupOrganizeMetadata(ctx, req.Source, req.SourceRoot, mediaType, identity.Title, identity.Year, identity.Season, identity.Episode, req.MetadataCache) + if match == nil && identity.SourceMedia != nil { + match = organizeMatchFromMedia(identity.SourceMedia) + } + if match != nil { + applyOrganizeMetadataMatch(match, &identity.Title, &identity.ParsedTitle, &identity.Year) + } + return match +} + +func (o *OrganizerService) applyOrganizeSourceCategory( + ctx context.Context, + req organizeSourceFileRequest, + pathLayout, layout organizeDirectoryLayout, + forcedType string, + identity organizeSourceIdentity, + metadataMatch *Match, +) organizeDirectoryLayout { + if category := strings.TrimSpace(req.MediaCategoryOverride); category != "" { + layout.Category = sanitizeFilename(category) + } else if category := o.smartClassifySourceFile(ctx, req.Source, req.SourceRoot, layout.MediaType, identity.Title, identity.ParsedTitle, metadataMatch); category != "" { + layout.Category = category + } + if forcedType == "" { + if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" { + layout.Category = normalizedCategory + if layout.MediaType == "" || layout.MediaType == "tv" || layout.MediaType == "anime" || pathLayout.Category != layout.Category { + layout.MediaType = impliedType + } + } + } + return layout +} + +func (o *OrganizerService) resolveOrganizeSourceLayoutRoot(ctx context.Context, req organizeSourceFileRequest, layout organizeDirectoryLayout) (string, string) { + targetLibrary, matchedLibrary := o.organizeLibraryForLayout(ctx, req.DestRoot, layout.MediaType, layout.Category) + layoutRoot := targetLibrary.Path + targetLibraryID := targetLibrary.ID + if !matchedLibrary && layout.MediaType != "" { + layoutRoot = o.organizeRoot(req.DestRoot, layout.MediaType, layout.Category) + } + if !matchedLibrary && layout.Category != "" { + layoutRoot = categoryRoot(layoutRoot, sanitizeFilename(layout.Category)) + } + if !matchedLibrary && !req.DryRun { + if targetLibrary, ok := o.ensureOrganizeLibraryForRoot(ctx, layoutRoot, layout.MediaType, layout.Category); ok { + targetLibraryID = targetLibrary.ID + } + } + return layoutRoot, targetLibraryID +} + +func (o *OrganizerService) buildOrganizeSourceTarget( + ctx context.Context, + req organizeSourceFileRequest, + layout organizeDirectoryLayout, + layoutRoot string, + identity organizeSourceIdentity, +) (organizeTargetPath, error) { + isSeries := identity.Season > 0 || identity.Episode > 0 + if layout.MediaType != "" { + isSeries = isSeriesLibraryType(layout.MediaType) && isSeries + } + return o.buildOrganizeTargetPath(ctx, organizeTargetInput{ + Root: layoutRoot, + MediaType: layout.MediaType, + Category: layout.Category, + Title: identity.Title, + Source: req.Source, + Ext: identity.Ext, + Year: identity.Year, + Season: identity.Season, + Episode: identity.Episode, + Series: isSeries, + }) +}