From 7c1169d0dd145789d6703cf739574412efab6034 Mon Sep 17 00:00:00 2001 From: jwmann Date: Sat, 25 Apr 2026 13:04:40 -0400 Subject: [PATCH 1/3] Fix QBit Import Path for Single File Torrents --- .../Services/QBittorrentTest.cs | 56 +++++++++++++++++++ .../RdtClient.Service/Services/QBittorrent.cs | 34 ++++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/server/RdtClient.Service.Test/Services/QBittorrentTest.cs b/server/RdtClient.Service.Test/Services/QBittorrentTest.cs index 6e74451..e050dee 100644 --- a/server/RdtClient.Service.Test/Services/QBittorrentTest.cs +++ b/server/RdtClient.Service.Test/Services/QBittorrentTest.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.Logging; using Moq; +using System.Text.Json; +using RdtClient.Data.Data; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Data.Models.DebridClient; @@ -208,4 +210,58 @@ public class QBittorrentTest Assert.NotNull(result); Assert.False(result!.IsPrivate); } + + [Fact] + public async Task TorrentInfo_ShouldUseSelectedTopLevelFileName_WhenRootFileAddsOnlyTheExtension() + { + // Arrange + var previousMappedPath = SettingData.Get.DownloadClient.MappedPath; + SettingData.Get.DownloadClient.MappedPath = "/data/downloads"; + + try + { + var torrentRootName = "Example.Series.S01E01.1080p.WEB-DL-GROUP"; + var selectedFileName = $"{torrentRootName}.mkv"; + + var torrent = new Torrent + { + Hash = "hash1", + Category = "tv", + Completed = DateTimeOffset.UtcNow, + RdName = torrentRootName, + RdFiles = JsonSerializer.Serialize(new List + { + new() + { + Id = 1, + Path = $"/{selectedFileName}", + Bytes = 1000, + Selected = true + }, + new() + { + Id = 2, + Path = $"/{torrentRootName}.nfo", + Bytes = 10, + Selected = false + } + }), + Type = DownloadType.Torrent + }; + + _torrentsMock.Setup(m => m.Get()).ReturnsAsync(new List { torrent }); + + // Act + var result = await _qBittorrent.TorrentInfo(); + + // Assert + Assert.Single(result); + Assert.Equal(Path.Combine("/data/downloads", "tv", selectedFileName) + Path.DirectorySeparatorChar, result[0].ContentPath); + Assert.Equal(torrentRootName, result[0].Name); + } + finally + { + SettingData.Get.DownloadClient.MappedPath = previousMappedPath; + } + } } diff --git a/server/RdtClient.Service/Services/QBittorrent.cs b/server/RdtClient.Service/Services/QBittorrent.cs index a69b6a6..9f7d6cf 100644 --- a/server/RdtClient.Service/Services/QBittorrent.cs +++ b/server/RdtClient.Service/Services/QBittorrent.cs @@ -204,6 +204,7 @@ public class QBittorrent(ILogger logger, Settings settings, Authent } var torrentPath = downloadPath; + var contentPathName = GetContentPathName(torrent); if (!String.IsNullOrWhiteSpace(torrent.RdName)) { @@ -214,7 +215,7 @@ public class QBittorrent(ILogger logger, Settings settings, Authent } else { - torrentPath = Path.Combine(downloadPath, torrent.RdName) + Path.DirectorySeparatorChar; + torrentPath = Path.Combine(downloadPath, contentPathName) + Path.DirectorySeparatorChar; } } @@ -328,6 +329,37 @@ public class QBittorrent(ILogger logger, Settings settings, Authent return results; } + private static String GetContentPathName(Torrent torrent) + { + if (String.IsNullOrWhiteSpace(torrent.RdName)) + { + return torrent.RdName ?? String.Empty; + } + + var topLevelSelectedFiles = torrent.Files + .Where(m => m.Selected && !String.IsNullOrWhiteSpace(m.Path)) + .Select(m => m.Path.Trim('/').Trim('\\')) + .Where(m => m.IndexOfAny(['/', '\\']) < 0) + .Select(Path.GetFileName) + .Where(m => !String.IsNullOrWhiteSpace(m)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (topLevelSelectedFiles.Count == 1) + { + var selectedFileName = topLevelSelectedFiles[0]!; + var selectedFileBaseName = Path.GetFileNameWithoutExtension(selectedFileName); + + if (!String.IsNullOrWhiteSpace(selectedFileBaseName) && + selectedFileBaseName.Equals(torrent.RdName, StringComparison.OrdinalIgnoreCase)) + { + return selectedFileName; + } + } + + return torrent.RdName; + } + public async Task?> TorrentFileContents(String hash) { var torrent = await torrents.GetByHash(hash); From cd1310c1e9ec7aaf2badc46ad5f7151588b2b8df Mon Sep 17 00:00:00 2001 From: jwmann Date: Tue, 28 Apr 2026 20:24:27 -0400 Subject: [PATCH 2/3] Add additional compatibility when a Torrent stores its media file within a Sub Folder --- .../Services/QBittorrentTest.cs | 129 ++++++++++++++++- .../RdtClient.Service/Services/QBittorrent.cs | 133 +++++++++++++++++- 2 files changed, 259 insertions(+), 3 deletions(-) diff --git a/server/RdtClient.Service.Test/Services/QBittorrentTest.cs b/server/RdtClient.Service.Test/Services/QBittorrentTest.cs index e050dee..27f6470 100644 --- a/server/RdtClient.Service.Test/Services/QBittorrentTest.cs +++ b/server/RdtClient.Service.Test/Services/QBittorrentTest.cs @@ -220,7 +220,7 @@ public class QBittorrentTest try { - var torrentRootName = "Example.Series.S01E01.1080p.WEB-DL-GROUP"; + var torrentRootName = "Sample.Show.S01E01.1080p.WEB-DL-GROUP"; var selectedFileName = $"{torrentRootName}.mkv"; var torrent = new Torrent @@ -264,4 +264,131 @@ public class QBittorrentTest SettingData.Get.DownloadClient.MappedPath = previousMappedPath; } } + + [Fact] + public async Task TorrentInfo_ShouldUseExistingCompletedContentRoot_WhenSingleFileDirectoryDiffersFromRdName() + { + var previousMappedPath = SettingData.Get.DownloadClient.MappedPath; + var mappedPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + SettingData.Get.DownloadClient.MappedPath = mappedPath; + + try + { + var category = "tv"; + var selectedFileName = "Sample.Show.S01E02.1080p.WEB-DL-GROUP.mkv"; + var contentRoot = Path.Combine(mappedPath, category, selectedFileName); + Directory.CreateDirectory(contentRoot); + await File.WriteAllTextAsync(Path.Combine(contentRoot, selectedFileName), "test"); + + var torrent = new Torrent + { + Hash = "hash1", + Category = category, + Completed = DateTimeOffset.UtcNow, + Downloads = new List + { + new() + { + DownloadId = Guid.NewGuid(), + FileName = selectedFileName, + Link = "https://fake.url/Sample.Show.S01E02.1080p.WEB-DL-GROUP.mkv", + Completed = DateTimeOffset.UtcNow + } + }, + RdName = "tracker.example - Sample.Show.S01E02.1080p.WEB-DL-GROUP", + RdFiles = JsonSerializer.Serialize(new List + { + new() + { + Id = 1, + Path = $"/{selectedFileName}", + Bytes = 1000, + Selected = true + } + }), + Type = DownloadType.Torrent + }; + + _torrentsMock.Setup(m => m.Get()).ReturnsAsync(new List { torrent }); + + var result = await _qBittorrent.TorrentInfo(); + + Assert.Single(result); + Assert.Equal(contentRoot + Path.DirectorySeparatorChar, result[0].ContentPath); + } + finally + { + SettingData.Get.DownloadClient.MappedPath = previousMappedPath; + + if (Directory.Exists(mappedPath)) + { + Directory.Delete(mappedPath, true); + } + } + } + + [Fact] + public async Task TorrentInfo_ShouldUseExistingCompletedContentRoot_WhenSelectedFileIsNestedUnderDifferentRoot() + { + var previousMappedPath = SettingData.Get.DownloadClient.MappedPath; + var mappedPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + SettingData.Get.DownloadClient.MappedPath = mappedPath; + + try + { + var category = "tv"; + var rootDirectoryName = "tracker.example - Sample.Show.S01E03.1080p.x265-GROUP.mkv"; + var nestedDirectoryName = "Sample.Show.S01E03.1080p.x265-GROUP"; + var fileName = "Sample.Show.S01E03.1080p.x265-GROUP.mkv"; + var contentRoot = Path.Combine(mappedPath, category, rootDirectoryName); + var nestedDirectory = Path.Combine(contentRoot, nestedDirectoryName); + Directory.CreateDirectory(nestedDirectory); + await File.WriteAllTextAsync(Path.Combine(nestedDirectory, fileName), "test"); + + var torrent = new Torrent + { + Hash = "hash1", + Category = category, + Completed = DateTimeOffset.UtcNow, + Downloads = new List + { + new() + { + DownloadId = Guid.NewGuid(), + FileName = fileName, + Link = "https://fake.url/Sample.Show.S01E03.1080p.x265-GROUP.mkv", + Completed = DateTimeOffset.UtcNow + } + }, + RdName = "tracker.example - Sample.Show.S01E03.1080p.x265-GROUP", + RdFiles = JsonSerializer.Serialize(new List + { + new() + { + Id = 1, + Path = $"/{nestedDirectoryName}/{fileName}", + Bytes = 1000, + Selected = true + } + }), + Type = DownloadType.Torrent + }; + + _torrentsMock.Setup(m => m.Get()).ReturnsAsync(new List { torrent }); + + var result = await _qBittorrent.TorrentInfo(); + + Assert.Single(result); + Assert.Equal(contentRoot + Path.DirectorySeparatorChar, result[0].ContentPath); + } + finally + { + SettingData.Get.DownloadClient.MappedPath = previousMappedPath; + + if (Directory.Exists(mappedPath)) + { + Directory.Delete(mappedPath, true); + } + } + } } diff --git a/server/RdtClient.Service/Services/QBittorrent.cs b/server/RdtClient.Service/Services/QBittorrent.cs index 9f7d6cf..0192ee4 100644 --- a/server/RdtClient.Service/Services/QBittorrent.cs +++ b/server/RdtClient.Service/Services/QBittorrent.cs @@ -204,7 +204,6 @@ public class QBittorrent(ILogger logger, Settings settings, Authent } var torrentPath = downloadPath; - var contentPathName = GetContentPathName(torrent); if (!String.IsNullOrWhiteSpace(torrent.RdName)) { @@ -215,7 +214,17 @@ public class QBittorrent(ILogger logger, Settings settings, Authent } else { - torrentPath = Path.Combine(downloadPath, contentPathName) + Path.DirectorySeparatorChar; + var existingContentPath = GetExistingContentPath(downloadPath, torrent); + + if (!String.IsNullOrWhiteSpace(existingContentPath)) + { + torrentPath = existingContentPath; + } + else + { + var contentPathName = GetContentPathName(torrent); + torrentPath = Path.Combine(downloadPath, contentPathName) + Path.DirectorySeparatorChar; + } } } @@ -360,6 +369,126 @@ public class QBittorrent(ILogger logger, Settings settings, Authent return torrent.RdName; } + private static String? GetExistingContentPath(String downloadPath, Torrent torrent) + { + if (!torrent.Completed.HasValue || !Directory.Exists(downloadPath)) + { + return null; + } + + var selectedFilePaths = torrent.Files + .Where(m => m.Selected && !String.IsNullOrWhiteSpace(m.Path)) + .Select(m => NormalizeRelativePath(m.Path!)) + .Where(m => !String.IsNullOrWhiteSpace(m)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var downloadFileNames = torrent.Downloads + .Select(m => m.FileName) + .Where(m => !String.IsNullOrWhiteSpace(m)) + .Select(m => Path.GetFileName(m!)) + .Where(m => !String.IsNullOrWhiteSpace(m)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (selectedFilePaths.Count == 0 && downloadFileNames.Count == 0) + { + return null; + } + + foreach (var candidateRoot in GetCandidateContentRoots(downloadPath, torrent, selectedFilePaths, downloadFileNames)) + { + if (IsMatchingContentRoot(candidateRoot, selectedFilePaths, downloadFileNames)) + { + return candidateRoot + Path.DirectorySeparatorChar; + } + } + + return null; + } + + private static IEnumerable GetCandidateContentRoots(String downloadPath, + Torrent torrent, + IEnumerable selectedFilePaths, + IEnumerable downloadFileNames) + { + var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); + + void AddCandidate(ICollection candidates, String? name) + { + if (!String.IsNullOrWhiteSpace(name)) + { + candidates.Add(Path.Combine(downloadPath, name)); + } + } + + var directCandidates = new List(); + + AddCandidate(directCandidates, torrent.RdName); + + foreach (var fileName in downloadFileNames) + { + AddCandidate(directCandidates, fileName); + } + + foreach (var selectedFilePath in selectedFilePaths) + { + AddCandidate(directCandidates, Path.GetFileName(selectedFilePath)); + AddCandidate(directCandidates, GetFirstPathComponent(selectedFilePath)); + } + + foreach (var candidate in directCandidates) + { + if (Directory.Exists(candidate) && yielded.Add(candidate)) + { + yield return candidate; + } + } + + foreach (var directory in Directory.EnumerateDirectories(downloadPath)) + { + if (yielded.Add(directory)) + { + yield return directory; + } + } + } + + private static Boolean IsMatchingContentRoot(String candidateRoot, + IEnumerable selectedFilePaths, + IEnumerable downloadFileNames) + { + foreach (var selectedFilePath in selectedFilePaths) + { + if (File.Exists(Path.Combine(candidateRoot, selectedFilePath))) + { + return true; + } + } + + foreach (var fileName in downloadFileNames) + { + if (File.Exists(Path.Combine(candidateRoot, fileName))) + { + return true; + } + } + + return false; + } + + private static String NormalizeRelativePath(String path) + { + return path.Trim('/').Trim('\\').Replace('\\', Path.DirectorySeparatorChar); + } + + private static String GetFirstPathComponent(String path) + { + var separatorIndex = path.IndexOfAny(['/', '\\']); + + return separatorIndex < 0 ? path : path[..separatorIndex]; + } + public async Task?> TorrentFileContents(String hash) { var torrent = await torrents.GetByHash(hash); From 6c5d1fb39df70e51d5e513624d971c4ff44764b8 Mon Sep 17 00:00:00 2001 From: jwmann Date: Fri, 1 May 2026 15:28:58 -0400 Subject: [PATCH 3/3] Remove duplicate import --- server/RdtClient.Service.Test/Services/QBittorrentTest.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/server/RdtClient.Service.Test/Services/QBittorrentTest.cs b/server/RdtClient.Service.Test/Services/QBittorrentTest.cs index 27f6470..80b23b6 100644 --- a/server/RdtClient.Service.Test/Services/QBittorrentTest.cs +++ b/server/RdtClient.Service.Test/Services/QBittorrentTest.cs @@ -6,7 +6,6 @@ using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Data.Models.DebridClient; using RdtClient.Service.Services; -using System.Text.Json; namespace RdtClient.Service.Test.Services;