diff --git a/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs b/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs index 3dca6c66a..9a7804e72 100644 --- a/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs +++ b/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs @@ -49,7 +49,7 @@ namespace NzbDrone.Core.ImportLists var importListLocal = importList; var importListStatus = _importListStatusService.GetLastSyncListInfo(importListLocal.Definition.Id); - if (DateTime.UtcNow < (importListStatus + importListLocal.MinRefreshInterval)) + if (importListStatus.HasValue && DateTime.UtcNow < (importListStatus + importListLocal.MinRefreshInterval)) { _logger.Trace("Skipping refresh of Import List {0} due to minimum refresh inverval", importListLocal.Definition.Name); continue; diff --git a/src/NzbDrone.Core/ImportLists/ImportListStatus.cs b/src/NzbDrone.Core/ImportLists/ImportListStatus.cs index 69892e1b5..90efc387b 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListStatus.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListStatus.cs @@ -6,6 +6,6 @@ namespace NzbDrone.Core.ImportLists { public class ImportListStatus : ProviderStatusBase { - public DateTime LastInfoSync { get; set; } + public DateTime? LastInfoSync { get; set; } } } diff --git a/src/NzbDrone.Core/ImportLists/ImportListStatusService.cs b/src/NzbDrone.Core/ImportLists/ImportListStatusService.cs index e543d0a7e..bbef4b179 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListStatusService.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListStatusService.cs @@ -8,7 +8,7 @@ namespace NzbDrone.Core.ImportLists { public interface IImportListStatusService : IProviderStatusServiceBase { - DateTime GetLastSyncListInfo(int importListId); + DateTime? GetLastSyncListInfo(int importListId); void UpdateListSyncStatus(int importListId); } @@ -20,7 +20,7 @@ namespace NzbDrone.Core.ImportLists { } - public DateTime GetLastSyncListInfo(int importListId) + public DateTime? GetLastSyncListInfo(int importListId) { return GetProviderStatus(importListId).LastInfoSync; } diff --git a/src/NzbDrone.Core/ImportLists/ImportListType.cs b/src/NzbDrone.Core/ImportLists/ImportListType.cs index 179db800c..de5d188b6 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListType.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListType.cs @@ -5,6 +5,7 @@ namespace NzbDrone.Core.ImportLists Program, Plex, Trakt, + Simkl, Other, Advanced } diff --git a/src/NzbDrone.Core/ImportLists/Simkl/SimklAPI.cs b/src/NzbDrone.Core/ImportLists/Simkl/SimklAPI.cs new file mode 100644 index 000000000..4472af249 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/SimklAPI.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.ImportLists.Simkl +{ + public class SimklSeriesIdsResource + { + public int Simkl { get; set; } + public string Slug { get; set; } + public string Imdb { get; set; } + public string Tmdb { get; set; } + public string Tvdb { get; set; } + } + + public class SimklSeriesPropsResource + { + public string Title { get; set; } + public int? Year { get; set; } + public SimklSeriesIdsResource Ids { get; set; } + } + + public class SimklSeriesResource + { + public SimklSeriesPropsResource Show { get; set; } + } + + public class SimklResponse + { + public List Shows { get; set; } + } + + public class RefreshRequestResponse + { + [JsonProperty("access_token")] + public string AccessToken { get; set; } + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + [JsonProperty("refresh_token")] + public string RefreshToken { get; set; } + } + + public class UserSettingsResponse + { + public SimklUserResource User { get; set; } + public SimklUserAccountResource Account { get; set; } + } + + public class SimklUserResource + { + public string Name { get; set; } + } + + public class SimklUserAccountResource + { + public int Id { get; set; } + } + + public class SimklSyncActivityResource + { + [JsonProperty("tv_shows")] + public SimklTvSyncActivityResource TvShows { get; set; } + } + + public class SimklTvSyncActivityResource + { + public DateTime All { 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 000000000..f6daf9e5f --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/SimklImportBase.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Simkl +{ + public abstract class SimklImportBase : HttpImportListBase + where TSettings : SimklSettingsBase, new() + { + public override ImportListType ListType => ImportListType.Simkl; + public override TimeSpan MinRefreshInterval => TimeSpan.FromHours(6); + + public const string OAuthUrl = "https://simkl.com/oauth/authorize"; + public const string RedirectUri = "https://auth.servarr.com/v1/simkl_sonarr/auth"; + public const string RenewUri = "https://auth.servarr.com/v1/simkl_sonarr/renew"; + public const string ClientId = "3281c139f576b2f59c1389b22337140b6b087ee17e000e89dbafdcf20af6dac7"; + + private IImportListRepository _importListRepository; + + protected SimklImportBase(IImportListRepository netImportRepository, + IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + _importListRepository = netImportRepository; + } + + public override IList Fetch() + { + Settings.Validate().Filter("AccessToken", "RefreshToken").ThrowOnError(); + _logger.Trace($"Access token expires at {Settings.Expires}"); + + // Simkl doesn't currently expire access tokens, but if they start lets be prepared + if (Settings.RefreshToken.IsNotNullOrWhiteSpace() && Settings.Expires < DateTime.UtcNow.AddMinutes(5)) + { + RefreshToken(); + } + + var lastFetch = _importListStatusService.GetLastSyncListInfo(Definition.Id); + var lastActivity = GetLastActivity(); + + // Check to see if user has any activity since last sync, if not return empty to avoid work + if (lastFetch.HasValue && lastActivity < lastFetch.Value.AddHours(-2)) + { + return new List(); + } + + var generator = GetRequestGenerator(); + + return FetchItems(g => g.GetListItems(), true); + } + + public override IParseImportListResponse GetParser() + { + return new SimklParser(); + } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "startOAuth") + { + var request = new HttpRequestBuilder(OAuthUrl) + .AddQueryParam("client_id", ClientId) + .AddQueryParam("response_type", "code") + .AddQueryParam("redirect_uri", RedirectUri) + .AddQueryParam("state", query["callbackUrl"]) + .Build(); + + 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 = GetUserId(query["access_token"]) + }; + } + + return new { }; + } + + private DateTime GetLastActivity() + { + var request = new HttpRequestBuilder(string.Format("{0}/sync/activities", Settings.BaseUrl)).Build(); + + request.Headers.Add("simkl-api-key", ClientId); + request.Headers.Add("Authorization", "Bearer " + Settings.AccessToken); + + try + { + var response = _httpClient.Get(request); + + if (response?.Resource != null) + { + return response.Resource.TvShows.All; + } + } + catch (HttpException) + { + _logger.Warn($"Error fetching user activity"); + } + + return DateTime.UtcNow; + } + + private string GetUserId(string accessToken) + { + var request = new HttpRequestBuilder(string.Format("{0}/users/settings", Settings.BaseUrl)) + .Build(); + + request.Headers.Add("simkl-api-key", ClientId); + + if (accessToken.IsNotNullOrWhiteSpace()) + { + request.Headers.Add("Authorization", "Bearer " + accessToken); + } + + try + { + var response = _httpClient.Get(request); + + if (response?.Resource != null) + { + return response.Resource.Account.Id.ToString(); + } + } + catch (HttpException) + { + _logger.Warn($"Error refreshing simkl access token"); + } + + return null; + } + + private void RefreshToken() + { + _logger.Trace("Refreshing Token"); + + Settings.Validate().Filter("RefreshToken").ThrowOnError(); + + var request = new HttpRequestBuilder(RenewUri) + .AddQueryParam("refresh_token", Settings.RefreshToken) + .Build(); + + try + { + var response = _httpClient.Get(request); + + if (response?.Resource != null) + { + var token = response.Resource; + 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 000000000..53d72809c --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/SimklParser.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Net; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.ImportLists.Exceptions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists.Simkl +{ + public class SimklParser : IParseImportListResponse + { + private ImportListResponse _importResponse; + + public SimklParser() + { + } + + public virtual IList ParseResponse(ImportListResponse importResponse) + { + _importResponse = importResponse; + + var series = new List(); + + if (!PreProcess(_importResponse)) + { + return series; + } + + var jsonResponse = STJson.Deserialize(_importResponse.Content); + + // no shows were return + if (jsonResponse == null) + { + return series; + } + + foreach (var show in jsonResponse.Shows) + { + series.AddIfNotNull(new ImportListItemInfo() + { + Title = show.Show.Title, + TvdbId = int.TryParse(show.Show.Ids.Tvdb, out var tvdbId) ? tvdbId : 0, + ImdbId = show.Show.Ids.Imdb + }); + } + + return series; + } + + protected virtual bool PreProcess(ImportListResponse netImportResponse) + { + if (netImportResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new ImportListException(netImportResponse, "Simkl API call resulted in an unexpected StatusCode [{0}]", netImportResponse.HttpResponse.StatusCode); + } + + if (netImportResponse.HttpResponse.Headers.ContentType != null && netImportResponse.HttpResponse.Headers.ContentType.Contains("text/json") && + netImportResponse.HttpRequest.Headers.Accept != null && !netImportResponse.HttpRequest.Headers.Accept.Contains("text/json")) + { + throw new ImportListException(netImportResponse, "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 000000000..f77de5665 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/SimklSettingsBase.cs @@ -0,0 +1,60 @@ +using System; +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Simkl +{ + public class SimklSettingsBaseValidator : AbstractValidator + where TSettings : SimklSettingsBase + { + public SimklSettingsBaseValidator() + { + RuleFor(c => c.BaseUrl).ValidRootUrl(); + + RuleFor(c => c.AccessToken).NotEmpty() + .OverridePropertyName("SignIn") + .WithMessage("Must authenticate with Simkl"); + + RuleFor(c => c.Expires).NotEmpty() + .OverridePropertyName("SignIn") + .WithMessage("Must authenticate with Simkl") + .When(c => c.AccessToken.IsNotNullOrWhiteSpace() && c.RefreshToken.IsNotNullOrWhiteSpace()); + } + } + + public class SimklSettingsBase : IImportListSettings + where TSettings : SimklSettingsBase + { + protected virtual AbstractValidator Validator => new SimklSettingsBaseValidator(); + + public SimklSettingsBase() + { + BaseUrl = "https://api.simkl.com"; + SignIn = "startOAuth"; + } + + public string BaseUrl { get; set; } + + [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((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 000000000..d7a9f7908 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserImport.cs @@ -0,0 +1,31 @@ +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.Simkl.User +{ + public class SimklUserImport : SimklImportBase + { + public SimklUserImport(IImportListRepository netImportRepository, + IHttpClient httpClient, + IImportListStatusService netImportStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(netImportRepository, httpClient, netImportStatusService, configService, parsingService, logger) + { + } + + public override string Name => "Simkl User Watchlist"; + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new SimklUserRequestGenerator() + { + Settings = Settings, + ClientId = ClientId + }; + } + } +} 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 000000000..e00bc60ac --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserListType.cs @@ -0,0 +1,18 @@ +using System.Runtime.Serialization; + +namespace NzbDrone.Core.ImportLists.Simkl.User +{ + public enum SimklUserListType + { + [EnumMember(Value = "Watching")] + Watching = 0, + [EnumMember(Value = "Plan To Watch")] + PlanToWatch = 1, + [EnumMember(Value = "Hold")] + Hold = 2, + [EnumMember(Value = "Completed")] + Completed = 3, + [EnumMember(Value = "Dropped")] + Dropped = 4 + } +} 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 000000000..3f30be390 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserRequestGenerator.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.ImportLists.Simkl.User +{ + public class SimklUserRequestGenerator : IImportListRequestGenerator + { + public SimklUserSettings Settings { get; set; } + + public string ClientId { get; set; } + + public SimklUserRequestGenerator() + { + } + + public virtual ImportListPageableRequestChain GetListItems() + { + var pageableRequests = new ImportListPageableRequestChain(); + + pageableRequests.Add(GetSeriesRequest()); + + return pageableRequests; + } + + private IEnumerable GetSeriesRequest() + { + var link = $"{Settings.BaseUrl.Trim()}/sync/all-items/shows/{((SimklUserListType)Settings.ListType).ToString().ToLowerInvariant()}"; + + var request = new ImportListRequest(link, HttpAccept.Json); + + request.HttpRequest.Headers.Add("simkl-api-key", ClientId); + + if (Settings.AccessToken.IsNotNullOrWhiteSpace()) + { + request.HttpRequest.Headers.Add("Authorization", "Bearer " + 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 000000000..61cc48129 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Simkl/User/SimklUserSettings.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.ImportLists.Simkl.User +{ + public class SimklUserSettingsValidator : SimklSettingsBaseValidator + { + public SimklUserSettingsValidator() + : base() + { + RuleFor(c => c.ListType).NotNull(); + } + } + + public class SimklUserSettings : SimklSettingsBase + { + protected override AbstractValidator Validator => new SimklUserSettingsValidator(); + + public SimklUserSettings() + { + ListType = (int)SimklUserListType.Watching; + } + + [FieldDefinition(1, Label = "List Type", Type = FieldType.Select, SelectOptions = typeof(SimklUserListType), HelpText = "Type of list you're seeking to import from")] + public int ListType { get; set; } + } +}