diff --git a/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs b/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs new file mode 100644 index 000000000..7c7ce0fe0 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients.Flood.Models; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download.Clients.Flood +{ + public class Flood : TorrentClientBase + { + private readonly IFloodProxy _proxy; + + public Flood(IFloodProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IRemotePathMappingService remotePathMappingService, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger) + { + _proxy = proxy; + } + + private static IEnumerable HandleTags(RemoteBook remoteBook, FloodSettings settings) + { + var result = new HashSet(); + + if (settings.Tags.Any()) + { + result.UnionWith(settings.Tags); + } + + if (settings.AdditionalTags.Any()) + { + foreach (var additionalTag in settings.AdditionalTags) + { + switch (additionalTag) + { + case (int)AdditionalTags.Author: + result.Add(remoteBook.Author.Name); + break; + case (int)AdditionalTags.Format: + result.Add(remoteBook.ParsedBookInfo.Quality.Quality.ToString()); + break; + case (int)AdditionalTags.ReleaseGroup: + result.Add(remoteBook.ParsedBookInfo.ReleaseGroup); + break; + case (int)AdditionalTags.Year: + result.UnionWith(remoteBook.Books.ConvertAll(book => book.ReleaseDate.Value.Year.ToString())); + break; + case (int)AdditionalTags.Indexer: + result.Add(remoteBook.Release.Indexer); + break; + default: + throw new DownloadClientException("Unexpected additional tag ID"); + } + } + } + + return result; + } + + public override string Name => "Flood"; + public override ProviderMessage Message => new ProviderMessage("Readarr is unable to remove torrents that have finished seeding when using Flood", ProviderMessageType.Warning); + + protected override string AddFromTorrentFile(RemoteBook remoteBook, string hash, string filename, byte[] fileContent) + { + _proxy.AddTorrentByFile(Convert.ToBase64String(fileContent), HandleTags(remoteBook, Settings), Settings); + + return hash; + } + + protected override string AddFromMagnetLink(RemoteBook remoteBook, string hash, string magnetLink) + { + _proxy.AddTorrentByUrl(magnetLink, HandleTags(remoteBook, Settings), Settings); + + return hash; + } + + public override IEnumerable GetItems() + { + var items = new List(); + + var list = _proxy.GetTorrents(Settings); + + foreach (var torrent in list) + { + var properties = torrent.Value; + + if (!Settings.Tags.All(tag => properties.Tags.Contains(tag))) + { + continue; + } + + var item = new DownloadClientItem + { + DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this), + DownloadId = torrent.Key, + Title = properties.Name, + OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(properties.Directory)), + Category = properties.Tags.Count > 0 ? properties.Tags[0] : null, + RemainingSize = properties.SizeBytes - properties.BytesDone, + TotalSize = properties.SizeBytes, + SeedRatio = properties.Ratio, + Message = properties.Message, + }; + + if (properties.Eta > 0) + { + item.RemainingTime = TimeSpan.FromSeconds(properties.Eta); + } + + if (properties.Status.Contains("error")) + { + item.Status = DownloadItemStatus.Warning; + } + else if (properties.Status.Contains("seeding") || properties.Status.Contains("complete")) + { + item.Status = DownloadItemStatus.Completed; + } + else if (properties.Status.Contains("downloading")) + { + item.Status = DownloadItemStatus.Downloading; + } + else if (properties.Status.Contains("stopped")) + { + item.Status = DownloadItemStatus.Paused; + } + + item.CanMoveFiles = item.CanBeRemoved = false; + + items.Add(item); + } + + return items; + } + + public override DownloadClientItem GetImportItem(DownloadClientItem item, DownloadClientItem previousImportAttempt) + { + var result = item.Clone(); + + var contentPaths = _proxy.GetTorrentContentPaths(item.DownloadId, Settings); + + if (contentPaths.Count < 1) + { + throw new DownloadClientUnavailableException($"Failed to fetch list of contents of torrent: {item.DownloadId}"); + } + + if (contentPaths.Count == 1) + { + // For single-file torrent, OutputPath should be the path of file. + result.OutputPath = item.OutputPath + new OsPath(contentPaths[0]); + } + else + { + // For multi-file torrent, OutputPath should be the path of base directory of torrent. + var baseDirectoryPaths = contentPaths.ConvertAll(path => + path.Split(new char[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries)[0]); + + // Check first segment (directory) of paths of contents. If all contents share the same directory, use that directory. + if (baseDirectoryPaths.TrueForAll(path => path == baseDirectoryPaths[0])) + { + result.OutputPath = item.OutputPath + new OsPath(baseDirectoryPaths[0]); + } + + // Otherwise, OutputPath is already the base directory. + } + + return result; + } + + public override void MarkItemAsImported(DownloadClientItem downloadClientItem) + { + if (Settings.PostImportTags.Any()) + { + var list = _proxy.GetTorrents(Settings); + + if (list.ContainsKey(downloadClientItem.DownloadId)) + { + _proxy.SetTorrentsTags(downloadClientItem.DownloadId, + list[downloadClientItem.DownloadId].Tags.Concat(Settings.PostImportTags).ToHashSet(), + Settings); + } + } + } + + public override void RemoveItem(string downloadId, bool deleteData) + { + _proxy.DeleteTorrent(downloadId, deleteData, Settings); + } + + public override DownloadClientInfo GetStatus() + { + return new DownloadClientInfo + { + IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "::1" || Settings.Host == "localhost", + OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(Settings.Destination)) } + }; + } + + protected override void Test(List failures) + { + try + { + _proxy.AuthVerify(Settings); + } + catch (DownloadClientAuthenticationException ex) + { + failures.Add(new ValidationFailure("Password", ex.Message)); + } + catch (Exception ex) + { + failures.Add(new ValidationFailure("Host", ex.Message)); + } + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs b/src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs new file mode 100644 index 000000000..ddebdbfff --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs @@ -0,0 +1,213 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Download.Clients.Flood.Types; + +namespace NzbDrone.Core.Download.Clients.Flood +{ + public interface IFloodProxy + { + void AuthVerify(FloodSettings settings); + void AddTorrentByUrl(string url, IEnumerable tags, FloodSettings settings); + void AddTorrentByFile(string file, IEnumerable tags, FloodSettings settings); + void DeleteTorrent(string hash, bool deleteData, FloodSettings settings); + Dictionary GetTorrents(FloodSettings settings); + List GetTorrentContentPaths(string hash, FloodSettings settings); + void SetTorrentsTags(string hash, IEnumerable tags, FloodSettings settings); + } + + public class FloodProxy : IFloodProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + private readonly ICached> _authCookieCache; + + public FloodProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } + + private string BuildUrl(FloodSettings settings) + { + return $"{(settings.UseSsl ? "https://" : "http://")}{settings.Host}:{settings.Port}/{settings.UrlBase}"; + } + + private string BuildCachedCookieKey(FloodSettings settings) + { + return $"{BuildUrl(settings)}:{settings.Username}"; + } + + private HttpRequestBuilder BuildRequest(FloodSettings settings) + { + var requestBuilder = new HttpRequestBuilder(HttpUri.CombinePath(BuildUrl(settings), "/api")) + { + LogResponseContent = true, + NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + }; + + requestBuilder.Headers.ContentType = "application/json"; + requestBuilder.SetCookies(AuthAuthenticate(requestBuilder, settings)); + + return requestBuilder; + } + + private HttpResponse HandleRequest(HttpRequest request, FloodSettings settings) + { + try + { + return _httpClient.Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Forbidden || + ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _authCookieCache.Remove(BuildCachedCookieKey(settings)); + throw new DownloadClientAuthenticationException("Failed to authenticate with Flood."); + } + + throw new DownloadClientException("Unable to connect to Flood, please check your settings"); + } + catch + { + throw new DownloadClientException("Unable to connect to Flood, please check your settings"); + } + } + + private Dictionary AuthAuthenticate(HttpRequestBuilder requestBuilder, FloodSettings settings, bool force = false) + { + var cachedCookies = _authCookieCache.Find(BuildCachedCookieKey(settings)); + + if (cachedCookies == null || force) + { + var authenticateRequest = requestBuilder.Resource("/auth/authenticate").Post().Build(); + + var body = new Dictionary + { + { "username", settings.Username }, + { "password", settings.Password } + }; + authenticateRequest.SetContent(body.ToJson()); + + var response = HandleRequest(authenticateRequest, settings); + cachedCookies = response.GetCookies(); + _authCookieCache.Set(BuildCachedCookieKey(settings), cachedCookies); + } + + return cachedCookies; + } + + public void AuthVerify(FloodSettings settings) + { + var verifyRequest = BuildRequest(settings).Resource("/auth/verify").Build(); + + verifyRequest.Method = HttpMethod.GET; + + HandleRequest(verifyRequest, settings); + } + + public void AddTorrentByFile(string file, IEnumerable tags, FloodSettings settings) + { + var addRequest = BuildRequest(settings).Resource("/torrents/add-files").Post().Build(); + + var body = new Dictionary + { + { "files", new List { file } }, + { "tags", tags.ToList() } + }; + + if (settings.Destination != null) + { + body.Add("destination", settings.Destination); + } + + if (!settings.AddPaused) + { + body.Add("start", true); + } + + addRequest.SetContent(body.ToJson()); + + HandleRequest(addRequest, settings); + } + + public void AddTorrentByUrl(string url, IEnumerable tags, FloodSettings settings) + { + var addRequest = BuildRequest(settings).Resource("/torrents/add-urls").Post().Build(); + + var body = new Dictionary + { + { "urls", new List { url } }, + { "tags", tags.ToList() } + }; + + if (settings.Destination != null) + { + body.Add("destination", settings.Destination); + } + + if (!settings.AddPaused) + { + body.Add("start", true); + } + + addRequest.SetContent(body.ToJson()); + + HandleRequest(addRequest, settings); + } + + public void DeleteTorrent(string hash, bool deleteData, FloodSettings settings) + { + var deleteRequest = BuildRequest(settings).Resource("/torrents/delete").Post().Build(); + + var body = new Dictionary + { + { "hashes", new List { hash } }, + { "deleteData", deleteData } + }; + deleteRequest.SetContent(body.ToJson()); + + HandleRequest(deleteRequest, settings); + } + + public Dictionary GetTorrents(FloodSettings settings) + { + var getTorrentsRequest = BuildRequest(settings).Resource("/torrents").Build(); + + getTorrentsRequest.Method = HttpMethod.GET; + + return Json.Deserialize(HandleRequest(getTorrentsRequest, settings).Content).Torrents; + } + + public List GetTorrentContentPaths(string hash, FloodSettings settings) + { + var contentsRequest = BuildRequest(settings).Resource($"/torrents/{hash}/contents").Build(); + + contentsRequest.Method = HttpMethod.GET; + + return Json.Deserialize>(HandleRequest(contentsRequest, settings).Content).ConvertAll(content => content.Path); + } + + public void SetTorrentsTags(string hash, IEnumerable tags, FloodSettings settings) + { + var tagsRequest = BuildRequest(settings).Resource("/torrents/tags").Build(); + + tagsRequest.Method = HttpMethod.PATCH; + + var body = new Dictionary + { + { "hashes", new List { hash } }, + { "tags", tags.ToList() } + }; + tagsRequest.SetContent(body.ToJson()); + + HandleRequest(tagsRequest, settings); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs b/src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs new file mode 100644 index 000000000..7741c4540 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Download.Clients.Flood.Models; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Flood +{ + public class FloodSettingsValidator : AbstractValidator + { + public FloodSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + } + } + + public class FloodSettings : IProviderConfig + { + private static readonly FloodSettingsValidator Validator = new FloodSettingsValidator(); + + public FloodSettings() + { + UseSsl = false; + Host = "localhost"; + Port = 3000; + Tags = new string[] + { + "readarr" + }; + AdditionalTags = Enumerable.Empty(); + AddPaused = false; + } + + [FieldDefinition(0, Label = "Use SSL", Type = FieldType.Checkbox)] + public bool UseSsl { get; set; } + + [FieldDefinition(1, Label = "Host", Type = FieldType.Textbox)] + public string Host { get; set; } + + [FieldDefinition(2, Label = "Port", Type = FieldType.Textbox)] + public int Port { get; set; } + + [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, HelpText = "Optionally adds a prefix to Flood API, such as [protocol]://[host]:[port]/[urlBase]api")] + public string UrlBase { get; set; } + + [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] + public string Username { get; set; } + + [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + public string Password { get; set; } + + [FieldDefinition(6, Label = "Destination", Type = FieldType.Textbox, HelpText = "Manually specifies download destination")] + public string Destination { get; set; } + + [FieldDefinition(7, Label = "Tags", Type = FieldType.Tag, HelpText = "Initial tags of a download. To be recognized, a download must have all initial tags. This avoids conflicts with unrelated downloads.")] + public IEnumerable Tags { get; set; } + + [FieldDefinition(8, Label = "Post-Import Tags", Type = FieldType.Tag, HelpText = "Appends tags after a download is imported.", Advanced = true)] + public IEnumerable PostImportTags { get; set; } + + [FieldDefinition(9, Label = "Additional Tags", Type = FieldType.Select, SelectOptions = typeof(AdditionalTags), HelpText = "Adds properties of media as tags. Hints are examples.", Advanced = true)] + public IEnumerable AdditionalTags { get; set; } + + [FieldDefinition(10, Label = "Add Paused", Type = FieldType.Checkbox)] + public bool AddPaused { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/Models/AdditionalTags.cs b/src/NzbDrone.Core/Download/Clients/Flood/Models/AdditionalTags.cs new file mode 100644 index 000000000..2aa6d3ea7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/Models/AdditionalTags.cs @@ -0,0 +1,22 @@ +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.Download.Clients.Flood.Models +{ + public enum AdditionalTags + { + [FieldOption(Hint = "J.R.R. Tolkien")] + Author = 0, + + [FieldOption(Hint = "EPUB")] + Format = 1, + + [FieldOption(Hint = "Example-Raws")] + ReleaseGroup = 2, + + [FieldOption(Hint = "1954")] + Year = 3, + + [FieldOption(Hint = "Torznab")] + Indexer = 4, + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/Types/Torrent.cs b/src/NzbDrone.Core/Download/Clients/Flood/Types/Torrent.cs new file mode 100644 index 000000000..3f3500307 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/Types/Torrent.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Flood.Types +{ + public sealed class Torrent + { + [JsonProperty(PropertyName = "bytesDone")] + public long BytesDone { get; set; } + + [JsonProperty(PropertyName = "directory")] + public string Directory { get; set; } + + [JsonProperty(PropertyName = "eta")] + public long Eta { get; set; } + + [JsonProperty(PropertyName = "message")] + public string Message { get; set; } + + [JsonProperty(PropertyName = "name")] + public string Name { get; set; } + + [JsonProperty(PropertyName = "ratio")] + public float Ratio { get; set; } + + [JsonProperty(PropertyName = "sizeBytes")] + public long SizeBytes { get; set; } + + [JsonProperty(PropertyName = "status")] + public List Status { get; set; } + + [JsonProperty(PropertyName = "tags")] + public List Tags { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentContent.cs b/src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentContent.cs new file mode 100644 index 000000000..6dfd7cb98 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentContent.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Flood.Types +{ + public sealed class TorrentContent + { + [JsonProperty(PropertyName = "path")] + public string Path { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentListSummary.cs b/src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentListSummary.cs new file mode 100644 index 000000000..2d81cfba7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentListSummary.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Flood.Types +{ + public sealed class TorrentListSummary + { + [JsonProperty(PropertyName = "id")] + public long Id { get; set; } + + [JsonProperty(PropertyName = "torrents")] + public Dictionary Torrents { get; set; } + } +}