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/1800/head
ta264 4 years ago committed by Qstick
parent 0232e8c93e
commit 39b57cfe6e

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

@ -45,6 +45,10 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
.Setup(c => c.Get(It.IsAny<int>()))
.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>()
.Setup(s => s.MostRecentForDownloadId(_trackedDownload.DownloadItem.DownloadId))
.Returns(new History.History());

@ -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 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
Mocker.GetMock<IQBittorrentProxy>()
.Setup(s => s.GetTorrents(It.IsAny<QBittorrentSettings>()))
.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]
@ -259,6 +279,78 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
}
[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"));
}
public void missingFiles_item_should_have_required_properties()
{
var torrent = new QBittorrentTorrent
@ -279,6 +371,39 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
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<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]
public void Download_should_return_unique_id()
{

@ -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 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
public override IEnumerable<DownloadClientItem> GetItems()
{
var version = Proxy.GetApiVersion(Settings);
var config = Proxy.GetConfig(Settings);
var torrents = Proxy.GetTorrents(Settings);
@ -138,19 +140,18 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
DownloadClient = Definition.Name,
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 @@ 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.FileName;
}
result.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath);
return result;
}
public override DownloadClientInfo GetStatus()
{
var config = Proxy.GetConfig(Settings);

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

@ -107,6 +107,14 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
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)
{
var request = BuildRequest(settings).Resource("/command/download")

@ -105,6 +105,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
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)
{
var request = BuildRequest(settings).Resource("/api/v2/torrents/add")

@ -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; }
}
}

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NzbDrone.Common.EnvironmentInfo;
@ -25,16 +26,19 @@ namespace NzbDrone.Core.Download
private readonly IHistoryService _historyService;
private readonly IDownloadedTracksImportService _downloadedTracksImportService;
private readonly IArtistService _artistService;
private readonly IProvideImportItemService _importItemService;
private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported;
public CompletedDownloadService(IEventAggregator eventAggregator,
IHistoryService historyService,
IProvideImportItemService importItemService,
IDownloadedTracksImportService downloadedTracksImportService,
IArtistService artistService,
ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported)
{
_eventAggregator = eventAggregator;
_historyService = historyService;
_importItemService = importItemService;
_downloadedTracksImportService = downloadedTracksImportService;
_artistService = artistService;
_trackedDownloadAlreadyImported = trackedDownloadAlreadyImported;
@ -48,6 +52,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)
{
@ -62,7 +68,7 @@ namespace NzbDrone.Core.Download
return;
}
var downloadItemOutputPath = trackedDownload.DownloadItem.OutputPath;
var downloadItemOutputPath = trackedDownload.ImportItem.OutputPath;
if (downloadItemOutputPath.IsEmpty)
{
@ -100,7 +106,7 @@ namespace NzbDrone.Core.Download
{
trackedDownload.State = TrackedDownloadState.Importing;
var outputPath = trackedDownload.DownloadItem.OutputPath.FullPath;
var outputPath = trackedDownload.ImportItem.OutputPath.FullPath;
var importResults = _downloadedTracksImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteAlbum.Artist, trackedDownload.DownloadItem);
if (importResults.Empty())

@ -62,6 +62,12 @@ namespace NzbDrone.Core.Download
public abstract string Download(RemoteAlbum remoteAlbum);
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 DownloadClientInfo GetStatus();

@ -1,6 +1,7 @@
using System;
using System.Diagnostics;
using NzbDrone.Common.Disk;
using NzbDrone.Core.Indexers;
namespace NzbDrone.Core.Download
{
@ -25,6 +26,20 @@ 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
{
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; }
}

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.ThingiProvider;
@ -11,6 +11,7 @@ namespace NzbDrone.Core.Download
string Download(RemoteAlbum remoteAlbum);
IEnumerable<DownloadClientItem> GetItems();
DownloadClientItem GetImportItem(DownloadClientItem item, DownloadClientItem previousImportAttempt);
void RemoveItem(string downloadId, bool deleteData);
DownloadClientInfo GetStatus();
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 DownloadClientItem DownloadItem { get; set; }
public DownloadClientItem ImportItem { get; set; }
public TrackedDownloadState State { get; set; }
public TrackedDownloadStatus Status { get; private set; }
public RemoteAlbum RemoteAlbum { get; set; }

@ -89,7 +89,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
return new List<ManualImportItem>();
}
path = trackedDownload.DownloadItem.OutputPath.FullPath;
path = trackedDownload.ImportItem.OutputPath.FullPath;
}
if (!_diskProvider.FolderExists(path))
@ -363,13 +363,15 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
{
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(
_diskProvider.GetDirectoryInfo(trackedDownload.DownloadItem.OutputPath.FullPath),
_diskProvider.GetDirectoryInfo(outputPath),
trackedDownload.RemoteAlbum.Artist) && trackedDownload.DownloadItem.CanMoveFiles)
{
_diskProvider.DeleteFolder(trackedDownload.DownloadItem.OutputPath.FullPath, true);
_diskProvider.DeleteFolder(outputPath, true);
}
}

Loading…
Cancel
Save