diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs index 30b785b2d..92ca8407f 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs @@ -37,6 +37,7 @@ public void Setup() _trackedDownload = Builder.CreateNew() .With(c => c.State = TrackedDownloadState.Downloading) + .With(c => c.ImportItem = completed) .With(c => c.DownloadItem = completed) .With(c => c.RemoteMovie = remoteMovie) .Build(); diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs index 0a3dafb12..a7aa1dcbf 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs @@ -47,6 +47,10 @@ public void Setup() .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 MovieHistory()); diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs index aa835d775..53aa778a9 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.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.Download; using NzbDrone.Core.Download.Clients.QBittorrent; @@ -124,6 +126,24 @@ protected virtual void GivenTorrents(List torrents) 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] @@ -259,6 +279,78 @@ public void stalledDL_item_should_have_required_properties() } [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")); + } + public void missingFiles_item_should_have_required_properties() { var torrent = new QBittorrentTorrent @@ -279,6 +371,39 @@ public void missingFiles_item_should_have_required_properties() item.RemainingTime.Should().NotHaveValue(); } + [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 fcd6c8694..bb83bab0e 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using FluentValidation.Results; using NLog; @@ -122,6 +123,7 @@ protected override string AddFromTorrentFile(RemoteMovie remoteMovie, string has public override IEnumerable GetItems() { + var version = Proxy.GetApiVersion(Settings); var config = Proxy.GetConfig(Settings); var torrents = Proxy.GetTorrents(Settings); @@ -138,19 +140,18 @@ public override IEnumerable GetItems() 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 @@ -224,6 +225,49 @@ public override void RemoveItem(string hash, bool deleteData) 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() { 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 bbd308eea..248d96bb2 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs @@ -16,6 +16,7 @@ public interface IQBittorrentProxy 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 a2d690503..e7e8f1e6e 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -105,6 +105,14 @@ public QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorren 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 02296097b..29a3551b0 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -110,6 +110,15 @@ public QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorren 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 @@ public class QBittorrentTorrent [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 @@ public class QBittorrentTorrentProperties { 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 b6e3921be..b4ff3719d 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -27,6 +27,7 @@ public class CompletedDownloadService : ICompletedDownloadService { private readonly IEventAggregator _eventAggregator; private readonly IHistoryService _historyService; + private readonly IProvideImportItemService _importItemService; private readonly IDownloadedMovieImportService _downloadedMovieImportService; private readonly IParsingService _parsingService; private readonly IMovieService _movieService; @@ -35,6 +36,7 @@ public class CompletedDownloadService : ICompletedDownloadService public CompletedDownloadService(IEventAggregator eventAggregator, IHistoryService historyService, + IProvideImportItemService importItemService, IDownloadedMovieImportService downloadedMovieImportService, IParsingService parsingService, IMovieService movieService, @@ -43,6 +45,7 @@ public CompletedDownloadService(IEventAggregator eventAggregator, { _eventAggregator = eventAggregator; _historyService = historyService; + _importItemService = importItemService; _downloadedMovieImportService = downloadedMovieImportService; _parsingService = parsingService; _movieService = movieService; @@ -57,6 +60,8 @@ public void Check(TrackedDownload trackedDownload) return; } + trackedDownload.ImportItem = _importItemService.ProvideImportItem(trackedDownload.DownloadItem, trackedDownload.ImportItem); + // Only process tracked downloads that are still downloading if (trackedDownload.State != TrackedDownloadState.Downloading) { @@ -71,7 +76,7 @@ public void Check(TrackedDownload trackedDownload) return; } - var downloadItemOutputPath = trackedDownload.DownloadItem.OutputPath; + var downloadItemOutputPath = trackedDownload.ImportItem.OutputPath; if (downloadItemOutputPath.IsEmpty) { @@ -109,7 +114,7 @@ public void Import(TrackedDownload trackedDownload) { trackedDownload.State = TrackedDownloadState.Importing; - var outputPath = trackedDownload.DownloadItem.OutputPath.FullPath; + var outputPath = trackedDownload.ImportItem.OutputPath.FullPath; if (trackedDownload.RemoteMovie?.Movie == null) { @@ -183,7 +188,7 @@ public bool VerifyImport(TrackedDownload trackedDownload, List imp .Property("MovieId", trackedDownload.RemoteMovie.Movie.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 88c6c13ae..b4520d92b 100644 --- a/src/NzbDrone.Core/Download/DownloadClientBase.cs +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -66,6 +66,12 @@ public abstract DownloadProtocol Protocol public abstract string Download(RemoteMovie remoteMovie); 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 e548b027d..aa44a3acf 100644 --- a/src/NzbDrone.Core/Download/DownloadClientItem.cs +++ b/src/NzbDrone.Core/Download/DownloadClientItem.cs @@ -25,6 +25,11 @@ public class DownloadClientItem 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 31eb310d3..f9c857ee8 100644 --- a/src/NzbDrone.Core/Download/IDownloadClient.cs +++ b/src/NzbDrone.Core/Download/IDownloadClient.cs @@ -1,4 +1,5 @@ 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 @@ public interface IDownloadClient : IProvider DownloadProtocol Protocol { get; } string Download(RemoteMovie remoteMovie); 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 d10dd0256..f0623dc84 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs @@ -7,6 +7,7 @@ public class TrackedDownload { 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 RemoteMovie RemoteMovie { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/MovieImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/MovieImport/Manual/ManualImportService.cs index 0242df594..76e48914e 100644 --- a/src/NzbDrone.Core/MediaFiles/MovieImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/MovieImport/Manual/ManualImportService.cs @@ -73,7 +73,7 @@ public List GetMediaFiles(string path, string downloadId, int? return new List(); } - path = trackedDownload.DownloadItem.OutputPath.FullPath; + path = trackedDownload.ImportItem.OutputPath.FullPath; } if (!_diskProvider.FolderExists(path)) @@ -305,14 +305,15 @@ public void Execute(ManualImportCommand message) var trackedDownload = groupedTrackedDownload.First().TrackedDownload; var importMovie = groupedTrackedDownload.First().ImportResult.ImportDecision.LocalMovie.Movie; + var outputPath = trackedDownload.ImportItem.OutputPath.FullPath; - if (_diskProvider.FolderExists(trackedDownload.DownloadItem.OutputPath.FullPath)) + if (_diskProvider.FolderExists(outputPath)) { if (_downloadedMovieImportService.ShouldDeleteFolder( - new DirectoryInfo(trackedDownload.DownloadItem.OutputPath.FullPath), + new DirectoryInfo(outputPath), importMovie) && trackedDownload.DownloadItem.CanMoveFiles) { - _diskProvider.DeleteFolder(trackedDownload.DownloadItem.OutputPath.FullPath, true); + _diskProvider.DeleteFolder(outputPath, true); } }