Fixed: Get actual file names from QBittorrent API (#5226)

* Fixed: Get actual file names from QBittorrent API

Previously we were assuming that the output filename was the category
output dir + the torrent name.  This isn't true if the torrent has
been renamed or sanitized.
pull/659/head
ta264 4 years ago
parent 6791787655
commit eb50b4f09f

@ -37,6 +37,7 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
_trackedDownload = Builder<TrackedDownload>.CreateNew() _trackedDownload = Builder<TrackedDownload>.CreateNew()
.With(c => c.State = TrackedDownloadState.Downloading) .With(c => c.State = TrackedDownloadState.Downloading)
.With(c => c.ImportItem = completed)
.With(c => c.DownloadItem = completed) .With(c => c.DownloadItem = completed)
.With(c => c.RemoteBook = remoteBook) .With(c => c.RemoteBook = remoteBook)
.Build(); .Build();

@ -45,6 +45,10 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
.Setup(c => c.Get(It.IsAny<int>())) .Setup(c => c.Get(It.IsAny<int>()))
.Returns(Mocker.GetMock<IDownloadClient>().Object); .Returns(Mocker.GetMock<IDownloadClient>().Object);
Mocker.GetMock<IProvideImportItemService>()
.Setup(c => c.ProvideImportItem(It.IsAny<DownloadClientItem>(), It.IsAny<DownloadClientItem>()))
.Returns((DownloadClientItem item, DownloadClientItem previous) => item);
Mocker.GetMock<IHistoryService>() Mocker.GetMock<IHistoryService>()
.Setup(s => s.MostRecentForDownloadId(_trackedDownload.DownloadItem.DownloadId)) .Setup(s => s.MostRecentForDownloadId(_trackedDownload.DownloadItem.DownloadId))
.Returns(new History.History()); .Returns(new History.History());

@ -1,9 +1,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
using NzbDrone.Core.Download; using NzbDrone.Core.Download;
using NzbDrone.Core.Download.Clients.QBittorrent; using NzbDrone.Core.Download.Clients.QBittorrent;
@ -124,6 +126,24 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
Mocker.GetMock<IQBittorrentProxy>() Mocker.GetMock<IQBittorrentProxy>()
.Setup(s => s.GetTorrents(It.IsAny<QBittorrentSettings>())) .Setup(s => s.GetTorrents(It.IsAny<QBittorrentSettings>()))
.Returns(torrents); .Returns(torrents);
foreach (var torrent in torrents)
{
Mocker.GetMock<IQBittorrentProxy>()
.Setup(s => s.GetTorrentProperties(torrent.Hash.ToLower(), It.IsAny<QBittorrentSettings>()))
.Returns(new QBittorrentTorrentProperties { SavePath = torrent.SavePath });
Mocker.GetMock<IQBittorrentProxy>()
.Setup(s => s.GetTorrentFiles(torrent.Hash.ToLower(), It.IsAny<QBittorrentSettings>()))
.Returns(new List<QBittorrentTorrentFile> { new QBittorrentTorrentFile { Name = torrent.Name } });
}
}
private void GivenTorrentFiles(string hash, List<QBittorrentTorrentFile> files)
{
Mocker.GetMock<IQBittorrentProxy>()
.Setup(s => s.GetTorrentFiles(hash.ToLower(), It.IsAny<QBittorrentSettings>()))
.Returns(files);
} }
[Test] [Test]
@ -258,6 +278,112 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
item.RemainingTime.Should().NotHaveValue(); item.RemainingTime.Should().NotHaveValue();
} }
[Test]
public void single_file_torrent_outputpath_should_have_sanitised_name()
{
var torrent = new QBittorrentTorrent
{
Hash = "HASH",
Name = @"Droned.S01E01.Test\'s.1080p.WEB-DL-DRONE.mkv",
Size = 1000,
Progress = 0.7,
Eta = 8640000,
State = "stalledDL",
Label = "",
SavePath = @"C:\Torrents".AsOsAgnostic()
};
var file = new QBittorrentTorrentFile
{
Name = "Droned.S01E01.Tests.1080p.WEB-DL-DRONE.mkv"
};
GivenTorrents(new List<QBittorrentTorrent> { torrent });
GivenTorrentFiles(torrent.Hash, new List<QBittorrentTorrentFile> { file });
var item = new DownloadClientItem
{
DownloadId = torrent.Hash
};
var result = Subject.GetImportItem(item, null);
result.OutputPath.FullPath.Should().Be(Path.Combine(torrent.SavePath, file.Name));
}
[Test]
public void multi_file_torrent_outputpath_should_have_sanitised_name()
{
var torrent = new QBittorrentTorrent
{
Hash = "HASH",
Name = @"Droned.S01.\1/2",
Size = 1000,
Progress = 0.7,
Eta = 8640000,
State = "stalledDL",
Label = "",
SavePath = @"C:\Torrents".AsOsAgnostic()
};
var files = new List<QBittorrentTorrentFile>
{
new QBittorrentTorrentFile
{
Name = @"Droned.S01.12\E01.mkv".AsOsAgnostic()
},
new QBittorrentTorrentFile
{
Name = @"Droned.S01.12\E02.mkv".AsOsAgnostic()
}
};
GivenTorrents(new List<QBittorrentTorrent> { torrent });
GivenTorrentFiles(torrent.Hash, files);
var item = new DownloadClientItem
{
DownloadId = torrent.Hash
};
var result = Subject.GetImportItem(item, null);
result.OutputPath.FullPath.Should().Be(Path.Combine(torrent.SavePath, "Droned.S01.12"));
}
[Test]
public void api_261_should_use_content_path()
{
var torrent = new QBittorrentTorrent
{
Hash = "HASH",
Name = @"Droned.S01.\1/2",
Size = 1000,
Progress = 0.7,
Eta = 8640000,
State = "stalledDL",
Label = "",
SavePath = @"C:\Torrents".AsOsAgnostic(),
ContentPath = @"C:\Torrents\Droned.S01.12".AsOsAgnostic()
};
GivenTorrents(new List<QBittorrentTorrent> { torrent });
Mocker.GetMock<IQBittorrentProxy>()
.Setup(v => v.GetApiVersion(It.IsAny<QBittorrentSettings>()))
.Returns(new Version(2, 6, 1));
var item = new DownloadClientItem
{
DownloadId = torrent.Hash,
OutputPath = new OsPath(torrent.ContentPath)
};
var result = Subject.GetImportItem(item, null);
result.OutputPath.FullPath.Should().Be(torrent.ContentPath);
}
[Test] [Test]
public void Download_should_return_unique_id() public void Download_should_return_unique_id()
{ {

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net; using System.Net;
using FluentValidation.Results; using FluentValidation.Results;
using NLog; using NLog;
@ -122,6 +123,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
public override IEnumerable<DownloadClientItem> GetItems() public override IEnumerable<DownloadClientItem> GetItems()
{ {
var version = Proxy.GetApiVersion(Settings);
var config = Proxy.GetConfig(Settings); var config = Proxy.GetConfig(Settings);
var torrents = Proxy.GetTorrents(Settings); var torrents = Proxy.GetTorrents(Settings);
@ -138,19 +140,18 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
DownloadClient = Definition.Name, DownloadClient = Definition.Name,
RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)), RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)),
RemainingTime = GetRemainingTime(torrent), RemainingTime = GetRemainingTime(torrent),
SeedRatio = torrent.Ratio, SeedRatio = torrent.Ratio
OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)),
}; };
if (version >= new Version("2.6.1"))
{
item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.ContentPath));
}
// Avoid removing torrents that haven't reached the global max ratio. // Avoid removing torrents that haven't reached the global max ratio.
// Removal also requires the torrent to be paused, in case a higher max ratio was set on the torrent itself (which is not exposed by the api). // Removal also requires the torrent to be paused, in case a higher max ratio was set on the torrent itself (which is not exposed by the api).
item.CanMoveFiles = item.CanBeRemoved = torrent.State == "pausedUP" && HasReachedSeedLimit(torrent, config); item.CanMoveFiles = item.CanBeRemoved = torrent.State == "pausedUP" && HasReachedSeedLimit(torrent, config);
if (!item.OutputPath.IsEmpty && item.OutputPath.FileName != torrent.Name)
{
item.OutputPath += torrent.Name;
}
switch (torrent.State) switch (torrent.State)
{ {
case "error": // some error occurred, applies to paused torrents case "error": // some error occurred, applies to paused torrents
@ -219,6 +220,49 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
Proxy.RemoveTorrent(hash.ToLower(), deleteData, Settings); Proxy.RemoveTorrent(hash.ToLower(), deleteData, Settings);
} }
public override DownloadClientItem GetImportItem(DownloadClientItem item, DownloadClientItem previousImportAttempt)
{
// On API version >= 2.6.1 this is already set correctly
if (!item.OutputPath.IsEmpty)
{
return item;
}
var files = Proxy.GetTorrentFiles(item.DownloadId.ToLower(), Settings);
if (!files.Any())
{
_logger.Debug($"No files found for torrent {item.Title} in qBittorrent");
return item;
}
var properties = Proxy.GetTorrentProperties(item.DownloadId.ToLower(), Settings);
var savePath = new OsPath(properties.SavePath);
var result = item.Clone();
OsPath outputPath;
if (files.Count == 1)
{
outputPath = savePath + files[0].Name;
}
else
{
// we have multiple files in the torrent so just get
// the first subdirectory
var relativePath = new OsPath(files[0].Name);
while (!relativePath.Directory.IsEmpty)
{
relativePath = relativePath.Directory;
}
outputPath = savePath + relativePath.FileName;
}
result.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath);
return result;
}
public override DownloadClientInfo GetStatus() public override DownloadClientInfo GetStatus()
{ {
var config = Proxy.GetConfig(Settings); var config = Proxy.GetConfig(Settings);

@ -16,6 +16,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
QBittorrentPreferences GetConfig(QBittorrentSettings settings); QBittorrentPreferences GetConfig(QBittorrentSettings settings);
List<QBittorrentTorrent> GetTorrents(QBittorrentSettings settings); List<QBittorrentTorrent> GetTorrents(QBittorrentSettings settings);
QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings); QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings);
List<QBittorrentTorrentFile> GetTorrentFiles(string hash, QBittorrentSettings settings);
void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings); void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings);
void AddTorrentFromFile(string fileName, byte[] fileContent, QBittorrentSettings settings); void AddTorrentFromFile(string fileName, byte[] fileContent, QBittorrentSettings settings);

@ -107,6 +107,14 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
return response; return response;
} }
public List<QBittorrentTorrentFile> GetTorrentFiles(string hash, QBittorrentSettings settings)
{
var request = BuildRequest(settings).Resource($"/query/propertiesFiles/{hash}");
var response = ProcessRequest<List<QBittorrentTorrentFile>>(request, settings);
return response;
}
public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings) public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings)
{ {
var request = BuildRequest(settings).Resource("/command/download") var request = BuildRequest(settings).Resource("/command/download")

@ -105,6 +105,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
return response; return response;
} }
public List<QBittorrentTorrentFile> GetTorrentFiles(string hash, QBittorrentSettings settings)
{
var request = BuildRequest(settings).Resource("/api/v2/torrents/files")
.AddQueryParam("hash", hash);
var response = ProcessRequest<List<QBittorrentTorrentFile>>(request, settings);
return response;
}
public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings) public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings)
{ {
var request = BuildRequest(settings).Resource("/api/v2/torrents/add") var request = BuildRequest(settings).Resource("/api/v2/torrents/add")

@ -24,6 +24,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
[JsonProperty(PropertyName = "save_path")] [JsonProperty(PropertyName = "save_path")]
public string SavePath { get; set; } // Torrent save path public string SavePath { get; set; } // Torrent save path
[JsonProperty(PropertyName = "content_path")]
public string ContentPath { get; set; } // Torrent save path
public float Ratio { get; set; } // Torrent share ratio public float Ratio { get; set; } // Torrent share ratio
[JsonProperty(PropertyName = "ratio_limit")] // Per torrent seeding ratio limit (-2 = use global, -1 = unlimited) [JsonProperty(PropertyName = "ratio_limit")] // Per torrent seeding ratio limit (-2 = use global, -1 = unlimited)
@ -40,7 +43,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
{ {
public string Hash { get; set; } // Torrent hash public string Hash { get; set; } // Torrent hash
[JsonProperty(PropertyName = "save_path")]
public string SavePath { get; set; }
[JsonProperty(PropertyName = "seeding_time")] [JsonProperty(PropertyName = "seeding_time")]
public long SeedingTime { get; set; } // Torrent seeding time public long SeedingTime { get; set; } // Torrent seeding time
} }
public class QBittorrentTorrentFile
{
public string Name { get; set; }
}
} }

@ -26,16 +26,19 @@ namespace NzbDrone.Core.Download
private readonly IHistoryService _historyService; private readonly IHistoryService _historyService;
private readonly IDownloadedBooksImportService _downloadedTracksImportService; private readonly IDownloadedBooksImportService _downloadedTracksImportService;
private readonly IAuthorService _authorService; private readonly IAuthorService _authorService;
private readonly IProvideImportItemService _importItemService;
private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported; private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported;
public CompletedDownloadService(IEventAggregator eventAggregator, public CompletedDownloadService(IEventAggregator eventAggregator,
IHistoryService historyService, IHistoryService historyService,
IProvideImportItemService importItemService,
IDownloadedBooksImportService downloadedTracksImportService, IDownloadedBooksImportService downloadedTracksImportService,
IAuthorService authorService, IAuthorService authorService,
ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported) ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported)
{ {
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_historyService = historyService; _historyService = historyService;
_importItemService = importItemService;
_downloadedTracksImportService = downloadedTracksImportService; _downloadedTracksImportService = downloadedTracksImportService;
_authorService = authorService; _authorService = authorService;
_trackedDownloadAlreadyImported = trackedDownloadAlreadyImported; _trackedDownloadAlreadyImported = trackedDownloadAlreadyImported;
@ -49,6 +52,8 @@ namespace NzbDrone.Core.Download
return; return;
} }
trackedDownload.ImportItem = _importItemService.ProvideImportItem(trackedDownload.DownloadItem, trackedDownload.ImportItem);
// Only process tracked downloads that are still downloading // Only process tracked downloads that are still downloading
if (trackedDownload.State != TrackedDownloadState.Downloading) if (trackedDownload.State != TrackedDownloadState.Downloading)
{ {
@ -63,7 +68,7 @@ namespace NzbDrone.Core.Download
return; return;
} }
var downloadItemOutputPath = trackedDownload.DownloadItem.OutputPath; var downloadItemOutputPath = trackedDownload.ImportItem.OutputPath;
if (downloadItemOutputPath.IsEmpty) if (downloadItemOutputPath.IsEmpty)
{ {
@ -101,7 +106,7 @@ namespace NzbDrone.Core.Download
{ {
trackedDownload.State = TrackedDownloadState.Importing; trackedDownload.State = TrackedDownloadState.Importing;
var outputPath = trackedDownload.DownloadItem.OutputPath.FullPath; var outputPath = trackedDownload.ImportItem.OutputPath.FullPath;
var importResults = _downloadedTracksImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteBook.Author, trackedDownload.DownloadItem); var importResults = _downloadedTracksImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteBook.Author, trackedDownload.DownloadItem);
if (importResults.Empty()) if (importResults.Empty())

@ -62,6 +62,12 @@ namespace NzbDrone.Core.Download
public abstract string Download(RemoteBook remoteBook); public abstract string Download(RemoteBook remoteBook);
public abstract IEnumerable<DownloadClientItem> GetItems(); public abstract IEnumerable<DownloadClientItem> GetItems();
public virtual DownloadClientItem GetImportItem(DownloadClientItem item, DownloadClientItem previousImportAttempt)
{
return item;
}
public abstract void RemoveItem(string downloadId, bool deleteData); public abstract void RemoveItem(string downloadId, bool deleteData);
public abstract DownloadClientInfo GetStatus(); public abstract DownloadClientInfo GetStatus();

@ -1,6 +1,7 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Core.Indexers;
namespace NzbDrone.Core.Download namespace NzbDrone.Core.Download
{ {
@ -25,6 +26,20 @@ namespace NzbDrone.Core.Download
public bool CanMoveFiles { get; set; } public bool CanMoveFiles { get; set; }
public bool CanBeRemoved { get; set; } public bool CanBeRemoved { get; set; }
public bool Removed { get; set; }
public DownloadClientItem Clone()
{
return MemberwiseClone() as DownloadClientItem;
}
}
public class DownloadClientItemClientInfo
{
public DownloadProtocol Protocol { get; set; }
public string Type { get; set; }
public int Id { get; set; }
public string Name { get; set; }
public bool Removed { get; set; } public bool Removed { get; set; }
} }

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.ThingiProvider;
@ -11,6 +11,7 @@ namespace NzbDrone.Core.Download
string Download(RemoteBook remoteBook); string Download(RemoteBook remoteBook);
IEnumerable<DownloadClientItem> GetItems(); IEnumerable<DownloadClientItem> GetItems();
DownloadClientItem GetImportItem(DownloadClientItem item, DownloadClientItem previousImportAttempt);
void RemoveItem(string downloadId, bool deleteData); void RemoveItem(string downloadId, bool deleteData);
DownloadClientInfo GetStatus(); DownloadClientInfo GetStatus();
void MarkItemAsImported(DownloadClientItem downloadClientItem); void MarkItemAsImported(DownloadClientItem downloadClientItem);

@ -0,0 +1,24 @@
namespace NzbDrone.Core.Download
{
public interface IProvideImportItemService
{
DownloadClientItem ProvideImportItem(DownloadClientItem item, DownloadClientItem previousImportAttempt);
}
public class ProvideImportItemService : IProvideImportItemService
{
private readonly IProvideDownloadClient _downloadClientProvider;
public ProvideImportItemService(IProvideDownloadClient downloadClientProvider)
{
_downloadClientProvider = downloadClientProvider;
}
public DownloadClientItem ProvideImportItem(DownloadClientItem item, DownloadClientItem previousImportAttempt)
{
var client = _downloadClientProvider.Get(item.DownloadClientInfo.Id);
return client.GetImportItem(item, previousImportAttempt);
}
}
}

@ -7,6 +7,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads
{ {
public int DownloadClient { get; set; } public int DownloadClient { get; set; }
public DownloadClientItem DownloadItem { get; set; } public DownloadClientItem DownloadItem { get; set; }
public DownloadClientItem ImportItem { get; set; }
public TrackedDownloadState State { get; set; } public TrackedDownloadState State { get; set; }
public TrackedDownloadStatus Status { get; private set; } public TrackedDownloadStatus Status { get; private set; }
public RemoteBook RemoteBook { get; set; } public RemoteBook RemoteBook { get; set; }

@ -86,7 +86,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual
return new List<ManualImportItem>(); return new List<ManualImportItem>();
} }
path = trackedDownload.DownloadItem.OutputPath.FullPath; path = trackedDownload.ImportItem.OutputPath.FullPath;
} }
if (!_diskProvider.FolderExists(path)) if (!_diskProvider.FolderExists(path))
@ -348,13 +348,15 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual
{ {
var trackedDownload = groupedTrackedDownload.First().TrackedDownload; var trackedDownload = groupedTrackedDownload.First().TrackedDownload;
if (_diskProvider.FolderExists(trackedDownload.DownloadItem.OutputPath.FullPath)) var outputPath = trackedDownload.ImportItem.OutputPath.FullPath;
if (_diskProvider.FolderExists(outputPath))
{ {
if (_downloadedTracksImportService.ShouldDeleteFolder( if (_downloadedTracksImportService.ShouldDeleteFolder(
_diskProvider.GetDirectoryInfo(trackedDownload.DownloadItem.OutputPath.FullPath), _diskProvider.GetDirectoryInfo(outputPath),
trackedDownload.RemoteBook.Author) && trackedDownload.DownloadItem.CanMoveFiles) trackedDownload.RemoteBook.Author) && trackedDownload.DownloadItem.CanMoveFiles)
{ {
_diskProvider.DeleteFolder(trackedDownload.DownloadItem.OutputPath.FullPath, true); _diskProvider.DeleteFolder(outputPath, true);
} }
} }

Loading…
Cancel
Save