Initial support for Flood (#5552)

* Initial support for Flood

* Flip StartOnAdd to AddPaused
This commit is contained in:
Jesse Chan 2021-01-31 14:46:01 +08:00 committed by GitHub
parent 39d11b4669
commit 2237624333
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 609 additions and 0 deletions

View File

@ -0,0 +1,234 @@
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.Organizer;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.RemotePathMappings;
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.Download.Clients.Flood
{
public class Flood : TorrentClientBase<FloodSettings>
{
private readonly IFloodProxy _proxy;
public Flood(IFloodProxy proxy,
ITorrentFileInfoReader torrentFileInfoReader,
IHttpClient httpClient,
IConfigService configService,
INamingConfigService namingConfigService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, namingConfigService, diskProvider, remotePathMappingService, logger)
{
_proxy = proxy;
}
private static IEnumerable<string> HandleTags(RemoteMovie remoteMovie, FloodSettings settings)
{
var result = new HashSet<string>();
if (settings.Tags.Any())
{
result.UnionWith(settings.Tags);
}
if (settings.AdditionalTags.Any())
{
foreach (var additionalTag in settings.AdditionalTags)
{
switch (additionalTag)
{
case (int)AdditionalTags.Collection:
result.Add(remoteMovie.Movie.Collection.Name);
break;
case (int)AdditionalTags.Quality:
result.Add(remoteMovie.ParsedMovieInfo.Quality.Quality.ToString());
break;
case (int)AdditionalTags.Languages:
result.UnionWith(remoteMovie.ParsedMovieInfo.Languages.ConvertAll(language => language.ToString()));
break;
case (int)AdditionalTags.ReleaseGroup:
result.Add(remoteMovie.ParsedMovieInfo.ReleaseGroup);
break;
case (int)AdditionalTags.Year:
result.Add(remoteMovie.Movie.Year.ToString());
break;
case (int)AdditionalTags.Indexer:
result.Add(remoteMovie.Release.Indexer);
break;
case (int)AdditionalTags.Studio:
result.Add(remoteMovie.Movie.Studio);
break;
default:
throw new DownloadClientException("Unexpected additional tag ID");
}
}
}
return result;
}
public override string Name => "Flood";
public override ProviderMessage Message => new ProviderMessage("Radarr is unable to remove torrents that have finished seeding when using Flood", ProviderMessageType.Warning);
protected override string AddFromTorrentFile(RemoteMovie remoteMovie, string hash, string filename, byte[] fileContent)
{
_proxy.AddTorrentByFile(Convert.ToBase64String(fileContent), HandleTags(remoteMovie, Settings), Settings);
return hash;
}
protected override string AddFromMagnetLink(RemoteMovie remoteMovie, string hash, string magnetLink)
{
_proxy.AddTorrentByUrl(magnetLink, HandleTags(remoteMovie, Settings), Settings);
return hash;
}
public override IEnumerable<DownloadClientItem> GetItems()
{
var items = new List<DownloadClientItem>();
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<OsPath> { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(Settings.Destination)) }
};
}
protected override void Test(List<ValidationFailure> 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));
}
}
}
}

View File

@ -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<string> tags, FloodSettings settings);
void AddTorrentByFile(string file, IEnumerable<string> tags, FloodSettings settings);
void DeleteTorrent(string hash, bool deleteData, FloodSettings settings);
Dictionary<string, Torrent> GetTorrents(FloodSettings settings);
List<string> GetTorrentContentPaths(string hash, FloodSettings settings);
void SetTorrentsTags(string hash, IEnumerable<string> tags, FloodSettings settings);
}
public class FloodProxy : IFloodProxy
{
private readonly IHttpClient _httpClient;
private readonly Logger _logger;
private readonly ICached<Dictionary<string, string>> _authCookieCache;
public FloodProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger)
{
_httpClient = httpClient;
_logger = logger;
_authCookieCache = cacheManager.GetCache<Dictionary<string, string>>(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<string, string> 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<string, object>
{
{ "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<string> tags, FloodSettings settings)
{
var addRequest = BuildRequest(settings).Resource("/torrents/add-files").Post().Build();
var body = new Dictionary<string, object>
{
{ "files", new List<string> { 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<string> tags, FloodSettings settings)
{
var addRequest = BuildRequest(settings).Resource("/torrents/add-urls").Post().Build();
var body = new Dictionary<string, object>
{
{ "urls", new List<string> { 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<string, object>
{
{ "hashes", new List<string> { hash } },
{ "deleteData", deleteData }
};
deleteRequest.SetContent(body.ToJson());
HandleRequest(deleteRequest, settings);
}
public Dictionary<string, Torrent> GetTorrents(FloodSettings settings)
{
var getTorrentsRequest = BuildRequest(settings).Resource("/torrents").Build();
getTorrentsRequest.Method = HttpMethod.GET;
return Json.Deserialize<TorrentListSummary>(HandleRequest(getTorrentsRequest, settings).Content).Torrents;
}
public List<string> GetTorrentContentPaths(string hash, FloodSettings settings)
{
var contentsRequest = BuildRequest(settings).Resource($"/torrents/{hash}/contents").Build();
contentsRequest.Method = HttpMethod.GET;
return Json.Deserialize<List<TorrentContent>>(HandleRequest(contentsRequest, settings).Content).ConvertAll(content => content.Path);
}
public void SetTorrentsTags(string hash, IEnumerable<string> tags, FloodSettings settings)
{
var tagsRequest = BuildRequest(settings).Resource("/torrents/tags").Build();
tagsRequest.Method = HttpMethod.PATCH;
var body = new Dictionary<string, object>
{
{ "hashes", new List<string> { hash } },
{ "tags", tags.ToList() }
};
tagsRequest.SetContent(body.ToJson());
HandleRequest(tagsRequest, settings);
}
}
}

View File

@ -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<FloodSettings>
{
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[]
{
"radarr"
};
AdditionalTags = Enumerable.Empty<int>();
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<string> Tags { get; set; }
[FieldDefinition(8, Label = "Post-Import Tags", Type = FieldType.Tag, HelpText = "Appends tags after a download is imported.", Advanced = true)]
public IEnumerable<string> 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<int> 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));
}
}
}

View File

@ -0,0 +1,28 @@
using NzbDrone.Core.Annotations;
namespace NzbDrone.Core.Download.Clients.Flood.Models
{
public enum AdditionalTags
{
[FieldOption(Hint = "Big Buck Bunny Series")]
Collection = 0,
[FieldOption(Hint = "Bluray-2160p")]
Quality = 1,
[FieldOption(Hint = "English")]
Languages = 2,
[FieldOption(Hint = "Example-Raws")]
ReleaseGroup = 3,
[FieldOption(Hint = "2020")]
Year = 4,
[FieldOption(Hint = "Torznab")]
Indexer = 5,
[FieldOption(Hint = "C-SPAN")]
Studio = 6
}
}

View File

@ -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<string> Status { get; set; }
[JsonProperty(PropertyName = "tags")]
public List<string> Tags { get; set; }
}
}

View File

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

View File

@ -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<string, Torrent> Torrents { get; set; }
}
}