From cd1310c1e9ec7aaf2badc46ad5f7151588b2b8df Mon Sep 17 00:00:00 2001 From: jwmann Date: Tue, 28 Apr 2026 20:24:27 -0400 Subject: [PATCH] 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);