mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-10 15:26:38 +08:00
refactor organizer source flow by responsibility
This commit is contained in:
@@ -6,8 +6,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"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)
|
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) {
|
func organizeTitleFromParentFolder(src, sourceRoot string, seriesLike bool) (string, int) {
|
||||||
if !seriesLike {
|
if !seriesLike {
|
||||||
return "", 0
|
return "", 0
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user