Merge pull request #967 from jwmann/feature/FixQBittorrentImportPath

Fix multiple qBittorrent Import Path issues for Completed Torrent & Fix compatibility with Sonarr
This commit is contained in:
Roger Far 2026-05-17 13:51:17 -06:00 committed by GitHub
commit bc9fb89ecb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 345 additions and 2 deletions

View file

@ -1,10 +1,11 @@
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;
using RdtClient.Service.Services;
using System.Text.Json;
namespace RdtClient.Service.Test.Services;
@ -208,4 +209,185 @@ 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 = "Sample.Show.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<DebridClientFile>
{
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> { 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;
}
}
[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<Download>
{
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<DebridClientFile>
{
new()
{
Id = 1,
Path = $"/{selectedFileName}",
Bytes = 1000,
Selected = true
}
}),
Type = DownloadType.Torrent
};
_torrentsMock.Setup(m => m.Get()).ReturnsAsync(new List<Torrent> { 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<Download>
{
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<DebridClientFile>
{
new()
{
Id = 1,
Path = $"/{nestedDirectoryName}/{fileName}",
Bytes = 1000,
Selected = true
}
}),
Type = DownloadType.Torrent
};
_torrentsMock.Setup(m => m.Get()).ReturnsAsync(new List<Torrent> { 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);
}
}
}
}

View file

@ -214,7 +214,17 @@ public class QBittorrent(ILogger<QBittorrent> logger, Settings settings, Authent
}
else
{
torrentPath = Path.Combine(downloadPath, torrent.RdName) + 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;
}
}
}
@ -328,6 +338,157 @@ public class QBittorrent(ILogger<QBittorrent> 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;
}
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<String> GetCandidateContentRoots(String downloadPath,
Torrent torrent,
IEnumerable<String> selectedFilePaths,
IEnumerable<String> downloadFileNames)
{
var yielded = new HashSet<String>(StringComparer.OrdinalIgnoreCase);
void AddCandidate(ICollection<String> candidates, String? name)
{
if (!String.IsNullOrWhiteSpace(name))
{
candidates.Add(Path.Combine(downloadPath, name));
}
}
var directCandidates = new List<String>();
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<String> selectedFilePaths,
IEnumerable<String> 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<IList<TorrentFileItem>?> TorrentFileContents(String hash)
{
var torrent = await torrents.GetByHash(hash);