From 7b80e038cc915374dd317e27ece2f6777a21d652 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sun, 5 Apr 2026 22:11:22 +0300 Subject: [PATCH] Fix rTorrent path evaluation for items without a subdirectory (#548) --- .../RTorrent/Response/RTorrentTorrent.cs | 9 +- .../DownloadClient/RTorrentServiceDCTests.cs | 89 +++++++++++++++++++ .../DownloadClient/RTorrent/RTorrentClient.cs | 8 +- .../RTorrent/RTorrentServiceDC.cs | 2 +- 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/code/backend/Cleanuparr.Domain/Entities/RTorrent/Response/RTorrentTorrent.cs b/code/backend/Cleanuparr.Domain/Entities/RTorrent/Response/RTorrentTorrent.cs index c494b3aa..ac25c43a 100644 --- a/code/backend/Cleanuparr.Domain/Entities/RTorrent/Response/RTorrentTorrent.cs +++ b/code/backend/Cleanuparr.Domain/Entities/RTorrent/Response/RTorrentTorrent.cs @@ -61,10 +61,17 @@ public sealed record RTorrentTorrent public string? Label { get; init; } /// - /// Base path where the torrent data is stored + /// Base path where the torrent data is stored. + /// For multi-file torrents this is the torrent directory; for single-file torrents this is the full file path. /// public string? BasePath { get; init; } + /// + /// Directory containing the torrent data (from d.directory). + /// Unlike BasePath, this always points to a directory for both single-file and multi-file torrents. + /// + public string? Directory { get; init; } + /// /// List of tracker URLs for this torrent /// diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs index eb6ec394..3b6171e2 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs @@ -660,6 +660,95 @@ public class RTorrentServiceDCTests : IClassFixture Times.Once); } + [Fact] + public async Task UsesDirectoryOverBasePathForFilePath() + { + // Arrange + var sut = _fixture.CreateSut(); + + var unlinkedConfig = new UnlinkedConfig + { + Id = Guid.NewGuid(), + TargetCategory = "unlinked" + }; + + // Single-file torrent: BasePath is the full file path, Directory is the containing dir + var downloads = new List + { + new RTorrentItemWrapper(new RTorrentTorrent + { + Hash = "HASH1", Name = "movie.mkv", Label = "movies", + BasePath = "/downloads/movie.mkv", + Directory = "/downloads" + }) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFilesAsync("HASH1")) + .ReturnsAsync(new List + { + new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); + + // Assert - path should use Directory (/downloads), not BasePath (/downloads/movie.mkv) + var expectedPath = string.Join(Path.DirectorySeparatorChar, + Path.Combine("/downloads", "movie.mkv").Split('\\', '/')); + _fixture.HardLinkFileService.Verify( + x => x.GetHardLinkCount(expectedPath, false), + Times.Once); + } + + [Fact] + public async Task FallsBackToBasePathWhenDirectoryNull() + { + // Arrange + var sut = _fixture.CreateSut(); + + var unlinkedConfig = new UnlinkedConfig + { + Id = Guid.NewGuid(), + TargetCategory = "unlinked" + }; + + var downloads = new List + { + new RTorrentItemWrapper(new RTorrentTorrent + { + Hash = "HASH1", Name = "Test", Label = "movies", + BasePath = "/downloads", + Directory = null + }) + }; + + _fixture.ClientWrapper + .Setup(x => x.GetTorrentFilesAsync("HASH1")) + .ReturnsAsync(new List + { + new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 } + }); + + _fixture.HardLinkFileService + .Setup(x => x.GetHardLinkCount(It.IsAny(), It.IsAny())) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); + + // Assert - path should fall back to BasePath + var expectedPath = string.Join(Path.DirectorySeparatorChar, + Path.Combine("/downloads", "file1.mkv").Split('\\', '/')); + _fixture.HardLinkFileService.Verify( + x => x.GetHardLinkCount(expectedPath, false), + Times.Once); + } + [Fact] public async Task UpdatesCategoryOnWrapper() { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentClient.cs index 1318c21d..42c5bfd7 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentClient.cs @@ -29,7 +29,8 @@ public sealed class RTorrentClient "d.complete=", "d.timestamp.finished=", "d.custom1=", - "d.base_path=" + "d.base_path=", + "d.directory=" ]; // Fields to request when fetching file data via f.multicall @@ -327,7 +328,7 @@ public sealed class RTorrentClient private static RTorrentTorrent? CreateTorrentFromValues(object?[] values) { - if (values.Length < 12) return null; + if (values.Length < 13) return null; return new RTorrentTorrent { @@ -342,7 +343,8 @@ public sealed class RTorrentClient Complete = Convert.ToInt32(values[8] ?? 0), TimestampFinished = Convert.ToInt64(values[9] ?? 0), Label = values[10]?.ToString(), - BasePath = values[11]?.ToString() + BasePath = values[11]?.ToString(), + Directory = values[12]?.ToString() }; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs index 4f59b976..44ef0825 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs @@ -93,7 +93,7 @@ public partial class RTorrentService foreach (var file in files) { string filePath = string.Join(Path.DirectorySeparatorChar, - Path.Combine(torrent.Info.BasePath ?? "", file.Path).Split(['\\', '/'])); + Path.Combine(torrent.Info.Directory ?? torrent.Info.BasePath ?? "", file.Path).Split(['\\', '/'])); filePath = PathHelper.RemapPath(filePath, unlinkedConfig.DownloadDirectorySource, unlinkedConfig.DownloadDirectoryTarget);