New: Simkl List Support (#5313)

* New: Simkl List Support

* fixup! smarter sync

* fixup! comments

* fixup! comments
This commit is contained in:
Qstick 2023-01-07 12:58:10 -06:00 committed by GitHub
parent e07936fb84
commit 4a740acb80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 501 additions and 4 deletions

View File

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

View File

@ -6,6 +6,6 @@ namespace NzbDrone.Core.ImportLists
{
public class ImportListStatus : ProviderStatusBase
{
public DateTime LastInfoSync { get; set; }
public DateTime? LastInfoSync { get; set; }
}
}

View File

@ -8,7 +8,7 @@ namespace NzbDrone.Core.ImportLists
{
public interface IImportListStatusService : IProviderStatusServiceBase<ImportListStatus>
{
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;
}

View File

@ -5,6 +5,7 @@ namespace NzbDrone.Core.ImportLists
Program,
Plex,
Trakt,
Simkl,
Other,
Advanced
}

View File

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

View File

@ -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<TSettings> : HttpImportListBase<TSettings>
where TSettings : SimklSettingsBase<TSettings>, 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<ImportListItemInfo> 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<ImportListItemInfo>();
}
var generator = GetRequestGenerator();
return FetchItems(g => g.GetListItems(), true);
}
public override IParseImportListResponse GetParser()
{
return new SimklParser();
}
public override object RequestAction(string action, IDictionary<string, string> 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<SimklSyncActivityResource>(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<UserSettingsResponse>(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<RefreshRequestResponse>(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");
}
}
}
}

View File

@ -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<ImportListItemInfo> ParseResponse(ImportListResponse importResponse)
{
_importResponse = importResponse;
var series = new List<ImportListItemInfo>();
if (!PreProcess(_importResponse))
{
return series;
}
var jsonResponse = STJson.Deserialize<SimklResponse>(_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;
}
}
}

View File

@ -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<TSettings> : AbstractValidator<TSettings>
where TSettings : SimklSettingsBase<TSettings>
{
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<TSettings> : IImportListSettings
where TSettings : SimklSettingsBase<TSettings>
{
protected virtual AbstractValidator<TSettings> Validator => new SimklSettingsBaseValidator<TSettings>();
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));
}
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,27 @@
using FluentValidation;
using NzbDrone.Core.Annotations;
namespace NzbDrone.Core.ImportLists.Simkl.User
{
public class SimklUserSettingsValidator : SimklSettingsBaseValidator<SimklUserSettings>
{
public SimklUserSettingsValidator()
: base()
{
RuleFor(c => c.ListType).NotNull();
}
}
public class SimklUserSettings : SimklSettingsBase<SimklUserSettings>
{
protected override AbstractValidator<SimklUserSettings> 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; }
}
}