diff --git a/internal/service/organizer_directory_reclassify.go b/internal/service/organizer_directory_reclassify.go index b5bd32a..c279839 100644 --- a/internal/service/organizer_directory_reclassify.go +++ b/internal/service/organizer_directory_reclassify.go @@ -8,8 +8,6 @@ import ( "strings" "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" ) type organizeExistingReclassifyRequest struct { @@ -216,166 +214,3 @@ func (o *OrganizerService) nextReclassifyConflictTarget(ctx context.Context, tar } return "" } - -func safeToRemoveReclassifiedDuplicate(path, target string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - targetInfo, err := os.Stat(target) - if err != nil { - return false - } - if os.SameFile(info, targetInfo) { - return true - } - return info.Size() > 0 && info.Size() == targetInfo.Size() -} - -func organizeFileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -func moveSidecarNFO(oldMedia, newMedia string) error { - oldNFO := nfoPath(oldMedia) - newNFO := nfoPath(newMedia) - if oldNFO == newNFO || !organizeFileExists(oldNFO) || organizeFileExists(newNFO) { - return nil - } - if err := os.MkdirAll(filepath.Dir(newNFO), 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users. - return err - } - return moveFile(oldNFO, newNFO) -} - -func removeMediaAndNFO(path string) error { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return err - } - if nfo := nfoPath(path); nfo != "" { - if err := os.Remove(nfo); err != nil && !os.IsNotExist(err) { - return err - } - } - return nil -} - -func (o *OrganizerService) updateReclassifiedMediaRow(ctx context.Context, oldPath, newPath string, req organizeExistingReclassifyRequest) error { - if o == nil || o.repo == nil || o.repo.DB == nil { - return nil - } - updates := map[string]any{ - "path": newPath, - } - if strings.TrimSpace(req.TargetLibraryID) != "" { - updates["library_id"] = strings.TrimSpace(req.TargetLibraryID) - } - if strings.TrimSpace(req.Title) != "" { - updates["title"] = strings.TrimSpace(req.Title) - } - if req.Year > 0 { - updates["year"] = req.Year - } - if normalizeOrganizeMediaType(req.MediaType) == "movie" { - updates["season_num"] = 0 - updates["episode_num"] = 0 - } else if req.Season > 0 { - updates["season_num"] = req.Season - if req.Episode > 0 { - updates["episode_num"] = req.Episode - } - } else if req.Episode > 0 { - updates["episode_num"] = req.Episode - } - applyReclassifyMatchUpdates(updates, req.MetadataMatch) - return o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("path = ?", oldPath).Updates(updates).Error -} - -func applyReclassifyMatchUpdates(updates map[string]any, match *Match) { - if updates == nil || match == nil { - return - } - if value := strings.TrimSpace(match.Title); value != "" { - updates["title"] = value - } - if value := strings.TrimSpace(match.OriginalName); value != "" { - updates["original_name"] = value - } - if value := strings.TrimSpace(match.Overview); value != "" { - updates["overview"] = value - } - if value := strings.TrimSpace(match.PosterURL); value != "" { - updates["poster_url"] = value - } - if value := strings.TrimSpace(match.BackdropURL); value != "" { - updates["backdrop_url"] = value - } - if match.Rating > 0 { - updates["rating"] = match.Rating - } - if match.Year > 0 { - updates["year"] = match.Year - } - if match.TMDbID > 0 { - updates["tm_db_id"] = match.TMDbID - } - if match.BangumiID > 0 { - updates["bangumi_id"] = match.BangumiID - } - if value := strings.TrimSpace(match.DoubanID); value != "" { - updates["douban_id"] = value - } - if value := strings.TrimSpace(match.TheTVDBID); value != "" { - updates["thetvdb_id"] = value - } - if len(match.Genres) > 0 { - updates["genres"] = strings.Join(match.Genres, ",") - } - if len(match.Countries) > 0 { - updates["countries"] = strings.Join(match.Countries, ",") - } - if len(match.Languages) > 0 { - updates["languages"] = strings.Join(match.Languages, ",") - } - if match.NSFW { - updates["nsfw"] = true - } - updates["scrape_status"] = "matched" -} - -func (o *OrganizerService) deleteMediaRowForPath(ctx context.Context, path string) { - if o == nil || o.repo == nil || o.repo.DB == nil { - return - } - _ = o.repo.DB.WithContext(ctx).Where("path = ?", path).Delete(&model.Media{}).Error -} - -func (o *OrganizerService) mediaPathExists(ctx context.Context, path string) bool { - if o == nil || o.repo == nil || o.repo.DB == nil { - return false - } - var count int64 - if err := o.repo.DB.WithContext(ctx).Unscoped().Model(&model.Media{}).Where("path = ?", path).Count(&count).Error; err != nil { - return false - } - return count > 0 -} - -func cleanupEmptyMediaDirs(startDir, stopRoot string) { - dir := filepath.Clean(strings.TrimSpace(startDir)) - stopRoot = filepath.Clean(strings.TrimSpace(stopRoot)) - for dir != "" && dir != "." { - if stopRoot != "" && stopRoot != "." && (!pathWithin(dir, stopRoot) || strings.EqualFold(dir, stopRoot)) { - return - } - if err := os.Remove(dir); err != nil { - return - } - parent := filepath.Dir(dir) - if parent == dir { - return - } - dir = parent - } -} diff --git a/internal/service/organizer_reclassify_files.go b/internal/service/organizer_reclassify_files.go new file mode 100644 index 0000000..01dbbf2 --- /dev/null +++ b/internal/service/organizer_reclassify_files.go @@ -0,0 +1,69 @@ +package service + +import ( + "os" + "path/filepath" + "strings" +) + +func safeToRemoveReclassifiedDuplicate(path, target string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + targetInfo, err := os.Stat(target) + if err != nil { + return false + } + if os.SameFile(info, targetInfo) { + return true + } + return info.Size() > 0 && info.Size() == targetInfo.Size() +} + +func organizeFileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func moveSidecarNFO(oldMedia, newMedia string) error { + oldNFO := nfoPath(oldMedia) + newNFO := nfoPath(newMedia) + if oldNFO == newNFO || !organizeFileExists(oldNFO) || organizeFileExists(newNFO) { + return nil + } + if err := os.MkdirAll(filepath.Dir(newNFO), 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users. + return err + } + return moveFile(oldNFO, newNFO) +} + +func removeMediaAndNFO(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + if nfo := nfoPath(path); nfo != "" { + if err := os.Remove(nfo); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +func cleanupEmptyMediaDirs(startDir, stopRoot string) { + dir := filepath.Clean(strings.TrimSpace(startDir)) + stopRoot = filepath.Clean(strings.TrimSpace(stopRoot)) + for dir != "" && dir != "." { + if stopRoot != "" && stopRoot != "." && (!pathWithin(dir, stopRoot) || strings.EqualFold(dir, stopRoot)) { + return + } + if err := os.Remove(dir); err != nil { + return + } + parent := filepath.Dir(dir) + if parent == dir { + return + } + dir = parent + } +} diff --git a/internal/service/organizer_reclassify_media_rows.go b/internal/service/organizer_reclassify_media_rows.go new file mode 100644 index 0000000..27e8ee2 --- /dev/null +++ b/internal/service/organizer_reclassify_media_rows.go @@ -0,0 +1,109 @@ +package service + +import ( + "context" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func (o *OrganizerService) updateReclassifiedMediaRow(ctx context.Context, oldPath, newPath string, req organizeExistingReclassifyRequest) error { + if o == nil || o.repo == nil || o.repo.DB == nil { + return nil + } + updates := map[string]any{ + "path": newPath, + } + if strings.TrimSpace(req.TargetLibraryID) != "" { + updates["library_id"] = strings.TrimSpace(req.TargetLibraryID) + } + if strings.TrimSpace(req.Title) != "" { + updates["title"] = strings.TrimSpace(req.Title) + } + if req.Year > 0 { + updates["year"] = req.Year + } + if normalizeOrganizeMediaType(req.MediaType) == "movie" { + updates["season_num"] = 0 + updates["episode_num"] = 0 + } else if req.Season > 0 { + updates["season_num"] = req.Season + if req.Episode > 0 { + updates["episode_num"] = req.Episode + } + } else if req.Episode > 0 { + updates["episode_num"] = req.Episode + } + applyReclassifyMatchUpdates(updates, req.MetadataMatch) + return o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("path = ?", oldPath).Updates(updates).Error +} + +func applyReclassifyMatchUpdates(updates map[string]any, match *Match) { + if updates == nil || match == nil { + return + } + if value := strings.TrimSpace(match.Title); value != "" { + updates["title"] = value + } + if value := strings.TrimSpace(match.OriginalName); value != "" { + updates["original_name"] = value + } + if value := strings.TrimSpace(match.Overview); value != "" { + updates["overview"] = value + } + if value := strings.TrimSpace(match.PosterURL); value != "" { + updates["poster_url"] = value + } + if value := strings.TrimSpace(match.BackdropURL); value != "" { + updates["backdrop_url"] = value + } + if match.Rating > 0 { + updates["rating"] = match.Rating + } + if match.Year > 0 { + updates["year"] = match.Year + } + if match.TMDbID > 0 { + updates["tm_db_id"] = match.TMDbID + } + if match.BangumiID > 0 { + updates["bangumi_id"] = match.BangumiID + } + if value := strings.TrimSpace(match.DoubanID); value != "" { + updates["douban_id"] = value + } + if value := strings.TrimSpace(match.TheTVDBID); value != "" { + updates["thetvdb_id"] = value + } + if len(match.Genres) > 0 { + updates["genres"] = strings.Join(match.Genres, ",") + } + if len(match.Countries) > 0 { + updates["countries"] = strings.Join(match.Countries, ",") + } + if len(match.Languages) > 0 { + updates["languages"] = strings.Join(match.Languages, ",") + } + if match.NSFW { + updates["nsfw"] = true + } + updates["scrape_status"] = "matched" +} + +func (o *OrganizerService) deleteMediaRowForPath(ctx context.Context, path string) { + if o == nil || o.repo == nil || o.repo.DB == nil { + return + } + _ = o.repo.DB.WithContext(ctx).Where("path = ?", path).Delete(&model.Media{}).Error +} + +func (o *OrganizerService) mediaPathExists(ctx context.Context, path string) bool { + if o == nil || o.repo == nil || o.repo.DB == nil { + return false + } + var count int64 + if err := o.repo.DB.WithContext(ctx).Unscoped().Model(&model.Media{}).Where("path = ?", path).Count(&count).Error; err != nil { + return false + } + return count > 0 +}