From c01e54760139d6d0876dca785fa6b6f5c15a90ee Mon Sep 17 00:00:00 2001 From: Foowy <49217685+Foowy@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:58:04 +0000 Subject: [PATCH 1/4] fix(relocation): allow case-only renames on case-sensitive file systems The same-location guard in `InternalDirectlyRelocateFile` aborted every path that differed only by case, even where the file system treats them as different paths. - Aborted only when the new path exists and is the same file as the old one - Added `ReuseExistingFolderCase` so a folder differing only by case from an existing one is reused rather than duplicated --- .../Services/VideoRelocationService.cs | 48 ++++++++++++++++--- .../Services/VideoRelocationGuardTests.cs | 37 ++++++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/Shoko.Server/Services/VideoRelocationService.cs b/Shoko.Server/Services/VideoRelocationService.cs index 362b912c8..dbe2bc898 100644 --- a/Shoko.Server/Services/VideoRelocationService.cs +++ b/Shoko.Server/Services/VideoRelocationService.cs @@ -1002,17 +1002,26 @@ private RelocationResult ProcessPreset(FuncReuses existing folders that differ only by case, so a case-sensitive file system doesn't get a second, case-variant folder. + internal string ReuseExistingFolderCase(string rootPath, string relativePath) + { + var directory = Path.GetDirectoryName(relativePath); + if (string.IsNullOrEmpty(directory)) + return relativePath; + + var segments = directory.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + var current = rootPath; + for (var i = 0; i < segments.Length; i++) + { + if (!fileSystemHelpers.DirectoryExists(Path.Combine(current, segments[i]))) + { + var match = fileSystemHelpers.GetDirectoryPaths(current) + .Select(Path.GetFileName) + .Where(name => string.Equals(name, segments[i], StringComparison.OrdinalIgnoreCase)) + .Order(StringComparer.Ordinal) + .FirstOrDefault(); + if (match is null) + break; + + segments[i] = match; + } + + current = Path.Combine(current, segments[i]); + } + + return Path.Combine(Path.Combine(segments), Path.GetFileName(relativePath)); + } + private void SetLinuxPermissions(string path) { var settings = settingsProvider.GetSettings(); diff --git a/Shoko.Tests/Services/VideoRelocationGuardTests.cs b/Shoko.Tests/Services/VideoRelocationGuardTests.cs index d5b3de5dc..b6b221fd0 100644 --- a/Shoko.Tests/Services/VideoRelocationGuardTests.cs +++ b/Shoko.Tests/Services/VideoRelocationGuardTests.cs @@ -268,4 +268,41 @@ await harness.Service.DirectlyRelocateFile( } #endregion + + #region Case-only changes + + [Fact] + public void AFolderThatOnlyDiffersByCaseFromAnExistingOneIsReusedInsteadOfDuplicated() + { + using var harness = Create(); + harness.FileSystem + .Setup(f => f.GetDirectoryPaths(It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + .Returns([Path.Combine(SourcePath, "Show")]); + + Assert.Equal(Path.Combine("Show", "episode.mkv"), harness.Service.ReuseExistingFolderCase(SourcePath, Path.Combine("show", "episode.mkv"))); + } + + [Fact] + public void AFolderWithoutAnExistingMatchKeepsItsCase() + { + using var harness = Create(); + + Assert.Equal(Path.Combine("show", "episode.mkv"), harness.Service.ReuseExistingFolderCase(SourcePath, Path.Combine("show", "episode.mkv"))); + } + + [Fact] + public async Task ACaseOnlyRenameOnACaseInsensitiveFileSystemIsRejected() + { + using var harness = Create(); + harness.FileSystem.Setup(f => f.GetVideoFileUID(It.IsAny())).Returns(5); + + var response = await harness.Service.DirectlyRelocateFile( + harness.Place, Request(harness.SourceFolder, "Show/Episode.mkv")); + + Assert.False(response.Success); + Assert.Contains("same location", response.Error?.Message ?? string.Empty); + harness.AssertNothingWasMoved(); + } + + #endregion } From 707af136dd2c5ad3629a9ae7d5f42b16447f2e14 Mon Sep 17 00:00:00 2001 From: Foowy <49217685+Foowy@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:32:47 +0000 Subject: [PATCH 2/4] fix(relocation): rename via temp name and clarify same-file abort --- .../Services/VideoRelocationService.cs | 53 +++++++++++++------ .../Services/VideoRelocationGuardTests.cs | 6 +-- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/Shoko.Server/Services/VideoRelocationService.cs b/Shoko.Server/Services/VideoRelocationService.cs index dbe2bc898..6e110671f 100644 --- a/Shoko.Server/Services/VideoRelocationService.cs +++ b/Shoko.Server/Services/VideoRelocationService.cs @@ -1009,6 +1009,8 @@ private RelocationResult ProcessPreset(Func f.GetVideoFileUID(It.IsAny())).Returns(5); + harness.FileSystem.Setup(f => f.GetVideoFileUID(It.IsAny())).Returns((long?)null); var response = await harness.Service.DirectlyRelocateFile( harness.Place, Request(harness.SourceFolder, "Show/Episode.mkv")); Assert.False(response.Success); - Assert.Contains("same location", response.Error?.Message ?? string.Empty); + Assert.Contains("could not verify", response.Error?.Message ?? string.Empty); harness.AssertNothingWasMoved(); } From 1f296598657e59c6671d7c8a46a100b36516bdb0 Mon Sep 17 00:00:00 2001 From: Foowy <49217685+Foowy@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:37:49 +0000 Subject: [PATCH 3/4] fix(relocation): log overwrite of a different file as warning --- Shoko.Server/Services/VideoRelocationService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shoko.Server/Services/VideoRelocationService.cs b/Shoko.Server/Services/VideoRelocationService.cs index 6e110671f..f9c30fd58 100644 --- a/Shoko.Server/Services/VideoRelocationService.cs +++ b/Shoko.Server/Services/VideoRelocationService.cs @@ -1040,7 +1040,7 @@ private RelocationResult ProcessPreset(Func Date: Sun, 11 Oct 2026 12:41:43 +0000 Subject: [PATCH 4/4] chore(relocation): split `ReuseExistingFolderCase` summary over three lines --- Shoko.Server/Services/VideoRelocationService.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Shoko.Server/Services/VideoRelocationService.cs b/Shoko.Server/Services/VideoRelocationService.cs index f9c30fd58..78075af80 100644 --- a/Shoko.Server/Services/VideoRelocationService.cs +++ b/Shoko.Server/Services/VideoRelocationService.cs @@ -1224,7 +1224,10 @@ request.ManagedFolder.AvailableFreeSpace is not -2 && return (RelocationResponse.FromResult(request.ManagedFolder, newRelativePath, moved, renamed), false); } - /// Reuses existing folders that differ only by case, so a case-sensitive file system doesn't get a second, case-variant folder. + /// + /// Reuses existing folders that differ only by case, so a case-sensitive + /// file system doesn't get a second, case-variant folder. + /// internal string ReuseExistingFolderCase(string rootPath, string relativePath) { var directory = Path.GetDirectoryName(relativePath);