diff --git a/src/NzbDrone.Core/ImportLists/ImportListType.cs b/src/NzbDrone.Core/ImportLists/ImportListType.cs index f31d835c2a0..35f1b71756c 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListType.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListType.cs @@ -5,6 +5,7 @@ public enum ImportListType Program, TMDB, Trakt, + Simkl, Other, Advanced } diff --git a/src/NzbDrone.Core/ImportLists/Simkl/List/SimklListImport.cs b/src/NzbDrone.Core/ImportLists/Simkl/List/SimklListImport.cs new file mode 100644 index 00000000000..c524614f556 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/List/SimklListImport.cs @@ -0,0 +1,34 @@ +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Notifications.Simkl; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.Simkl.List +{ + public class SimklListImport : SimklImportBase + { + public SimklListImport(IImportListRepository importListRepository, + ISimklProxy simklProxy, + IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(importListRepository, simklProxy, httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override string Name => "Simkl List"; + public override bool Enabled => true; + public override bool EnableAuto => false; + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new SimklListRequestGenerator(_SimklProxy) + { + Settings = Settings + }; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/List/SimklListRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Simkl/List/SimklListRequestGenerator.cs new file mode 100644 index 00000000000..0a23edccd1f --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/List/SimklListRequestGenerator.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using NzbDrone.Common.Http; +using NzbDrone.Core.Notifications.Simkl; + +namespace NzbDrone.Core.ImportLists.Simkl.List +{ + public class SimklListRequestGenerator : IImportListRequestGenerator + { + private readonly ISimklProxy _simklProxy; + public SimklListSettings Settings { get; set; } + + public SimklListRequestGenerator(ISimklProxy simklProxy) + { + _simklProxy = simklProxy; + } + + public virtual ImportListPageableRequestChain GetMovies() + { + var pageableRequests = new ImportListPageableRequestChain(); + + pageableRequests.Add(GetMoviesRequest()); + + return pageableRequests; + } + + private IEnumerable GetMoviesRequest() + { + var link = string.Empty; + + var listName = Parser.Parser.ToUrlSlug(Settings.Listname.Trim()); + link += $"users/{Settings.Username.Trim()}/lists/{listName}/items/movies?limit={Settings.Limit}"; + + var request = new ImportListRequest(_simklProxy.BuildSimklRequest(link, HttpMethod.GET, Settings.AccessToken)); + + yield return request; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/List/SimklListSettings.cs b/src/NzbDrone.Core/ImportLists/Simkl/List/SimklListSettings.cs new file mode 100644 index 00000000000..d1e1ef164d5 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/List/SimklListSettings.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.ImportLists.Simkl.List +{ + public class SimklListSettingsValidator : SimklSettingsBaseValidator + { + public SimklListSettingsValidator() + : base() + { + } + } + + public class SimklListSettings : SimklSettingsBase + { + protected override AbstractValidator Validator => new SimklListSettingsValidator(); + + public SimklListSettings() + { + } + + [FieldDefinition(1, Label = "Username", Privacy = PrivacyLevel.UserName, HelpText = "Username for the List to import from")] + public string Username { get; set; } + + [FieldDefinition(2, Label = "List Name", HelpText = "List name for import, list must be public or you must have access to the list")] + public string Listname { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularImport.cs b/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularImport.cs new file mode 100644 index 00000000000..7c2aa128e1b --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularImport.cs @@ -0,0 +1,39 @@ +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Notifications.Simkl; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.Simkl.Popular +{ + public class SimklPopularImport : SimklImportBase + { + public SimklPopularImport(IImportListRepository importListRepository, + ISimklProxy simklProxy, + IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(importListRepository, simklProxy, httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override string Name => "Simkl Popular List"; + public override bool Enabled => true; + public override bool EnableAuto => false; + + public override IParseImportListResponse GetParser() + { + return new SimklPopularParser(Settings); + } + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new SimklPopularRequestGenerator(_SimklProxy) + { + Settings = Settings + }; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularListType.cs b/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularListType.cs new file mode 100644 index 00000000000..3be3adf231f --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularListType.cs @@ -0,0 +1,25 @@ +using System.Runtime.Serialization; + +namespace NzbDrone.Core.ImportLists.Simkl.Popular +{ + public enum SimklPopularListType + { + [EnumMember(Value = "Trending Movies")] + Trending = 0, + [EnumMember(Value = "Popular Movies")] + Popular = 1, + [EnumMember(Value = "Top Anticipated Movies")] + Anticipated = 2, + [EnumMember(Value = "Top Box Office Movies")] + BoxOffice = 3, + + [EnumMember(Value = "Top Watched Movies By Week")] + TopWatchedByWeek = 4, + [EnumMember(Value = "Top Watched Movies By Month")] + TopWatchedByMonth = 5, + [EnumMember(Value = "Top Watched Movies By Year")] + TopWatchedByYear = 6, + [EnumMember(Value = "Top Watched Movies Of All Time")] + TopWatchedByAllTime = 7 + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularParser.cs b/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularParser.cs new file mode 100644 index 00000000000..2402990cdd5 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularParser.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.ImportLists.ImportListMovies; +using NzbDrone.Core.Notifications.Simkl.Resource; + +namespace NzbDrone.Core.ImportLists.Simkl.Popular +{ + public class SimklPopularParser : SimklParser + { + private readonly SimklPopularSettings _settings; + private ImportListResponse _importResponse; + + public SimklPopularParser(SimklPopularSettings settings) + { + _settings = settings; + } + + public override IList ParseResponse(ImportListResponse importResponse) + { + _importResponse = importResponse; + + var movies = new List(); + + if (!PreProcess(_importResponse)) + { + return movies; + } + + var jsonResponse = new List(); + + if (_settings.SimklListType == (int)SimklPopularListType.Popular) + { + jsonResponse = JsonConvert.DeserializeObject>(_importResponse.Content); + } + else + { + jsonResponse = JsonConvert.DeserializeObject>(_importResponse.Content).SelectList(c => c.Movie); + } + + // no movies were return + if (jsonResponse == null) + { + return movies; + } + + foreach (var movie in jsonResponse) + { + movies.AddIfNotNull(new ImportListMovie() + { + Title = movie.Title, + ImdbId = movie.Ids.Imdb, + TmdbId = movie.Ids.Tmdb, + Year = movie.Year ?? 0 + }); + } + + return movies; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularRequestGenerator.cs new file mode 100644 index 00000000000..1574755b2ac --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularRequestGenerator.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using NzbDrone.Common.Http; +using NzbDrone.Core.Notifications.Simkl; + +namespace NzbDrone.Core.ImportLists.Simkl.Popular +{ + public class SimklPopularRequestGenerator : IImportListRequestGenerator + { + private readonly ISimklProxy _simklProxy; + public SimklPopularSettings Settings { get; set; } + + public SimklPopularRequestGenerator(ISimklProxy simklProxy) + { + _simklProxy = simklProxy; + } + + public virtual ImportListPageableRequestChain GetMovies() + { + var pageableRequests = new ImportListPageableRequestChain(); + + pageableRequests.Add(GetMoviesRequest()); + + return pageableRequests; + } + + private IEnumerable GetMoviesRequest() + { + var link = string.Empty; + + var filtersAndLimit = $"?years={Settings.Years}&genres={Settings.Genres.ToLower()}&ratings={Settings.Rating}&certifications={Settings.Certification.ToLower()}&limit={Settings.Limit}{Settings.SimklAdditionalParameters}"; + + switch (Settings.SimklListType) + { + case (int)SimklPopularListType.Trending: + link += "movies/trending" + filtersAndLimit; + break; + case (int)SimklPopularListType.Popular: + link += "movies/popular" + filtersAndLimit; + break; + case (int)SimklPopularListType.Anticipated: + link += "movies/anticipated" + filtersAndLimit; + break; + case (int)SimklPopularListType.BoxOffice: + link += "movies/boxoffice" + filtersAndLimit; + break; + case (int)SimklPopularListType.TopWatchedByWeek: + link += "movies/watched/weekly" + filtersAndLimit; + break; + case (int)SimklPopularListType.TopWatchedByMonth: + link += "movies/watched/monthly" + filtersAndLimit; + break; + case (int)SimklPopularListType.TopWatchedByYear: + link += "movies/watched/yearly" + filtersAndLimit; + break; + case (int)SimklPopularListType.TopWatchedByAllTime: + link += "movies/watched/all" + filtersAndLimit; + break; + } + + var request = new ImportListRequest(_simklProxy.BuildSimklRequest(link, HttpMethod.GET, Settings.AccessToken)); + + yield return request; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularSettings.cs b/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularSettings.cs new file mode 100644 index 00000000000..4ac04777fd5 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/Popular/SimklPopularSettings.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.ImportLists.Simkl.Popular +{ + public class SimklPopularSettingsValidator : SimklSettingsBaseValidator + { + public SimklPopularSettingsValidator() + : base() + { + RuleFor(c => c.SimklListType).NotNull(); + } + } + + public class SimklPopularSettings : SimklSettingsBase + { + protected override AbstractValidator Validator => new SimklPopularSettingsValidator(); + + public SimklPopularSettings() + { + SimklListType = (int)SimklPopularListType.Popular; + } + + [FieldDefinition(1, Label = "List Type", Type = FieldType.Select, SelectOptions = typeof(SimklPopularListType), HelpText = "Type of list you're seeking to import from")] + public int SimklListType { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/SimklImportBase.cs b/src/NzbDrone.Core/ImportLists/Simkl/SimklImportBase.cs new file mode 100644 index 00000000000..ba246665e27 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/SimklImportBase.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Notifications.Simkl; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Simkl +{ + public abstract class SimklImportBase : HttpImportListBase + where TSettings : SimklSettingsBase, new() + { + public ISimklProxy _SimklProxy; + private readonly IImportListRepository _importListRepository; + public override ImportListType ListType => ImportListType.Simkl; + + protected SimklImportBase(IImportListRepository importListRepository, + ISimklProxy simklProxy, + IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + _importListRepository = importListRepository; + _SimklProxy = simklProxy; + } + + public override ImportListFetchResult Fetch() + { + Settings.Validate().Filter("AccessToken", "RefreshToken").ThrowOnError(); + _logger.Trace($"Access token expires at {Settings.Expires}"); + + if (Settings.Expires < DateTime.UtcNow.AddMinutes(5)) + { + RefreshToken(); + } + + var generator = GetRequestGenerator(); + return FetchMovies(generator.GetMovies()); + } + + public override IParseImportListResponse GetParser() + { + return new SimklParser(); + } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "startOAuth") + { + var request = _SimklProxy.GetOAuthRequest(query["callbackUrl"]); + + return new + { + OauthUrl = request.Url.ToString() + }; + } + else if (action == "getOAuthToken") + { + return new + { + accessToken = query["access_token"], + expires = DateTime.UtcNow.AddSeconds(int.Parse(query["expires_in"])), + refreshToken = query["refresh_token"], + authUser = _SimklProxy.GetUserName(query["access_token"]) + }; + } + + return new { }; + } + + private void RefreshToken() + { + _logger.Trace("Refreshing Token"); + + Settings.Validate().Filter("RefreshToken").ThrowOnError(); + + try + { + var response = _SimklProxy.RefreshAuthToken(Settings.RefreshToken); + + if (response != null) + { + var token = response; + Settings.AccessToken = token.AccessToken; + Settings.Expires = DateTime.UtcNow.AddSeconds(token.ExpiresIn); + Settings.RefreshToken = token.RefreshToken ?? Settings.RefreshToken; + + if (Definition.Id > 0) + { + _importListRepository.UpdateSettings((ImportListDefinition)Definition); + } + } + } + catch (HttpException) + { + _logger.Warn($"Error refreshing Simkl access token"); + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/SimklParser.cs b/src/NzbDrone.Core/ImportLists/Simkl/SimklParser.cs new file mode 100644 index 00000000000..4248abe636d --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/SimklParser.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Net; +using Newtonsoft.Json; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.ImportLists.Exceptions; +using NzbDrone.Core.ImportLists.ImportListMovies; +using NzbDrone.Core.Notifications.Simkl.Resource; + +namespace NzbDrone.Core.ImportLists.Simkl +{ + public class SimklParser : IParseImportListResponse + { + private ImportListResponse _importResponse; + + public SimklParser() + { + } + + public virtual IList ParseResponse(ImportListResponse importResponse) + { + _importResponse = importResponse; + + var movies = new List(); + + if (!PreProcess(_importResponse)) + { + return movies; + } + + var jsonResponse = JsonConvert.DeserializeObject>(_importResponse.Content); + + // no movies were return + if (jsonResponse == null) + { + return movies; + } + + foreach (var movie in jsonResponse) + { + movies.AddIfNotNull(new ImportListMovie() + { + Title = movie.Movie.Title, + ImdbId = movie.Movie.Ids.Imdb, + TmdbId = movie.Movie.Ids.Tmdb, + Year = movie.Movie.Year ?? 0 + }); + } + + return movies; + } + + protected virtual bool PreProcess(ImportListResponse importListResponse) + { + if (importListResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new ImportListException(importListResponse, "Simkl API call resulted in an unexpected StatusCode [{0}]", importListResponse.HttpResponse.StatusCode); + } + + if (importListResponse.HttpResponse.Headers.ContentType != null && importListResponse.HttpResponse.Headers.ContentType.Contains("text/json") && + importListResponse.HttpRequest.Headers.Accept != null && !importListResponse.HttpRequest.Headers.Accept.Contains("text/json")) + { + throw new ImportListException(importListResponse, "Simkl API responded with html content. Site is likely blocked or unavailable."); + } + + return true; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/SimklSettingsBase.cs b/src/NzbDrone.Core/ImportLists/Simkl/SimklSettingsBase.cs new file mode 100644 index 00000000000..8824336e2fa --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/SimklSettingsBase.cs @@ -0,0 +1,102 @@ +using System; +using System.Text.RegularExpressions; +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Simkl +{ + public class SimklSettingsBaseValidator : AbstractValidator + where TSettings : SimklSettingsBase + { + public SimklSettingsBaseValidator() + { + RuleFor(c => c.Link).ValidRootUrl(); + RuleFor(c => c.AccessToken).NotEmpty(); + RuleFor(c => c.RefreshToken).NotEmpty(); + RuleFor(c => c.Expires).NotEmpty(); + + // Loose validation @TODO + RuleFor(c => c.Rating) + .Matches(@"^\d+\-\d+$", RegexOptions.IgnoreCase) + .When(c => c.Rating.IsNotNullOrWhiteSpace()) + .WithMessage("Not a valid rating"); + + // Any valid certification + RuleFor(c => c.Certification) + .Matches(@"^\bNR\b|\bG\b|\bPG\b|\bPG\-13\b|\bR\b|\bNC\-17\b$", RegexOptions.IgnoreCase) + .When(c => c.Certification.IsNotNullOrWhiteSpace()) + .WithMessage("Not a valid cerification"); + + // Loose validation @TODO + RuleFor(c => c.Years) + .Matches(@"^\d+(\-\d+)?$", RegexOptions.IgnoreCase) + .When(c => c.Years.IsNotNullOrWhiteSpace()) + .WithMessage("Not a valid year or range of years"); + + // Limit not smaller than 1 and not larger than 100 + RuleFor(c => c.Limit) + .GreaterThan(0) + .WithMessage("Must be integer greater than 0"); + } + } + + public class SimklSettingsBase : IProviderConfig + where TSettings : SimklSettingsBase + { + protected virtual AbstractValidator Validator => new SimklSettingsBaseValidator(); + + public SimklSettingsBase() + { + SignIn = "startOAuth"; + Rating = "0-100"; + Certification = "NR,G,PG,PG-13,R,NC-17"; + Genres = ""; + Years = ""; + Limit = 100; + } + + public string Link => "https://api.Simkl.tv"; + public virtual string Scope => ""; + + [FieldDefinition(0, Label = "Access Token", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public string AccessToken { get; set; } + + [FieldDefinition(0, Label = "Refresh Token", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public string RefreshToken { get; set; } + + [FieldDefinition(0, Label = "Expires", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public DateTime Expires { get; set; } + + [FieldDefinition(0, Label = "Auth User", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public string AuthUser { get; set; } + + [FieldDefinition(1, Label = "Rating", HelpText = "Filter movies by rating range (0-100)")] + public string Rating { get; set; } + + [FieldDefinition(2, Label = "Certification", HelpText = "Filter movies by a certification (NR,G,PG,PG-13,R,NC-17), (Comma Separated)")] + public string Certification { get; set; } + + [FieldDefinition(3, Label = "Genres", HelpText = "Filter movies by Simkl Genre Slug (Comma Separated)")] + public string Genres { get; set; } + + [FieldDefinition(4, Label = "Years", HelpText = "Filter movies by year or year range")] + public string Years { get; set; } + + [FieldDefinition(5, Label = "Limit", HelpText = "Limit the number of movies to get")] + public int Limit { get; set; } + + [FieldDefinition(6, Label = "Additional Parameters", HelpText = "Additional Simkl API parameters", Advanced = true)] + public string SimklAdditionalParameters { get; set; } + + [FieldDefinition(99, Label = "Authenticate with Simkl", Type = FieldType.OAuth)] + public string SignIn { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate((TSettings)this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserImport.cs b/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserImport.cs new file mode 100644 index 00000000000..7e6dafa8b49 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserImport.cs @@ -0,0 +1,34 @@ +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Notifications.Simkl; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.Simkl.User +{ + public class SimklUserImport : SimklImportBase + { + public SimklUserImport(IImportListRepository importListRepository, + ISimklProxy simklProxy, + IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(importListRepository, simklProxy, httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override string Name => "Simkl User"; + public override bool Enabled => true; + public override bool EnableAuto => false; + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new SimklUserRequestGenerator(_SimklProxy) + { + Settings = Settings + }; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserListType.cs b/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserListType.cs new file mode 100644 index 00000000000..804a066f3f5 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserListType.cs @@ -0,0 +1,14 @@ +using System.Runtime.Serialization; + +namespace NzbDrone.Core.ImportLists.Simkl.User +{ + public enum SimklUserListType + { + [EnumMember(Value = "User Watch List")] + UserWatchList = 0, + [EnumMember(Value = "User Watched List")] + UserWatchedList = 1, + [EnumMember(Value = "User Collection List")] + UserCollectionList = 2 + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserRequestGenerator.cs new file mode 100644 index 00000000000..d24f88118db --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserRequestGenerator.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using NzbDrone.Common.Http; +using NzbDrone.Core.Notifications.Simkl; + +namespace NzbDrone.Core.ImportLists.Simkl.User +{ + public class SimklUserRequestGenerator : IImportListRequestGenerator + { + private readonly ISimklProxy _simklProxy; + public SimklUserSettings Settings { get; set; } + + public SimklUserRequestGenerator(ISimklProxy simklProxy) + { + _simklProxy = simklProxy; + } + + public virtual ImportListPageableRequestChain GetMovies() + { + var pageableRequests = new ImportListPageableRequestChain(); + + pageableRequests.Add(GetMoviesRequest()); + + return pageableRequests; + } + + private IEnumerable GetMoviesRequest() + { + var link = string.Empty; + + switch (Settings.SimklListType) + { + case (int)SimklUserListType.UserWatchList: + link += $"users/{Settings.AuthUser.Trim()}/watchlist/movies?limit={Settings.Limit}"; + break; + case (int)SimklUserListType.UserWatchedList: + link += $"users/{Settings.AuthUser.Trim()}/watched/movies?limit={Settings.Limit}"; + break; + case (int)SimklUserListType.UserCollectionList: + link += $"users/{Settings.AuthUser.Trim()}/collection/movies?limit={Settings.Limit}"; + break; + } + + var request = new ImportListRequest(_simklProxy.BuildSimklRequest(link, HttpMethod.GET, Settings.AccessToken)); + + yield return request; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserSettings.cs b/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserSettings.cs new file mode 100644 index 00000000000..7a58dd3d130 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserSettings.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.ImportLists.Simkl.User +{ + public class SimklUserSettingsValidator : SimklSettingsBaseValidator + { + public SimklUserSettingsValidator() + : base() + { + RuleFor(c => c.SimklListType).NotNull(); + RuleFor(c => c.AuthUser).NotEmpty(); + } + } + + public class SimklUserSettings : SimklSettingsBase + { + protected override AbstractValidator Validator => new SimklUserSettingsValidator(); + + public SimklUserSettings() + { + SimklListType = (int)SimklUserListType.UserWatchList; + } + + [FieldDefinition(1, Label = "List Type", Type = FieldType.Select, SelectOptions = typeof(SimklUserListType), HelpText = "Type of list you're seeking to import from")] + public int SimklListType { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklAuthRefreshResource.cs b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklAuthRefreshResource.cs new file mode 100644 index 00000000000..0fd377db621 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklAuthRefreshResource.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Notifications.Simkl.Resource +{ + public class SimklAuthRefreshResource + { + [JsonProperty(PropertyName = "access_token")] + public string AccessToken { get; set; } + [JsonProperty(PropertyName = "token_type")] + public string TokenType { get; set; } + [JsonProperty(PropertyName = "expires_in")] + public int ExpiresIn { get; set; } + [JsonProperty(PropertyName = "refresh_token")] + public string RefreshToken { get; set; } + public string Scope { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklCollectMovieResource.cs b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklCollectMovieResource.cs new file mode 100644 index 00000000000..1ffd9902a26 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklCollectMovieResource.cs @@ -0,0 +1,11 @@ +using System; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Notifications.Simkl.Resource +{ + public class SimklCollectMovie : SimklMovieResource + { + [JsonProperty(PropertyName = "collected_at")] + public DateTime CollectedAt { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklCollectMoviesResource.cs b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklCollectMoviesResource.cs new file mode 100644 index 00000000000..0ef4b81c0dd --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklCollectMoviesResource.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.Simkl.Resource +{ + public class SimklCollectMoviesResource + { + public List Movies { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklListResource.cs b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklListResource.cs new file mode 100644 index 00000000000..d2b2a2c8a16 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklListResource.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Notifications.Simkl.Resource +{ + public class SimklListResource + { + public int? Rank { get; set; } + [JsonProperty(PropertyName = "listed_at")] + public string ListedAt { get; set; } + public string Type { get; set; } + + public int? Watchers { get; set; } + + public long? Revenue { get; set; } + [JsonProperty(PropertyName = "watcher_count")] + public long? WatcherCount { get; set; } + [JsonProperty(PropertyName = "play_count")] + public long? PlayCount { get; set; } + [JsonProperty(PropertyName = "collected_count")] + public long? CollectedCount { get; set; } + + public SimklMovieResource Movie { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklMovieIdsResource.cs b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklMovieIdsResource.cs new file mode 100644 index 00000000000..977671d715f --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklMovieIdsResource.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.Notifications.Simkl.Resource +{ + public class SimklMovieIdsResource + { + public int Simkl { get; set; } + public string Slug { get; set; } + public string Imdb { get; set; } + public int Tmdb { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklMovieResource.cs b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklMovieResource.cs new file mode 100644 index 00000000000..ffac6107022 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklMovieResource.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Notifications.Simkl.Resource +{ + public class SimklMovieResource + { + public string Title { get; set; } + public int? Year { get; set; } + public SimklMovieIdsResource Ids { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklUserIdsResource.cs b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklUserIdsResource.cs new file mode 100644 index 00000000000..3b845e4d2fe --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklUserIdsResource.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Notifications.Simkl.Resource +{ + public class SimklUserIdsResource + { + public string Slug { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklUserResource.cs b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklUserResource.cs new file mode 100644 index 00000000000..71a85e1805a --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklUserResource.cs @@ -0,0 +1,10 @@ +using NzbDrone.Core.Notifications.Simkl.Resource; + +namespace NzbDrone.Core.Notifications.Simkl +{ + public class SimklUserResource + { + public string Username { get; set; } + public SimklUserIdsResource Ids { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklUserSettingsResource.cs b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklUserSettingsResource.cs new file mode 100644 index 00000000000..d60316544a5 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/Resource/SimklUserSettingsResource.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Notifications.Simkl.Resource +{ + public class SimklUserSettingsResource + { + public SimklUserResource User { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/Simkl.cs b/src/NzbDrone.Core/Notifications/Simkl/Simkl.cs new file mode 100644 index 00000000000..e91c3c44563 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/Simkl.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Simkl +{ + public class Simkl : NotificationBase + { + private readonly ISimklService _simklService; + private readonly INotificationRepository _notificationRepository; + private readonly Logger _logger; + + public Simkl(ISimklService simklService, INotificationRepository notificationRepository, Logger logger) + { + _simklService = simklService; + _notificationRepository = notificationRepository; + _logger = logger; + } + + public override string Link => "https://simkl.com/"; + public override string Name => "Simkl"; + + public override void OnDownload(DownloadMessage message) + { + _simklService.AddMovieToCollection(Settings, message.Movie, message.MovieFile); + } + + public override void OnDelete(DeleteMessage message) + { + if (message.Reason != MediaFiles.DeleteMediaFileReason.Upgrade) + { + _simklService.RemoveMovieFromCollection(Settings, message.Movie, message.MovieFile); + } + } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_simklService.Test(Settings)); + + return new ValidationResult(failures); + } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "startOAuth") + { + var request = _simklService.GetOAuthRequest(query["callbackUrl"]); + + return new + { + OauthUrl = request.Url.ToString() + }; + } + else if (action == "getOAuthToken") + { + return new + { + accessToken = query["access_token"], + expires = DateTime.UtcNow.AddSeconds(int.Parse(query["expires_in"])), + refreshToken = query["refresh_token"], + authUser = _simklService.GetUserName(query["access_token"]) + }; + } + + return new { }; + } + + public void RefreshToken() + { + _logger.Trace("Refreshing Token"); + + Settings.Validate().Filter("RefreshToken").ThrowOnError(); + + try + { + var response = _simklService.RefreshAuthToken(Settings.RefreshToken); + + if (response != null) + { + var token = response; + Settings.AccessToken = token.AccessToken; + Settings.Expires = DateTime.UtcNow.AddSeconds(token.ExpiresIn); + Settings.RefreshToken = token.RefreshToken ?? Settings.RefreshToken; + + if (Definition.Id > 0) + { + _notificationRepository.UpdateSettings((NotificationDefinition)Definition); + } + } + } + catch (HttpException) + { + _logger.Warn($"Error refreshing Simkl access token"); + } + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/SimklException.cs b/src/NzbDrone.Core/Notifications/Simkl/SimklException.cs new file mode 100644 index 00000000000..5aa6306d0c6 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/SimklException.cs @@ -0,0 +1,18 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Notifications.Simkl +{ + public class SimklException : NzbDroneException + { + public SimklException(string message) + : base(message) + { + } + + public SimklException(string message, Exception innerException, params object[] args) + : base(message, innerException, args) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/SimklProxy.cs b/src/NzbDrone.Core/Notifications/Simkl/SimklProxy.cs new file mode 100644 index 00000000000..adf379062c8 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/SimklProxy.cs @@ -0,0 +1,130 @@ +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Notifications.Simkl.Resource; + +namespace NzbDrone.Core.Notifications.Simkl +{ + public interface ISimklProxy + { + string GetUserName(string accessToken); + HttpRequest GetOAuthRequest(string callbackUrl); + SimklAuthRefreshResource RefreshAuthToken(string refreshToken); + void AddToCollection(SimklCollectMoviesResource payload, string accessToken); + void RemoveFromCollection(SimklCollectMoviesResource payload, string accessToken); + HttpRequest BuildSimklRequest(string resource, HttpMethod method, string accessToken); + } + + public class SimklProxy : ISimklProxy + { + private const string URL = "https://api.simkl.com"; + private const string OAuthUrl = "https://api.simkl.com/oauth/authorize"; + private const string RedirectUri = "https://auth.servarr.com/v1/simkl/auth"; + private const string RenewUri = "https://auth.servarr.com/v1/simkl/renew"; + private const string ClientId = "03d807fd74d79caa123c19dc7a2f1cb5851604e6b0e2530b03986fc95c4f19c6"; + + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public SimklProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public void AddToCollection(SimklCollectMoviesResource payload, string accessToken) + { + var request = BuildSimklRequest("sync/collection", HttpMethod.POST, accessToken); + + request.Headers.ContentType = "application/json"; + request.SetContent(payload.ToJson()); + + try + { + _httpClient.Execute(request); + } + catch (HttpException ex) + { + _logger.Error(ex, "Unable to post payload {0}", payload); + throw new SimklException("Unable to post payload", ex); + } + } + + public void RemoveFromCollection(SimklCollectMoviesResource payload, string accessToken) + { + var request = BuildSimklRequest("sync/collection/remove", HttpMethod.POST, accessToken); + + request.Headers.ContentType = "application/json"; + request.SetContent(payload.ToJson()); + + try + { + _httpClient.Execute(request); + } + catch (HttpException ex) + { + _logger.Error(ex, "Unable to post payload {0}", payload); + throw new SimklException("Unable to post payload", ex); + } + } + + public string GetUserName(string accessToken) + { + var request = BuildSimklRequest("users/settings", HttpMethod.GET, accessToken); + + try + { + var response = _httpClient.Get(request); + + if (response != null && response.Resource != null) + { + return response.Resource.User.Ids.Slug; + } + } + catch (HttpException) + { + _logger.Warn($"Error refreshing Simkl access token"); + } + + return null; + } + + public HttpRequest GetOAuthRequest(string callbackUrl) + { + return new HttpRequestBuilder(OAuthUrl) + .AddQueryParam("client_id", ClientId) + .AddQueryParam("response_type", "code") + .AddQueryParam("redirect_uri", RedirectUri) + .AddQueryParam("state", callbackUrl) + .Build(); + } + + public SimklAuthRefreshResource RefreshAuthToken(string refreshToken) + { + var request = new HttpRequestBuilder(RenewUri) + .AddQueryParam("refresh_token", refreshToken) + .Build(); + + return _httpClient.Get(request)?.Resource ?? null; + } + + public HttpRequest BuildSimklRequest(string resource, HttpMethod method, string accessToken) + { + var request = new HttpRequestBuilder(URL).Resource(resource).Build(); + + request.Headers.Accept = HttpAccept.Json.Value; + request.Method = method; + + request.Headers.Add("simkl-api-version", "2"); + request.Headers.Add("simkl-api-key", ClientId); + + if (accessToken.IsNotNullOrWhiteSpace()) + { + request.Headers.Add("Authorization", "Bearer " + accessToken); + } + + return request; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/SimklService.cs b/src/NzbDrone.Core/Notifications/Simkl/SimklService.cs new file mode 100644 index 00000000000..21fe275f6ef --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/SimklService.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Notifications.Simkl.Resource; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.Notifications.Simkl +{ + public interface ISimklService + { + HttpRequest GetOAuthRequest(string callbackUrl); + SimklAuthRefreshResource RefreshAuthToken(string refreshToken); + void AddMovieToCollection(SimklSettings settings, Movie movie, MovieFile movieFile); + void RemoveMovieFromCollection(SimklSettings settings, Movie movie, MovieFile movieFile); + string GetUserName(string accessToken); + ValidationFailure Test(SimklSettings settings); + } + + public class SimklService : ISimklService + { + private readonly ISimklProxy _proxy; + private readonly Logger _logger; + + public SimklService(ISimklProxy proxy, + Logger logger) + { + _proxy = proxy; + _logger = logger; + } + + public string GetUserName(string accessToken) + { + return _proxy.GetUserName(accessToken); + } + + public HttpRequest GetOAuthRequest(string callbackUrl) + { + return _proxy.GetOAuthRequest(callbackUrl); + } + + public SimklAuthRefreshResource RefreshAuthToken(string refreshToken) + { + return _proxy.RefreshAuthToken(refreshToken); + } + + public ValidationFailure Test(SimklSettings settings) + { + try + { + GetUserName(settings.AccessToken); + return null; + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _logger.Error(ex, "Access Token is invalid: " + ex.Message); + return new ValidationFailure("Token", "Access Token is invalid"); + } + + _logger.Error(ex, "Unable to send test message: " + ex.Message); + return new ValidationFailure("Token", "Unable to send test message"); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send test message: " + ex.Message); + return new ValidationFailure("", "Unable to send test message"); + } + } + + public void RemoveMovieFromCollection(SimklSettings settings, Movie movie, MovieFile movieFile) + { + var payload = new SimklCollectMoviesResource + { + Movies = new List() + }; + + payload.Movies.Add(new SimklCollectMovie + { + Title = movie.Title, + Year = movie.Year, + Ids = new SimklMovieIdsResource + { + Tmdb = movie.TmdbId, + Imdb = movie.ImdbId ?? "", + } + }); + + _proxy.RemoveFromCollection(payload, settings.AccessToken); + } + + public void AddMovieToCollection(SimklSettings settings, Movie movie, MovieFile movieFile) + { + var payload = new SimklCollectMoviesResource + { + Movies = new List() + }; + + payload.Movies.Add(new SimklCollectMovie + { + Title = movie.Title, + Year = movie.Year, + CollectedAt = DateTime.Now, + Ids = new SimklMovieIdsResource + { + Tmdb = movie.TmdbId, + Imdb = movie.ImdbId ?? "", + } + }); + + _proxy.AddToCollection(payload, settings.AccessToken); + } + + private string MapMediaType(Source source) + { + var simklSource = string.Empty; + + switch (source) + { + case Source.BLURAY: + simklSource = "bluray"; + break; + case Source.WEBDL: + simklSource = "digital"; + break; + case Source.WEBRIP: + simklSource = "digital"; + break; + case Source.DVD: + simklSource = "dvd"; + break; + case Source.TV: + simklSource = "dvd"; + break; + } + + return simklSource; + } + + private string MapResolution(int resolution, string scanType) + { + var simklResolution = string.Empty; + var interlacedTypes = new string[] { "Interlaced", "MBAFF", "PAFF" }; + + var scanIdentifier = scanType.IsNotNullOrWhiteSpace() && interlacedTypes.Contains(scanType) ? "i" : "p"; + + switch (resolution) + { + case 2160: + simklResolution = "uhd_4k"; + break; + case 1080: + simklResolution = string.Format("hd_1080{0}", scanIdentifier); + break; + case 720: + simklResolution = "hd_720p"; + break; + case 576: + simklResolution = string.Format("sd_576{0}", scanIdentifier); + break; + case 480: + simklResolution = string.Format("sd_480{0}", scanIdentifier); + break; + } + + return simklResolution; + } + + private string MapAudio(MovieFile movieFile) + { + var simklAudioFormat = string.Empty; + + var audioCodec = movieFile.MediaInfo != null ? MediaInfoFormatter.FormatAudioCodec(movieFile.MediaInfo, movieFile.SceneName) : string.Empty; + + switch (audioCodec) + { + case "AC3": + simklAudioFormat = "dolby_digital"; + break; + case "EAC3": + simklAudioFormat = "dolby_digital_plus"; + break; + case "TrueHD": + simklAudioFormat = "dolby_truehd"; + break; + case "EAC3 Atmos": + simklAudioFormat = "dolby_digital_plus_atmos"; + break; + case "TrueHD Atmos": + simklAudioFormat = "dolby_atmos"; + break; + case "DTS": + case "DTS-ES": + simklAudioFormat = "dts"; + break; + case "DTS-HD MA": + simklAudioFormat = "dts_ma"; + break; + case "DTS-HD HRA": + simklAudioFormat = "dts_hr"; + break; + case "DTS-X": + simklAudioFormat = "dts_x"; + break; + case "MP3": + simklAudioFormat = "mp3"; + break; + case "MP2": + simklAudioFormat = "mp2"; + break; + case "Vorbis": + simklAudioFormat = "ogg"; + break; + case "WMA": + simklAudioFormat = "wma"; + break; + case "AAC": + simklAudioFormat = "aac"; + break; + case "PCM": + simklAudioFormat = "lpcm"; + break; + case "FLAC": + simklAudioFormat = "flac"; + break; + case "Opus": + simklAudioFormat = "ogg_opus"; + break; + } + + return simklAudioFormat; + } + + private string MapAudioChannels(MovieFile movieFile, string audioFormat) + { + var audioChannels = movieFile.MediaInfo != null ? MediaInfoFormatter.FormatAudioChannels(movieFile.MediaInfo).ToString("0.0") : string.Empty; + + // Map cases where Radarr doesn't handle MI correctly, can purge once mediainfo handling is improved + if (audioChannels == "8.0") + { + audioChannels = "7.1"; + } + else if (audioChannels == "6.0" && audioFormat == "dts_ma") + { + audioChannels = "7.1"; + } + else if (audioChannels == "6.0" && audioFormat != "dts_ma") + { + audioChannels = "5.1"; + } + else if (audioChannels == "0.0") + { + audioChannels = string.Empty; + } + + return audioChannels; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Simkl/SimklSettings.cs b/src/NzbDrone.Core/Notifications/Simkl/SimklSettings.cs new file mode 100644 index 00000000000..22c654fcff5 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Simkl/SimklSettings.cs @@ -0,0 +1,48 @@ +using System; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Simkl +{ + public class SimklSettingsValidator : AbstractValidator + { + public SimklSettingsValidator() + { + RuleFor(c => c.AccessToken).NotEmpty(); + RuleFor(c => c.RefreshToken).NotEmpty(); + RuleFor(c => c.Expires).NotEmpty(); + } + } + + public class SimklSettings : IProviderConfig + { + private static readonly SimklSettingsValidator Validator = new SimklSettingsValidator(); + + public SimklSettings() + { + SignIn = "startOAuth"; + } + + [FieldDefinition(0, Label = "Access Token", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public string AccessToken { get; set; } + + [FieldDefinition(0, Label = "Refresh Token", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public string RefreshToken { get; set; } + + [FieldDefinition(0, Label = "Expires", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public DateTime Expires { get; set; } + + [FieldDefinition(0, Label = "Auth User", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public string AuthUser { get; set; } + + [FieldDefinition(99, Label = "Authenticate with Simkl", Type = FieldType.OAuth)] + public string SignIn { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +}