diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/FreeboxDownloadTests/TorrentFreeboxDownloadFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/FreeboxDownloadTests/TorrentFreeboxDownloadFixture.cs new file mode 100644 index 000000000..2549644e3 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/FreeboxDownloadTests/TorrentFreeboxDownloadFixture.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +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; +using NzbDrone.Core.Download.Clients.FreeboxDownload; +using NzbDrone.Core.Download.Clients.FreeboxDownload.Responses; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.FreeboxDownloadTests +{ + [TestFixture] + public class TorrentFreeboxDownloadFixture : DownloadClientFixtureBase + { + protected FreeboxDownloadSettings _settings; + + protected FreeboxDownloadConfiguration _downloadConfiguration; + + protected FreeboxDownloadTask _task; + + protected string _defaultDestination = @"/some/path"; + protected string _encodedDefaultDestination = "L3NvbWUvcGF0aA=="; + protected string _category = "somecat"; + protected string _encodedDefaultDestinationAndCategory = "L3NvbWUvcGF0aC9zb21lY2F0"; + protected string _destinationDirectory = @"/path/to/media"; + protected string _encodedDestinationDirectory = "L3BhdGgvdG8vbWVkaWE="; + protected OsPath _physicalPath = new OsPath("/mnt/sdb1/mydata"); + protected string _downloadURL => "magnet:?xt=urn:btih:5dee65101db281ac9c46344cd6b175cdcad53426&dn=download"; + + [SetUp] + public void Setup() + { + Subject.Definition = new DownloadClientDefinition(); + + _settings = new FreeboxDownloadSettings() + { + Host = "127.0.0.1", + Port = 443, + ApiUrl = "/api/v1/", + AppId = "someid", + AppToken = "S0mEv3RY1oN9T0k3n" + }; + + Subject.Definition.Settings = _settings; + + _downloadConfiguration = new FreeboxDownloadConfiguration() + { + DownloadDirectory = _encodedDefaultDestination + }; + + _task = new FreeboxDownloadTask() + { + Id = "id0", + Name = "name", + DownloadDirectory = "L3NvbWUvcGF0aA==", + InfoHash = "HASH", + QueuePosition = 1, + Status = FreeboxDownloadTaskStatus.Unknown, + Eta = 0, + Error = "none", + Type = FreeboxDownloadTaskType.Bt.ToString(), + IoPriority = FreeboxDownloadTaskIoPriority.Normal.ToString(), + StopRatio = 150, + PieceLength = 125, + CreatedTimestamp = 1665261599, + Size = 1000, + ReceivedPrct = 0, + ReceivedBytes = 0, + ReceivedRate = 0, + TransmittedPrct = 0, + TransmittedBytes = 0, + TransmittedRate = 0, + }; + + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[0])); + } + + protected void GivenCategory() + { + _settings.Category = _category; + } + + protected void GivenDestinationDirectory() + { + _settings.DestinationDirectory = _destinationDirectory; + } + + protected virtual void GivenDownloadConfiguration() + { + Mocker.GetMock() + .Setup(s => s.GetDownloadConfiguration(It.IsAny())) + .Returns(_downloadConfiguration); + } + + protected virtual void GivenTasks(List torrents) + { + if (torrents == null) + { + torrents = new List(); + } + + Mocker.GetMock() + .Setup(s => s.GetTasks(It.IsAny())) + .Returns(torrents); + } + + protected void PrepareClientToReturnQueuedItem() + { + _task.Status = FreeboxDownloadTaskStatus.Queued; + + GivenTasks(new List + { + _task + }); + } + + protected void GivenSuccessfulDownload() + { + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[1000])); + + Mocker.GetMock() + .Setup(s => s.AddTaskFromUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(PrepareClientToReturnQueuedItem); + + Mocker.GetMock() + .Setup(s => s.AddTaskFromFile(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(PrepareClientToReturnQueuedItem); + } + + protected override RemoteEpisode CreateRemoteEpisode() + { + var episode = base.CreateRemoteEpisode(); + + episode.Release.DownloadUrl = _downloadURL; + + return episode; + } + + [Test] + public void Download_with_DestinationDirectory_should_force_directory() + { + GivenDestinationDirectory(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + Subject.Download(remoteEpisode); + + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), _encodedDestinationDirectory, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [Test] + public void Download_with_Category_should_force_directory() + { + GivenDownloadConfiguration(); + GivenCategory(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + Subject.Download(remoteEpisode); + + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), _encodedDefaultDestinationAndCategory, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [Test] + public void Download_without_DestinationDirectory_and_Category_should_use_default() + { + GivenDownloadConfiguration(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + Subject.Download(remoteEpisode); + + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), _encodedDefaultDestination, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [TestCase(false, false)] + [TestCase(true, true)] + public void Download_should_pause_torrent_as_expected(bool addPausedSetting, bool toBePausedFlag) + { + _settings.AddPaused = addPausedSetting; + + GivenDownloadConfiguration(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + Subject.Download(remoteEpisode); + + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), It.IsAny(), toBePausedFlag, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [TestCase(0, (int)FreeboxDownloadPriority.First, (int)FreeboxDownloadPriority.First, true)] + [TestCase(0, (int)FreeboxDownloadPriority.Last, (int)FreeboxDownloadPriority.First, true)] + [TestCase(0, (int)FreeboxDownloadPriority.First, (int)FreeboxDownloadPriority.Last, false)] + [TestCase(0, (int)FreeboxDownloadPriority.Last, (int)FreeboxDownloadPriority.Last, false)] + [TestCase(15, (int)FreeboxDownloadPriority.First, (int)FreeboxDownloadPriority.First, true)] + [TestCase(15, (int)FreeboxDownloadPriority.Last, (int)FreeboxDownloadPriority.First, false)] + [TestCase(15, (int)FreeboxDownloadPriority.First, (int)FreeboxDownloadPriority.Last, true)] + [TestCase(15, (int)FreeboxDownloadPriority.Last, (int)FreeboxDownloadPriority.Last, false)] + public void Download_should_queue_torrent_first_as_expected(int ageDay, int olderPriority, int recentPriority, bool toBeQueuedFirstFlag) + { + _settings.OlderPriority = olderPriority; + _settings.RecentPriority = recentPriority; + + GivenDownloadConfiguration(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var episode = new Tv.Episode() + { + AirDateUtc = DateTime.UtcNow.Date.AddDays(-ageDay) + }; + + remoteEpisode.Episodes.Add(episode); + + Subject.Download(remoteEpisode); + + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), It.IsAny(), It.IsAny(), toBeQueuedFirstFlag, It.IsAny(), It.IsAny()), Times.Once()); + } + + [TestCase(0, 0)] + [TestCase(1.5, 150)] + public void Download_should_define_seed_ratio_as_expected(double? providerSeedRatio, double? expectedSeedRatio) + { + GivenDownloadConfiguration(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + remoteEpisode.SeedConfiguration = new TorrentSeedConfiguration(); + remoteEpisode.SeedConfiguration.Ratio = providerSeedRatio; + + Subject.Download(remoteEpisode); + + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), expectedSeedRatio, It.IsAny()), Times.Once()); + } + + [Test] + public void GetItems_should_return_empty_list_if_no_tasks_available() + { + GivenTasks(new List()); + + Subject.GetItems().Should().BeEmpty(); + } + + [Test] + public void GetItems_should_return_ignore_tasks_of_unknown_type() + { + _task.Status = FreeboxDownloadTaskStatus.Done; + _task.Type = "toto"; + + GivenTasks(new List { _task }); + + Subject.GetItems().Should().BeEmpty(); + } + + [Test] + public void GetItems_when_destinationdirectory_is_set_should_ignore_downloads_in_wrong_folder() + { + _settings.DestinationDirectory = @"/some/path/that/will/not/match"; + + _task.Status = FreeboxDownloadTaskStatus.Done; + + GivenTasks(new List { _task }); + + Subject.GetItems().Should().BeEmpty(); + } + + [Test] + public void GetItems_when_category_is_set_should_ignore_downloads_in_wrong_folder() + { + _settings.Category = "somecategory"; + + _task.Status = FreeboxDownloadTaskStatus.Done; + + GivenTasks(new List { _task }); + + Subject.GetItems().Should().BeEmpty(); + } + + [TestCase(FreeboxDownloadTaskStatus.Downloading, false, false)] + [TestCase(FreeboxDownloadTaskStatus.Done, true, true)] + [TestCase(FreeboxDownloadTaskStatus.Seeding, false, false)] + [TestCase(FreeboxDownloadTaskStatus.Stopped, false, false)] + public void GetItems_should_return_canBeMoved_and_canBeDeleted_as_expected(FreeboxDownloadTaskStatus apiStatus, bool canMoveFilesExpected, bool canBeRemovedExpected) + { + _task.Status = apiStatus; + + GivenTasks(new List() { _task }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().CanBeRemoved.Should().Be(canBeRemovedExpected); + items.First().CanMoveFiles.Should().Be(canMoveFilesExpected); + } + + [TestCase(FreeboxDownloadTaskStatus.Stopped, DownloadItemStatus.Paused)] + [TestCase(FreeboxDownloadTaskStatus.Stopping, DownloadItemStatus.Paused)] + [TestCase(FreeboxDownloadTaskStatus.Queued, DownloadItemStatus.Queued)] + [TestCase(FreeboxDownloadTaskStatus.Starting, DownloadItemStatus.Downloading)] + [TestCase(FreeboxDownloadTaskStatus.Downloading, DownloadItemStatus.Downloading)] + [TestCase(FreeboxDownloadTaskStatus.Retry, DownloadItemStatus.Downloading)] + [TestCase(FreeboxDownloadTaskStatus.Checking, DownloadItemStatus.Downloading)] + [TestCase(FreeboxDownloadTaskStatus.Error, DownloadItemStatus.Warning)] + [TestCase(FreeboxDownloadTaskStatus.Seeding, DownloadItemStatus.Completed)] + [TestCase(FreeboxDownloadTaskStatus.Done, DownloadItemStatus.Completed)] + [TestCase(FreeboxDownloadTaskStatus.Unknown, DownloadItemStatus.Downloading)] + public void GetItems_should_return_item_as_downloadItemStatus(FreeboxDownloadTaskStatus apiStatus, DownloadItemStatus expectedItemStatus) + { + _task.Status = apiStatus; + + GivenTasks(new List() { _task }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().Status.Should().Be(expectedItemStatus); + } + + [Test] + public void GetItems_should_return_decoded_destination_directory() + { + var decodedDownloadDirectory = "/that/the/path"; + + _task.Status = FreeboxDownloadTaskStatus.Done; + _task.DownloadDirectory = "L3RoYXQvdGhlL3BhdGg="; + + GivenTasks(new List { _task }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().OutputPath.Should().Be(decodedDownloadDirectory); + } + + [Test] + public void GetItems_should_return_message_if_tasks_in_error() + { + _task.Status = FreeboxDownloadTaskStatus.Error; + _task.Error = "internal"; + + GivenTasks(new List { _task }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().Message.Should().Be("Internal error."); + items.First().Status.Should().Be(DownloadItemStatus.Warning); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadEncoding.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadEncoding.cs new file mode 100644 index 000000000..0e12570aa --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadEncoding.cs @@ -0,0 +1,27 @@ +namespace NzbDrone.Core.Download.Clients.FreeboxDownload +{ + public static class EncodingForBase64 + { + public static string EncodeBase64(this string text) + { + if (text == null) + { + return null; + } + + byte[] textAsBytes = System.Text.Encoding.UTF8.GetBytes(text); + return System.Convert.ToBase64String(textAsBytes); + } + + public static string DecodeBase64(this string encodedText) + { + if (encodedText == null) + { + return null; + } + + byte[] textAsBytes = System.Convert.FromBase64String(encodedText); + return System.Text.Encoding.UTF8.GetString(textAsBytes); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadException.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadException.cs new file mode 100644 index 000000000..38fcf8047 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadException.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.Download.Clients.FreeboxDownload +{ + public class FreeboxDownloadException : DownloadClientException + { + public FreeboxDownloadException(string message) + : base(message) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadPriority.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadPriority.cs new file mode 100644 index 000000000..ee16d70d6 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadPriority.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.FreeboxDownload +{ + public enum FreeboxDownloadPriority + { + Last = 0, + First = 1 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadProxy.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadProxy.cs new file mode 100644 index 000000000..ae952e2b9 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadProxy.cs @@ -0,0 +1,277 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Download.Clients.FreeboxDownload.Responses; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload +{ + public interface IFreeboxDownloadProxy + { + void Authenticate(FreeboxDownloadSettings settings); + string AddTaskFromUrl(string url, string directory, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings); + string AddTaskFromFile(string fileName, byte[] fileContent, string directory, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings); + void DeleteTask(string id, bool deleteData, FreeboxDownloadSettings settings); + FreeboxDownloadConfiguration GetDownloadConfiguration(FreeboxDownloadSettings settings); + List GetTasks(FreeboxDownloadSettings settings); + } + + public class FreeboxDownloadProxy : IFreeboxDownloadProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + private ICached _authSessionTokenCache; + + public FreeboxDownloadProxy(ICacheManager cacheManager, IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + _authSessionTokenCache = cacheManager.GetCache(GetType(), "authSessionToken"); + } + + public void Authenticate(FreeboxDownloadSettings settings) + { + var request = BuildRequest(settings).Resource("/login").Build(); + + var response = ProcessRequest(request, settings); + + if (response.Result.LoggedIn == false) + { + throw new DownloadClientAuthenticationException("Not logged"); + } + } + + public string AddTaskFromUrl(string url, string directory, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings) + { + var request = BuildRequest(settings).Resource("/downloads/add").Post(); + request.Headers.ContentType = "application/x-www-form-urlencoded"; + + request.AddFormParameter("download_url", System.Web.HttpUtility.UrlPathEncode(url)); + + if (!directory.IsNullOrWhiteSpace()) + { + request.AddFormParameter("download_dir", directory); + } + + var response = ProcessRequest(request.Build(), settings); + + SetTorrentSettings(response.Result.Id, addPaused, addFirst, seedRatio, settings); + + return response.Result.Id; + } + + public string AddTaskFromFile(string fileName, byte[] fileContent, string directory, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings) + { + var request = BuildRequest(settings).Resource("/downloads/add").Post(); + + request.AddFormUpload("download_file", fileName, fileContent, "multipart/form-data"); + + if (directory.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("download_dir", directory); + } + + var response = ProcessRequest(request.Build(), settings); + + SetTorrentSettings(response.Result.Id, addPaused, addFirst, seedRatio, settings); + + return response.Result.Id; + } + + public void DeleteTask(string id, bool deleteData, FreeboxDownloadSettings settings) + { + var uri = "/downloads/" + id; + + if (deleteData == true) + { + uri += "/erase"; + } + + var request = BuildRequest(settings).Resource(uri).Build(); + + request.Method = HttpMethod.Delete; + + ProcessRequest(request, settings); + } + + public FreeboxDownloadConfiguration GetDownloadConfiguration(FreeboxDownloadSettings settings) + { + var request = BuildRequest(settings).Resource("/downloads/config/").Build(); + + return ProcessRequest(request, settings).Result; + } + + public List GetTasks(FreeboxDownloadSettings settings) + { + var request = BuildRequest(settings).Resource("/downloads/").Build(); + + return ProcessRequest>(request, settings).Result; + } + + private static string BuildCachedHeaderKey(FreeboxDownloadSettings settings) + { + return $"{settings.Host}:{settings.AppId}:{settings.AppToken}"; + } + + private void SetTorrentSettings(string id, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings) + { + var request = BuildRequest(settings).Resource("/downloads/" + id).Build(); + + request.Method = HttpMethod.Put; + + var body = new Dictionary { }; + + if (addPaused) + { + body.Add("status", FreeboxDownloadTaskStatus.Stopped.ToString().ToLower()); + } + + if (addFirst) + { + body.Add("queue_pos", "1"); + } + + if (seedRatio != null) + { + // 0 means unlimited seeding + body.Add("stop_ratio", seedRatio); + } + + if (body.Count == 0) + { + return; + } + + request.SetContent(body.ToJson()); + + ProcessRequest(request, settings); + } + + private string GetSessionToken(HttpRequestBuilder requestBuilder, FreeboxDownloadSettings settings, bool force = false) + { + var sessionToken = _authSessionTokenCache.Find(BuildCachedHeaderKey(settings)); + + if (sessionToken == null || force) + { + _authSessionTokenCache.Remove(BuildCachedHeaderKey(settings)); + + _logger.Debug($"Client needs a new Session Token to reach the API with App ID '{settings.AppId}'"); + + // Obtaining a Session Token (from official documentation): + // To protect the app_token secret, it will never be used directly to authenticate the + // application, instead the API will provide a challenge the app will combine to its + // app_token to open a session and get a session_token. + // The validity of the session_token is limited in time and the app will have to renew + // this session_token once in a while. + + // Retrieving the 'challenge' value (it changes frequently and have a limited time validity) + // needed to build password + var challengeRequest = requestBuilder.Resource("/login").Build(); + challengeRequest.Method = HttpMethod.Get; + + var challenge = ProcessRequest(challengeRequest, settings).Result.Challenge; + + // The password is computed using the 'challenge' value and the 'app_token' ('App Token' setting) + var enc = System.Text.Encoding.ASCII; + var hmac = new HMACSHA1(enc.GetBytes(settings.AppToken)); + hmac.Initialize(); + var buffer = enc.GetBytes(challenge); + var password = System.BitConverter.ToString(hmac.ComputeHash(buffer)).Replace("-", "").ToLower(); + + // Both 'app_id' ('App ID' setting) and computed password are set to get a Session Token + var sessionRequest = requestBuilder.Resource("/login/session").Post().Build(); + var body = new Dictionary + { + { "app_id", settings.AppId }, + { "password", password } + }; + sessionRequest.SetContent(body.ToJson()); + + sessionToken = ProcessRequest(sessionRequest, settings).Result.SessionToken; + + _authSessionTokenCache.Set(BuildCachedHeaderKey(settings), sessionToken); + + _logger.Debug($"New Session Token stored in cache for App ID '{settings.AppId}', ready to reach API"); + } + + return sessionToken; + } + + private HttpRequestBuilder BuildRequest(FreeboxDownloadSettings settings, bool authentication = true) + { + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.ApiUrl) + { + LogResponseContent = true + }; + + requestBuilder.Headers.ContentType = "application/json"; + + if (authentication == true) + { + requestBuilder.SetHeader("X-Fbx-App-Auth", GetSessionToken(requestBuilder, settings)); + } + + return requestBuilder; + } + + private FreeboxResponse ProcessRequest(HttpRequest request, FreeboxDownloadSettings settings) + { + request.LogResponseContent = true; + request.SuppressHttpError = true; + + HttpResponse response; + + try + { + response = _httpClient.Execute(request); + } + catch (HttpRequestException ex) + { + throw new DownloadClientUnavailableException($"Unable to reach Freebox API. Verify 'Host', 'Port' or 'Use SSL' settings. (Error: {ex.Message})", ex); + } + catch (WebException ex) + { + throw new DownloadClientUnavailableException("Unable to connect to Freebox API, please check your settings", ex); + } + + if (response.StatusCode == HttpStatusCode.Forbidden || response.StatusCode == HttpStatusCode.Unauthorized) + { + _authSessionTokenCache.Remove(BuildCachedHeaderKey(settings)); + + var responseContent = Json.Deserialize>(response.Content); + + var msg = $"Authentication to Freebox API failed. Reason: {responseContent.GetErrorDescription()}"; + _logger.Error(msg); + throw new DownloadClientAuthenticationException(msg); + } + else if (response.StatusCode == HttpStatusCode.NotFound) + { + throw new FreeboxDownloadException("Unable to reach Freebox API. Verify 'API URL' setting for base URL and version."); + } + else if (response.StatusCode == HttpStatusCode.OK) + { + var responseContent = Json.Deserialize>(response.Content); + + if (responseContent.Success) + { + return responseContent; + } + else + { + var msg = $"Freebox API returned error: {responseContent.GetErrorDescription()}"; + _logger.Error(msg); + throw new DownloadClientException(msg); + } + } + else + { + throw new DownloadClientException("Unable to connect to Freebox, please check your settings."); + } + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs new file mode 100644 index 000000000..45de36ef4 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs @@ -0,0 +1,87 @@ +using System.Text.RegularExpressions; +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; +using NzbDrone.Core.Validation.Paths; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload +{ + public class FreeboxDownloadSettingsValidator : AbstractValidator + { + public FreeboxDownloadSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.ApiUrl).NotEmpty() + .WithMessage("'API URL' must not be empty."); + RuleFor(c => c.ApiUrl).ValidUrlBase(); + RuleFor(c => c.AppId).NotEmpty() + .WithMessage("'App ID' must not be empty."); + RuleFor(c => c.AppToken).NotEmpty() + .WithMessage("'App Token' must not be empty."); + RuleFor(c => c.Category).Matches(@"^\.?[-a-z]*$", RegexOptions.IgnoreCase) + .WithMessage("Allowed characters a-z and -"); + RuleFor(c => c.DestinationDirectory).IsValidPath() + .When(c => c.DestinationDirectory.IsNotNullOrWhiteSpace()); + RuleFor(c => c.DestinationDirectory).Empty() + .When(c => c.Category.IsNotNullOrWhiteSpace()) + .WithMessage("Cannot use 'Category' and 'Destination Directory' at the same time."); + RuleFor(c => c.Category).Empty() + .When(c => c.DestinationDirectory.IsNotNullOrWhiteSpace()) + .WithMessage("Cannot use 'Category' and 'Destination Directory' at the same time."); + } + } + + public class FreeboxDownloadSettings : IProviderConfig + { + private static readonly FreeboxDownloadSettingsValidator Validator = new FreeboxDownloadSettingsValidator(); + + public FreeboxDownloadSettings() + { + Host = "mafreebox.freebox.fr"; + Port = 443; + UseSsl = true; + ApiUrl = "/api/v1/"; + } + + [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox, HelpText = "Hostname or host IP address of the Freebox, defaults to 'mafreebox.freebox.fr' (will only work if on same network)")] + public string Host { get; set; } + + [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox, HelpText = "Port used to access Freebox interface, defaults to '443'")] + public int Port { get; set; } + + [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secured connection when connecting to Freebox API")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "API URL", Type = FieldType.Textbox, Advanced = true, HelpText = "Define Freebox API base URL with API version, eg http://[host]:[port]/[api_base_url]/[api_version]/, defaults to '/api/v1/'")] + public string ApiUrl { get; set; } + + [FieldDefinition(4, Label = "App ID", Type = FieldType.Textbox, HelpText = "App ID given when creating access to Freebox API (ie 'app_id')")] + public string AppId { get; set; } + + [FieldDefinition(5, Label = "App Token", Type = FieldType.Password, Privacy = PrivacyLevel.Password, HelpText = "App token retrieved when creating access to Freebox API (ie 'app_token')")] + public string AppToken { get; set; } + + [FieldDefinition(6, Label = "Destination Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default Freebox download location")] + public string DestinationDirectory { get; set; } + + [FieldDefinition(7, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated non-Sonarr downloads (will create a [category] subdirectory in the output directory)")] + public string Category { get; set; } + + [FieldDefinition(8, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(FreeboxDownloadPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + public int RecentPriority { get; set; } + + [FieldDefinition(9, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(FreeboxDownloadPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + public int OlderPriority { 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/FreeboxDownload/Responses/FreeboxDownloadConfiguration.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadConfiguration.cs new file mode 100644 index 000000000..23850c651 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadConfiguration.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload.Responses +{ + public class FreeboxDownloadConfiguration + { + [JsonProperty(PropertyName = "download_dir")] + public string DownloadDirectory { get; set; } + public string DecodedDownloadDirectory + { + get + { + return DownloadDirectory.DecodeBase64(); + } + set + { + DownloadDirectory = value.EncodeBase64(); + } + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadTask.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadTask.cs new file mode 100644 index 000000000..faf4f646a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxDownloadTask.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload.Responses +{ + public enum FreeboxDownloadTaskType + { + Bt, + Nzb, + Http, + Ftp + } + + public enum FreeboxDownloadTaskStatus + { + Unknown, + Stopped, + Queued, + Starting, + Downloading, + Stopping, + Error, + Done, + Checking, + Repairing, + Extracting, + Seeding, + Retry + } + + public enum FreeboxDownloadTaskIoPriority + { + Low, + Normal, + High + } + + public class FreeboxDownloadTask + { + private static readonly Dictionary Descriptions; + + [JsonProperty(PropertyName = "id")] + public string Id { get; set; } + [JsonProperty(PropertyName = "name")] + public string Name { get; set; } + [JsonProperty(PropertyName = "download_dir")] + public string DownloadDirectory { get; set; } + public string DecodedDownloadDirectory + { + get + { + return DownloadDirectory.DecodeBase64(); + } + set + { + DownloadDirectory = value.EncodeBase64(); + } + } + + [JsonProperty(PropertyName = "info_hash")] + public string InfoHash { get; set; } + [JsonProperty(PropertyName = "queue_pos")] + public int QueuePosition { get; set; } + [JsonConverter(typeof(UnderscoreStringEnumConverter), FreeboxDownloadTaskStatus.Unknown)] + public FreeboxDownloadTaskStatus Status { get; set; } + [JsonProperty(PropertyName = "eta")] + public long Eta { get; set; } + [JsonProperty(PropertyName = "error")] + public string Error { get; set; } + [JsonProperty(PropertyName = "type")] + public string Type { get; set; } + [JsonProperty(PropertyName = "io_priority")] + public string IoPriority { get; set; } + [JsonProperty(PropertyName = "stop_ratio")] + public long StopRatio { get; set; } + [JsonProperty(PropertyName = "piece_length")] + public long PieceLength { get; set; } + [JsonProperty(PropertyName = "created_ts")] + public long CreatedTimestamp { get; set; } + [JsonProperty(PropertyName = "size")] + public long Size { get; set; } + [JsonProperty(PropertyName = "rx_pct")] + public long ReceivedPrct { get; set; } + [JsonProperty(PropertyName = "rx_bytes")] + public long ReceivedBytes { get; set; } + [JsonProperty(PropertyName = "rx_rate")] + public long ReceivedRate { get; set; } + [JsonProperty(PropertyName = "tx_pct")] + public long TransmittedPrct { get; set; } + [JsonProperty(PropertyName = "tx_bytes")] + public long TransmittedBytes { get; set; } + [JsonProperty(PropertyName = "tx_rate")] + public long TransmittedRate { get; set; } + + static FreeboxDownloadTask() + { + Descriptions = new Dictionary + { + { "internal", "Internal error." }, + { "disk_full", "The disk is full." }, + { "unknown", "Unknown error." }, + { "parse_error", "Parse error." }, + { "unknown_host", "Unknown host." }, + { "timeout", "Timeout." }, + { "bad_authentication", "Invalid credentials." }, + { "connection_refused", "Remote host refused connection." }, + { "bt_tracker_error", "Unable to announce on tracker." }, + { "bt_missing_files", "Missing torrent files." }, + { "bt_file_error", "Error accessing torrent files." }, + { "missing_ctx_file", "Error accessing task context file." }, + { "nzb_no_group", "Cannot find the requested group on server." }, + { "nzb_not_found", "Article not fount on the server." }, + { "nzb_invalid_crc", "Invalid article CRC." }, + { "nzb_invalid_size", "Invalid article size." }, + { "nzb_invalid_filename", "Invalid filename." }, + { "nzb_open_failed", "Error opening." }, + { "nzb_write_failed", "Error writing." }, + { "nzb_missing_size", "Missing article size." }, + { "nzb_decode_error", "Article decoding error." }, + { "nzb_missing_segments", "Missing article segments." }, + { "nzb_error", "Other nzb error." }, + { "nzb_authentication_required", "Nzb server need authentication." } + }; + } + + public string GetErrorDescription() + { + if (Descriptions.ContainsKey(Error)) + { + return Descriptions[Error]; + } + + return $"{Error} - Unknown error"; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxLogin.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxLogin.cs new file mode 100644 index 000000000..bfb01f050 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxLogin.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload.Responses +{ + public class FreeboxLogin + { + [JsonProperty(PropertyName = "logged_in")] + public bool LoggedIn { get; set; } + [JsonProperty(PropertyName = "challenge")] + public string Challenge { get; set; } + [JsonProperty(PropertyName = "password_salt")] + public string PasswordSalt { get; set; } + [JsonProperty(PropertyName = "password_set")] + public bool PasswordSet { get; set; } + [JsonProperty(PropertyName = "session_token")] + public string SessionToken { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxResponse.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxResponse.cs new file mode 100644 index 000000000..5aff5b68a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/Responses/FreeboxResponse.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload.Responses +{ + public class FreeboxResponse + { + private static readonly Dictionary Descriptions; + + [JsonProperty(PropertyName = "success")] + public bool Success { get; set; } + [JsonProperty(PropertyName = "msg")] + public string Message { get; set; } + [JsonProperty(PropertyName = "error_code")] + public string ErrorCode { get; set; } + [JsonProperty(PropertyName = "result")] + public T Result { get; set; } + + static FreeboxResponse() + { + Descriptions = new Dictionary + { + // Common errors + { "invalid_request", "Your request is invalid." }, + { "invalid_api_version", "Invalid API base url or unknown API version." }, + { "internal_error", "Internal error." }, + + // Login API errors + { "auth_required", "Invalid session token, or no session token sent." }, + { "invalid_token", "The app token you are trying to use is invalid or has been revoked." }, + { "pending_token", "The app token you are trying to use has not been validated by user yet." }, + { "insufficient_rights", "Your app permissions does not allow accessing this API." }, + { "denied_from_external_ip", "You are trying to get an app_token from a remote IP." }, + { "ratelimited", "Too many auth error have been made from your IP." }, + { "new_apps_denied", "New application token request has been disabled." }, + { "apps_denied", "API access from apps has been disabled." }, + + // Download API errors + { "task_not_found", "No task was found with the given id." }, + { "invalid_operation", "Attempt to perform an invalid operation." }, + { "invalid_file", "Error with the download file (invalid format ?)." }, + { "invalid_url", "URL is invalid." }, + { "not_implemented", "Method not implemented." }, + { "out_of_memory", "No more memory available to perform the requested action." }, + { "invalid_task_type", "The task type is invalid." }, + { "hibernating", "The downloader is hibernating." }, + { "need_bt_stopped_done", "This action is only valid for Bittorrent task in stopped or done state." }, + { "bt_tracker_not_found", "Attempt to access an invalid tracker object." }, + { "too_many_tasks", "Too many tasks." }, + { "invalid_address", "Invalid peer address." }, + { "port_conflict", "Port conflict when setting config." }, + { "invalid_priority", "Invalid priority." }, + { "ctx_file_error", "Failed to initialize task context file (need to check disk)." }, + { "exists", "Same task already exists." }, + { "port_outside_range", "Incoming port is not available for this customer." } + }; + } + + public string GetErrorDescription() + { + if (Descriptions.ContainsKey(ErrorCode)) + { + return Descriptions[ErrorCode]; + } + + return $"{ErrorCode} - Unknown error"; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs new file mode 100644 index 000000000..449afa7b3 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients.FreeboxDownload.Responses; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.RemotePathMappings; + +namespace NzbDrone.Core.Download.Clients.FreeboxDownload +{ + public class TorrentFreeboxDownload : TorrentClientBase + { + private readonly IFreeboxDownloadProxy _proxy; + + public TorrentFreeboxDownload(IFreeboxDownloadProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IRemotePathMappingService remotePathMappingService, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger) + { + _proxy = proxy; + } + + public override string Name => "Freebox Download"; + + protected IEnumerable GetTorrents() + { + return _proxy.GetTasks(Settings).Where(v => v.Type.ToLower() == FreeboxDownloadTaskType.Bt.ToString().ToLower()); + } + + public override IEnumerable GetItems() + { + var torrents = GetTorrents(); + + var queueItems = new List(); + + foreach (var torrent in torrents) + { + var outputPath = new OsPath(torrent.DecodedDownloadDirectory); + + if (Settings.DestinationDirectory.IsNotNullOrWhiteSpace()) + { + if (!new OsPath(Settings.DestinationDirectory).Contains(outputPath)) + { + continue; + } + } + + if (Settings.Category.IsNotNullOrWhiteSpace()) + { + var directories = outputPath.FullPath.Split('\\', '/'); + + if (!directories.Contains(Settings.Category)) + { + continue; + } + } + + var item = new DownloadClientItem() + { + DownloadId = torrent.Id, + Category = Settings.Category, + Title = torrent.Name, + TotalSize = torrent.Size, + DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this), + RemainingSize = (long)(torrent.Size * (double)(1 - ((double)torrent.ReceivedPrct / 10000))), + RemainingTime = torrent.Eta <= 0 ? null : TimeSpan.FromSeconds(torrent.Eta), + SeedRatio = torrent.StopRatio <= 0 ? 0 : torrent.StopRatio / 100, + OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath) + }; + + switch (torrent.Status) + { + case FreeboxDownloadTaskStatus.Stopped: // task is stopped, can be resumed by setting the status to downloading + case FreeboxDownloadTaskStatus.Stopping: // task is gracefully stopping + item.Status = DownloadItemStatus.Paused; + break; + + case FreeboxDownloadTaskStatus.Queued: // task will start when a new download slot is available the queue position is stored in queue_pos attribute + item.Status = DownloadItemStatus.Queued; + break; + + case FreeboxDownloadTaskStatus.Starting: // task is preparing to start download + case FreeboxDownloadTaskStatus.Downloading: + case FreeboxDownloadTaskStatus.Retry: // you can set a task status to ‘retry’ to restart the download task. + case FreeboxDownloadTaskStatus.Checking: // checking data before lauching download. + item.Status = DownloadItemStatus.Downloading; + break; + + case FreeboxDownloadTaskStatus.Error: // there was a problem with the download, you can get an error code in the error field + item.Status = DownloadItemStatus.Warning; + item.Message = torrent.GetErrorDescription(); + break; + + case FreeboxDownloadTaskStatus.Done: // the download is over. For bt you can resume seeding setting the status to seeding if the ratio is not reached yet + case FreeboxDownloadTaskStatus.Seeding: // download is over, the content is Change to being shared to other users. The task will automatically stop once the seed ratio has been reached + item.Status = DownloadItemStatus.Completed; + break; + + case FreeboxDownloadTaskStatus.Unknown: + default: // new status in API? default to downloading + item.Message = "Unknown download state: " + torrent.Status; + _logger.Info(item.Message); + item.Status = DownloadItemStatus.Downloading; + break; + } + + item.CanBeRemoved = item.CanMoveFiles = torrent.Status == FreeboxDownloadTaskStatus.Done; + + queueItems.Add(item); + } + + return queueItems; + } + + protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + { + return _proxy.AddTaskFromUrl(magnetLink, + GetDownloadDirectory().EncodeBase64(), + ToBePaused(), + ToBeQueuedFirst(remoteEpisode), + GetSeedRatio(remoteEpisode), + Settings); + } + + protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) + { + return _proxy.AddTaskFromFile(filename, + fileContent, + GetDownloadDirectory().EncodeBase64(), + ToBePaused(), + ToBeQueuedFirst(remoteEpisode), + GetSeedRatio(remoteEpisode), + Settings); + } + + public override void RemoveItem(DownloadClientItem item, bool deleteData) + { + _proxy.DeleteTask(item.DownloadId, deleteData, Settings); + } + + public override DownloadClientInfo GetStatus() + { + var destDir = GetDownloadDirectory(); + + 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(destDir)) } + }; + } + + protected override void Test(List failures) + { + try + { + _proxy.Authenticate(Settings); + } + catch (DownloadClientUnavailableException ex) + { + failures.Add(new ValidationFailure("Host", ex.Message)); + failures.Add(new ValidationFailure("Port", ex.Message)); + } + catch (DownloadClientAuthenticationException ex) + { + failures.Add(new ValidationFailure("AppId", ex.Message)); + failures.Add(new ValidationFailure("AppToken", ex.Message)); + } + catch (FreeboxDownloadException ex) + { + failures.Add(new ValidationFailure("ApiUrl", ex.Message)); + } + } + + private string GetDownloadDirectory() + { + if (Settings.DestinationDirectory.IsNotNullOrWhiteSpace()) + { + return Settings.DestinationDirectory.TrimEnd('/'); + } + + var destDir = _proxy.GetDownloadConfiguration(Settings).DecodedDownloadDirectory.TrimEnd('/'); + + if (Settings.Category.IsNotNullOrWhiteSpace()) + { + destDir = $"{destDir}/{Settings.Category}"; + } + + return destDir; + } + + private bool ToBePaused() + { + return Settings.AddPaused; + } + + private bool ToBeQueuedFirst(RemoteEpisode remoteEpisode) + { + if ((remoteEpisode.IsRecentEpisode() && Settings.RecentPriority == (int)FreeboxDownloadPriority.First) || + (!remoteEpisode.IsRecentEpisode() && Settings.OlderPriority == (int)FreeboxDownloadPriority.First)) + { + return true; + } + + return false; + } + + private double? GetSeedRatio(RemoteEpisode remoteEpisode) + { + if (remoteEpisode.SeedConfiguration == null || remoteEpisode.SeedConfiguration.Ratio == null) + { + return null; + } + + return remoteEpisode.SeedConfiguration.Ratio.Value * 100; + } + } +}