diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs index 32c648896..af5571f73 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs @@ -44,6 +44,7 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests _trackedDownload = Builder.CreateNew() .With(c => c.State = TrackedDownloadState.Downloading) + .With(c => c.ImportItem = completed) .With(c => c.DownloadItem = completed) .With(c => c.RemoteEpisode = remoteEpisode) .Build(); diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs index 7946a890a..76990c995 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs @@ -50,6 +50,10 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests .Setup(c => c.Get(It.IsAny())) .Returns(Mocker.GetMock().Object); + Mocker.GetMock() + .Setup(c => c.ProvideImportItem(It.IsAny(), It.IsAny())) + .Returns((DownloadClientItem item, DownloadClientItem previous) => item); + Mocker.GetMock() .Setup(s => s.MostRecentForDownloadId(_trackedDownload.DownloadItem.DownloadId)) .Returns(new EpisodeHistory()); diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs index 5ab2e0509..ca6192a59 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs @@ -1,9 +1,11 @@ using System; -using System.Linq; using System.Collections.Generic; +using System.IO; +using System.Linq; using FluentAssertions; using Moq; using NUnit.Framework; +using NzbDrone.Common.Disk; using NzbDrone.Common.Http; using NzbDrone.Core.MediaFiles.TorrentInfo; using NzbDrone.Core.Download; @@ -122,6 +124,24 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests Mocker.GetMock() .Setup(s => s.GetTorrents(It.IsAny())) .Returns(torrents); + + foreach (var torrent in torrents) + { + Mocker.GetMock() + .Setup(s => s.GetTorrentProperties(torrent.Hash.ToLower(), It.IsAny())) + .Returns(new QBittorrentTorrentProperties { SavePath = torrent.SavePath }); + + Mocker.GetMock() + .Setup(s => s.GetTorrentFiles(torrent.Hash.ToLower(), It.IsAny())) + .Returns(new List { new QBittorrentTorrentFile { Name = torrent.Name } }); + } + } + + private void GivenTorrentFiles(string hash, List files) + { + Mocker.GetMock() + .Setup(s => s.GetTorrentFiles(hash.ToLower(), It.IsAny())) + .Returns(files); } [Test] @@ -256,6 +276,112 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests 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 { torrent }); + GivenTorrentFiles(torrent.Hash, new List { 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 + { + new QBittorrentTorrentFile + { + Name = @"Droned.S01.12\E01.mkv".AsOsAgnostic() + }, + new QBittorrentTorrentFile + { + Name = @"Droned.S01.12\E02.mkv".AsOsAgnostic() + } + }; + + GivenTorrents(new List { 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") + Path.DirectorySeparatorChar); + } + + [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 { torrent }); + + Mocker.GetMock() + .Setup(v => v.GetApiVersion(It.IsAny())) + .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] public void Download_should_return_unique_id() { diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index be9d26225..b7a757e07 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -1,18 +1,18 @@ using System; using System.Linq; using System.Collections.Generic; +using System.Net; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Cache; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles.TorrentInfo; -using NLog; using NzbDrone.Core.Validation; -using FluentValidation.Results; -using System.Net; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.RemotePathMappings; -using NzbDrone.Common.Cache; namespace NzbDrone.Core.Download.Clients.QBittorrent { @@ -122,6 +122,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public override IEnumerable GetItems() { + var version = Proxy.GetApiVersion(Settings); var config = Proxy.GetConfig(Settings); var torrents = Proxy.GetTorrents(Settings); @@ -138,19 +139,18 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this), RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)), RemainingTime = GetRemainingTime(torrent), - SeedRatio = torrent.Ratio, - OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)), + SeedRatio = torrent.Ratio }; + 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. // 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)); - if (!item.OutputPath.IsEmpty && item.OutputPath.FileName != torrent.Name) - { - item.OutputPath += torrent.Name; - } - switch (torrent.State) { case "error": // some error occurred, applies to paused torrents @@ -218,6 +218,49 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent 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; + } + + result.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath); + + return result; + } + public override DownloadClientInfo GetStatus() { var config = Proxy.GetConfig(Settings); diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs index 0bde61ce0..85ab85118 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs @@ -17,6 +17,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent QBittorrentPreferences GetConfig(QBittorrentSettings settings); List GetTorrents(QBittorrentSettings settings); QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings); + List GetTorrentFiles(string hash, QBittorrentSettings settings); void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings); void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentSettings settings); diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs index 14a632687..4624bdf31 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -106,6 +106,14 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent return response; } + public List GetTorrentFiles(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource($"/query/propertiesFiles/{hash}"); + var response = ProcessRequest>(request, settings); + + return response; + } + public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings) { var request = BuildRequest(settings).Resource("/command/download") diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs index cc046475d..ff104cb4f 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -110,6 +110,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent return response; } + public List GetTorrentFiles(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/files") + .AddQueryParam("hash", hash); + var response = ProcessRequest>(request, settings); + + return response; + } + public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings) { var request = BuildRequest(settings).Resource("/api/v2/torrents/add") diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs index 63e93f523..dbfceb0c3 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs @@ -24,6 +24,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent [JsonProperty(PropertyName = "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 [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 + [JsonProperty(PropertyName = "save_path")] + public string SavePath { get; set; } + [JsonProperty(PropertyName = "seeding_time")] public long SeedingTime { get; set; } // Torrent seeding time } + + public class QBittorrentTorrentFile + { + public string Name { get; set; } + } } diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index f78bb95cb..59c5f7a9c 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -28,6 +28,7 @@ namespace NzbDrone.Core.Download { private readonly IEventAggregator _eventAggregator; private readonly IHistoryService _historyService; + private readonly IProvideImportItemService _importItemService; private readonly IDownloadedEpisodesImportService _downloadedEpisodesImportService; private readonly IParsingService _parsingService; private readonly ISeriesService _seriesService; @@ -36,6 +37,7 @@ namespace NzbDrone.Core.Download public CompletedDownloadService(IEventAggregator eventAggregator, IHistoryService historyService, + IProvideImportItemService importItemService, IDownloadedEpisodesImportService downloadedEpisodesImportService, IParsingService parsingService, ISeriesService seriesService, @@ -44,6 +46,7 @@ namespace NzbDrone.Core.Download { _eventAggregator = eventAggregator; _historyService = historyService; + _importItemService = importItemService; _downloadedEpisodesImportService = downloadedEpisodesImportService; _parsingService = parsingService; _seriesService = seriesService; @@ -58,6 +61,8 @@ namespace NzbDrone.Core.Download return; } + trackedDownload.ImportItem = _importItemService.ProvideImportItem(trackedDownload.DownloadItem, trackedDownload.ImportItem); + // Only process tracked downloads that are still downloading if (trackedDownload.State != TrackedDownloadState.Downloading) { @@ -72,7 +77,7 @@ namespace NzbDrone.Core.Download return; } - var downloadItemOutputPath = trackedDownload.DownloadItem.OutputPath; + var downloadItemOutputPath = trackedDownload.ImportItem.OutputPath; if (downloadItemOutputPath.IsEmpty) { @@ -110,7 +115,7 @@ namespace NzbDrone.Core.Download { trackedDownload.State = TrackedDownloadState.Importing; - var outputPath = trackedDownload.DownloadItem.OutputPath.FullPath; + var outputPath = trackedDownload.ImportItem.OutputPath.FullPath; var importResults = _downloadedEpisodesImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteEpisode.Series, trackedDownload.DownloadItem); @@ -185,7 +190,7 @@ namespace NzbDrone.Core.Download .Property("SeriesId", trackedDownload.RemoteEpisode.Series.Id) .Property("DownloadId", trackedDownload.DownloadItem.DownloadId) .Property("Title", trackedDownload.DownloadItem.Title) - .Property("Path", trackedDownload.DownloadItem.OutputPath.ToString()) + .Property("Path", trackedDownload.ImportItem.OutputPath.ToString()) .WriteSentryWarn("DownloadHistoryIncomplete") .Write(); } diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs index 4dfbe7244..3746b4698 100644 --- a/src/NzbDrone.Core/Download/DownloadClientBase.cs +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -59,6 +59,12 @@ namespace NzbDrone.Core.Download public abstract string Download(RemoteEpisode remoteEpisode); public abstract IEnumerable GetItems(); + + public virtual DownloadClientItem GetImportItem(DownloadClientItem item, DownloadClientItem previousImportAttempt) + { + return item; + } + public abstract void RemoveItem(string downloadId, bool deleteData); public abstract DownloadClientInfo GetStatus(); diff --git a/src/NzbDrone.Core/Download/DownloadClientItem.cs b/src/NzbDrone.Core/Download/DownloadClientItem.cs index ead3f4b48..22cf2615d 100644 --- a/src/NzbDrone.Core/Download/DownloadClientItem.cs +++ b/src/NzbDrone.Core/Download/DownloadClientItem.cs @@ -24,6 +24,11 @@ namespace NzbDrone.Core.Download public bool CanMoveFiles { get; set; } public bool CanBeRemoved { get; set; } public bool Removed { get; set; } + + public DownloadClientItem Clone() + { + return MemberwiseClone() as DownloadClientItem; + } } public class DownloadClientItemClientInfo diff --git a/src/NzbDrone.Core/Download/IDownloadClient.cs b/src/NzbDrone.Core/Download/IDownloadClient.cs index f396b7c34..108f79f15 100644 --- a/src/NzbDrone.Core/Download/IDownloadClient.cs +++ b/src/NzbDrone.Core/Download/IDownloadClient.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using NzbDrone.Common.Disk; using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ThingiProvider; @@ -10,6 +11,7 @@ namespace NzbDrone.Core.Download DownloadProtocol Protocol { get; } string Download(RemoteEpisode remoteEpisode); IEnumerable GetItems(); + DownloadClientItem GetImportItem(DownloadClientItem item, DownloadClientItem previousImportAttempt); void RemoveItem(string downloadId, bool deleteData); DownloadClientInfo GetStatus(); void MarkItemAsImported(DownloadClientItem downloadClientItem); diff --git a/src/NzbDrone.Core/Download/PrepareImportService.cs b/src/NzbDrone.Core/Download/PrepareImportService.cs new file mode 100644 index 000000000..a816d2341 --- /dev/null +++ b/src/NzbDrone.Core/Download/PrepareImportService.cs @@ -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); + } + } +} diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs index 64e4a15f6..45d792006 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs @@ -7,6 +7,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads { public int DownloadClient { get; set; } public DownloadClientItem DownloadItem { get; set; } + public DownloadClientItem ImportItem { get; set; } public TrackedDownloadState State { get; set; } public TrackedDownloadStatus Status { get; private set; } public RemoteEpisode RemoteEpisode { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs index 253828af0..d7463ea16 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs @@ -79,7 +79,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual return new List(); } - path = trackedDownload.DownloadItem.OutputPath.FullPath; + path = trackedDownload.ImportItem.OutputPath.FullPath; } if (!_diskProvider.FolderExists(path)) @@ -383,14 +383,15 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual { var trackedDownload = groupedTrackedDownload.First().TrackedDownload; var importedSeries = imported.First().ImportDecision.LocalEpisode.Series; + var outputPath = trackedDownload.ImportItem.OutputPath.FullPath; - if (_diskProvider.FolderExists(trackedDownload.DownloadItem.OutputPath.FullPath)) + if (_diskProvider.FolderExists(outputPath)) { if (_downloadedEpisodesImportService.ShouldDeleteFolder( - new DirectoryInfo(trackedDownload.DownloadItem.OutputPath.FullPath), importedSeries) && + new DirectoryInfo(outputPath), importedSeries) && trackedDownload.DownloadItem.CanMoveFiles) { - _diskProvider.DeleteFolder(trackedDownload.DownloadItem.OutputPath.FullPath, true); + _diskProvider.DeleteFolder(outputPath, true); } }