Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 78 additions & 16 deletions Shoko.Server/Services/VideoRelocationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1002,30 +1002,46 @@ private RelocationResult ProcessPreset(Func<IRelocationProvider, RelocationConte
"Could not find or access the video file in the file system!"
), true);

var newRelativePath = Path.GetRelativePath(folderPath, fullPath);
var newRelativePath = ReuseExistingFolderCase(folderPath, Path.GetRelativePath(folderPath, fullPath));
var newFolderPath = Path.GetDirectoryName(newRelativePath);
var newFullPath = Path.Combine(folderPath, newRelativePath);
var newFileName = Path.GetFileName(newRelativePath);
var renamed = !string.Equals(Path.GetFileName(oldRelativePath), newFileName, StringComparison.OrdinalIgnoreCase);
var renamed = !string.Equals(Path.GetFileName(oldRelativePath), newFileName, StringComparison.Ordinal);
var moved = !string.Equals(Path.GetDirectoryName(oldFullPath), Path.GetDirectoryName(newFullPath), StringComparison.OrdinalIgnoreCase);

var caseOnlyRename = false;

// Check if we're attempting to move the file onto itself.
if (string.Equals(newFullPath, oldFullPath, StringComparison.OrdinalIgnoreCase))
{
if (!string.Equals(newFullPath, oldFullPath, StringComparison.Ordinal))
if (string.Equals(newFullPath, oldFullPath, StringComparison.Ordinal))
{
logger.LogWarning(
"Resolved to relocate {OldFilePath} to {NewFilePath}. Which is the same location in a case-insensitive file system. Aborting.",
oldFullPath,
newFullPath
);
return (RelocationResponse.FromError(
"Resolved to relocate onto the same location in a case-insensitive file system."
), false);
logger.LogTrace("Resolved to relocate {FilePath} onto itself. Nothing to do.", newFullPath);
return (RelocationResponse.FromResult(request.ManagedFolder, newRelativePath), false);
}

logger.LogTrace("Resolved to relocate {FilePath} onto itself. Nothing to do.", newFullPath);
return (RelocationResponse.FromResult(request.ManagedFolder, newRelativePath), false);
// A valid rename on a case-sensitive file system, or the same file on a case-insensitive one.
if (fileSystemHelpers.FileExists(newFullPath))
{
var oldUid = fileSystemHelpers.GetVideoFileUID(oldFullPath);
var newUid = fileSystemHelpers.GetVideoFileUID(newFullPath);
if (oldUid is null || newUid is null)
{
logger.LogWarning(
"Resolved to relocate {OldFilePath} to {NewFilePath}, which only differs by case, but could not tell if they are the same file. Aborting.",
oldFullPath,
newFullPath
);
return (RelocationResponse.FromError(
"Resolved to relocate onto a path that only differs by case, and could not verify if it is the same file."
), false);
}

if (oldUid == newUid)
caseOnlyRename = true;
else
logger.LogWarning("Resolved to relocate {OldFilePath} over a different file at {NewFilePath}.", oldFullPath, newFullPath);
}
}

// Check if the managed folder can accept the file.
Expand Down Expand Up @@ -1062,7 +1078,7 @@ request.ManagedFolder.AvailableFreeSpace is not -2 &&

var destVideoLocalPlace = videoLocalPlace.GetByRelativePathAndManagedFolderID(newRelativePath, request.ManagedFolder.ID);
var relocatedFile = false;
if (fileSystemHelpers.FileExists(newFullPath))
if (!caseOnlyRename && fileSystemHelpers.FileExists(newFullPath))
{
// A file with the same name exists at the destination.
logger.LogTrace("A file already exists at the new location, checking it for duplicate…");
Expand Down Expand Up @@ -1139,19 +1155,30 @@ request.ManagedFolder.AvailableFreeSpace is not -2 &&
"Cancellation requested before relocation took place."
), false);

if (destVideoLocalPlace is not null)
if (destVideoLocalPlace is not null && !caseOnlyRename)
{
logger.LogTrace("An entry already exists for the new location at {NewPath} but no physical file resides there. Removing the entry.", newFullPath);
await _videoLocalPlaceService.RemoveRecord(destVideoLocalPlace);
}

// Move
var tempFullPath = caseOnlyRename ? newFullPath + ".tmp-name" : null;
fileWatcherService.AddFileWatcherExclusion(oldFullPath);
fileWatcherService.AddFileWatcherExclusion(newFullPath);
if (tempFullPath is not null)
fileWatcherService.AddFileWatcherExclusion(tempFullPath);
logger.LogInformation("Moving file from {PreviousPath} to {NextPath}", oldFullPath, newFullPath);
try
{
fileSystemHelpers.MoveFile(oldFullPath, newFullPath);
if (tempFullPath is not null)
{
fileSystemHelpers.MoveFile(oldFullPath, tempFullPath);
fileSystemHelpers.MoveFile(tempFullPath, newFullPath);
}
else
{
fileSystemHelpers.MoveFile(oldFullPath, newFullPath);
}
SetLinuxPermissions(newFullPath);
}
catch (Exception ex)
Expand All @@ -1164,6 +1191,8 @@ request.ManagedFolder.AvailableFreeSpace is not -2 &&
{
fileWatcherService.RemoveFileWatcherExclusion(oldFullPath);
fileWatcherService.RemoveFileWatcherExclusion(newFullPath);
if (tempFullPath is not null)
fileWatcherService.RemoveFileWatcherExclusion(tempFullPath);
}

place.ManagedFolderID = request.ManagedFolder.ID;
Expand Down Expand Up @@ -1195,6 +1224,39 @@ request.ManagedFolder.AvailableFreeSpace is not -2 &&
return (RelocationResponse.FromResult(request.ManagedFolder, newRelativePath, moved, renamed), false);
}

/// <summary>
/// Reuses existing folders that differ only by case, so a case-sensitive
/// file system doesn't get a second, case-variant folder.
/// </summary>
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();
Expand Down
37 changes: 37 additions & 0 deletions Shoko.Tests/Services/VideoRelocationGuardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(), It.IsAny<bool>(), It.IsAny<Func<string, bool, bool>?>(), It.IsAny<CancellationToken>()))
.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 ACaseOnlyRenameWithAnUnverifiableFileIsRejected()
{
using var harness = Create();
harness.FileSystem.Setup(f => f.GetVideoFileUID(It.IsAny<string>())).Returns((long?)null);

var response = await harness.Service.DirectlyRelocateFile(
harness.Place, Request(harness.SourceFolder, "Show/Episode.mkv"));

Assert.False(response.Success);
Assert.Contains("could not verify", response.Error?.Message ?? string.Empty);
harness.AssertNothingWasMoved();
}

#endregion
}
Loading