diff --git a/pkg/manager/task_scan.go b/pkg/manager/task_scan.go index 285b2b92f..53dda3486 100644 --- a/pkg/manager/task_scan.go +++ b/pkg/manager/task_scan.go @@ -72,6 +72,11 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) { var galleries []string for _, sp := range paths { + csFs, er := utils.IsFsPathCaseSensitive(sp.Path) + if er != nil { + logger.Warnf("Cannot determine fs case sensitivity: %s", er.Error()) + } + err = walkFilesToScan(sp, func(path string, info os.FileInfo, err error) error { if job.IsCancelled(ctx) { return stoppingErr @@ -96,6 +101,7 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) { GenerateSprite: utils.IsTrue(input.ScanGenerateSprites), GeneratePhash: utils.IsTrue(input.ScanGeneratePhashes), progress: progress, + CaseSensitiveFs: csFs, } go func() { @@ -207,6 +213,7 @@ type ScanTask struct { GenerateImagePreview bool zipGallery *models.Gallery progress *job.Progress + CaseSensitiveFs bool } func (t *ScanTask) Start(wg *sizedwaitgroup.SizedWaitGroup) { @@ -397,6 +404,14 @@ func (t *ScanTask) scanGallery() { g, _ = qb.FindByChecksum(checksum) if g != nil { exists, _ := utils.FileExists(g.Path.String) + if !t.CaseSensitiveFs { + // #1426 - if file exists but is a case-insensitive match for the + // original filename, then treat it as a move + if exists && strings.EqualFold(t.FilePath, g.Path.String) { + exists = false + } + } + if exists { logger.Infof("%s already exists. Duplicate of %s ", t.FilePath, g.Path.String) } else { @@ -749,6 +764,14 @@ func (t *ScanTask) scanScene() *models.Scene { if s != nil { exists, _ := utils.FileExists(s.Path) + if !t.CaseSensitiveFs { + // #1426 - if file exists but is a case-insensitive match for the + // original filename, then treat it as a move + if exists && strings.EqualFold(t.FilePath, s.Path) { + exists = false + } + } + if exists { logger.Infof("%s already exists. Duplicate of %s", t.FilePath, s.Path) } else { @@ -1034,6 +1057,14 @@ func (t *ScanTask) scanImage() { if i != nil { exists := image.FileExists(i.Path) + if !t.CaseSensitiveFs { + // #1426 - if file exists but is a case-insensitive match for the + // original filename, then treat it as a move + if exists && strings.EqualFold(t.FilePath, i.Path) { + exists = false + } + } + if exists { logger.Infof("%s already exists. Duplicate of %s ", image.PathDisplayName(t.FilePath), image.PathDisplayName(i.Path)) } else { diff --git a/pkg/utils/file.go b/pkg/utils/file.go index 616678fa3..81cd75598 100644 --- a/pkg/utils/file.go +++ b/pkg/utils/file.go @@ -306,3 +306,43 @@ func GetFunscriptPath(path string) string { fn := strings.TrimSuffix(path, ext) return fn + ".funscript" } + +// IsFsPathCaseSensitive checks the fs of the given path to see if it is case sensitive +// if the case sensitivity can not be determined false and an error != nil are returned +func IsFsPathCaseSensitive(path string) (bool, error) { + // The case sensitivity of the fs of "path" is determined by case flipping + // the first letter rune from the base string of the path + // If the resulting flipped path exists then the fs should not be case sensitive + // ( we check the file mod time to avoid matching an existing path ) + + fi, err := os.Stat(path) + if err != nil { // path cannot be stat'd + return false, err + } + + base := filepath.Base(path) + fBase, err := FlipCaseSingle(base) + if err != nil { // cannot be case flipped + return false, err + } + i := strings.LastIndex(path, base) + if i < 0 { // shouldn't happen + return false, fmt.Errorf("could not case flip path %s", path) + } + + flipped := []byte(path) + for _, c := range []byte(fBase) { // replace base of path with the flipped one ( we need to flip the base or last dir part ) + flipped[i] = c + i++ + } + + fiCase, err := os.Stat(string(flipped)) + if err != nil { // cannot stat the case flipped path + return true, nil // fs of path should be case sensitive + } + + if fiCase.ModTime() == fi.ModTime() { // file path exists and is the same + return false, nil // fs of path is not case sensitive + } + return false, fmt.Errorf("can not determine case sensitivity of path %s", path) +} diff --git a/pkg/utils/strings.go b/pkg/utils/strings.go index ba6a655fa..912690069 100644 --- a/pkg/utils/strings.go +++ b/pkg/utils/strings.go @@ -5,6 +5,7 @@ import ( "math/rand" "strings" "time" + "unicode" ) var characters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890") @@ -18,8 +19,37 @@ func RandomSequence(n int) string { return string(b) } +// FlipCaseSingle flips the case ( lower<->upper ) of a single char from the string s +// If the string cannot be flipped, the original string value and an error are returned +func FlipCaseSingle(s string) (string, error) { + rr := []rune(s) + for i, r := range rr { + if unicode.IsLetter(r) { // look for a letter to flip + if unicode.IsUpper(r) { + rr[i] = unicode.ToLower(r) + return string(rr), nil + } + rr[i] = unicode.ToUpper(r) + return string(rr), nil + } + + } + return s, fmt.Errorf("could not case flip string %s", s) +} + type StrFormatMap map[string]interface{} +// StrFormat formats the provided format string, replacing placeholders +// in the form of "{fieldName}" with the values in the provided +// StrFormatMap. +// +// For example, +// StrFormat("{foo} bar {baz}", StrFormatMap{ +// "foo": "bar", +// "baz": "abc", +// }) +// +// would return: "bar bar abc" func StrFormat(format string, m StrFormatMap) string { args := make([]string, len(m)*2) i := 0 diff --git a/ui/v2.5/src/components/Changelog/versions/v080.md b/ui/v2.5/src/components/Changelog/versions/v080.md index f808e72f6..9b3404986 100644 --- a/ui/v2.5/src/components/Changelog/versions/v080.md +++ b/ui/v2.5/src/components/Changelog/versions/v080.md @@ -19,6 +19,7 @@ * Add button to remove studio stash ID. ([#1378](https://github.com/stashapp/stash/pull/1378)) ### 🐛 Bug fixes +* Fix file move detection when case of filename is changed on case-insensitive file systems. ([#1426](https://github.com/stashapp/stash/issues/1426)) * Fix auto-tagger not tagging scenes with no whitespace in name. ([#1488](https://github.com/stashapp/stash/pull/1488)) * Fix click/drag to select scenes. ([#1476](https://github.com/stashapp/stash/pull/1476)) * Fix clearing Performer and Movie ratings not working. ([#1429](https://github.com/stashapp/stash/pull/1429))