AniList import list module
This commit is contained in:
parent
1a4403e0ab
commit
ddf3a208b4
|
@ -0,0 +1,155 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using NzbDrone.Common.Serializer;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.ImportLists.AniList
|
||||||
|
{
|
||||||
|
public enum AniListQuery
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
QueryList
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AniListAPI
|
||||||
|
{
|
||||||
|
// The maximum items anilist will return is 50 per request.
|
||||||
|
// Therefore, pagination must be used to retrieve the full list.
|
||||||
|
private const string QueryList = @"
|
||||||
|
query ($id: String, $statusType: [MediaListStatus], $page: Int) {
|
||||||
|
Page(page: $page) {
|
||||||
|
pageInfo {
|
||||||
|
currentPage
|
||||||
|
lastPage
|
||||||
|
hasNextPage
|
||||||
|
}
|
||||||
|
mediaList(userName: $id, type: ANIME, status_in: $statusType) {
|
||||||
|
status
|
||||||
|
progress
|
||||||
|
media {
|
||||||
|
id
|
||||||
|
format
|
||||||
|
title {
|
||||||
|
userPreferred
|
||||||
|
romaji
|
||||||
|
}
|
||||||
|
status
|
||||||
|
episodes
|
||||||
|
startDate {
|
||||||
|
year
|
||||||
|
month
|
||||||
|
day
|
||||||
|
}
|
||||||
|
endDate {
|
||||||
|
year
|
||||||
|
month
|
||||||
|
day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
public static string BuildQuery(AniListQuery query, object data)
|
||||||
|
{
|
||||||
|
var querySource = "";
|
||||||
|
if (query == AniListQuery.QueryList)
|
||||||
|
{
|
||||||
|
querySource = QueryList;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(querySource))
|
||||||
|
{
|
||||||
|
throw new Exception("Unknown Query Type");
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = Json.ToJson(new
|
||||||
|
{
|
||||||
|
query = querySource,
|
||||||
|
variables = data
|
||||||
|
});
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PageInfo
|
||||||
|
{
|
||||||
|
public int CurrentPage { get; set; }
|
||||||
|
|
||||||
|
public int LastPage { get; set; }
|
||||||
|
|
||||||
|
public bool HasNextPage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MediaTitles
|
||||||
|
{
|
||||||
|
public string UserPreferred { get; set; }
|
||||||
|
|
||||||
|
public string UserRomaji { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MediaInfo
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public string Status { get; set; }
|
||||||
|
|
||||||
|
public string Format { get; set; }
|
||||||
|
|
||||||
|
public int? Episodes { get; set; }
|
||||||
|
|
||||||
|
public MediaTitles Title { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public YMDBlock StartDate { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public YMDBlock EndDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MediaList
|
||||||
|
{
|
||||||
|
public MediaInfo Media { get; set; }
|
||||||
|
|
||||||
|
public string Status { get; set; }
|
||||||
|
|
||||||
|
public int Progress { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MediaPage
|
||||||
|
{
|
||||||
|
public PageInfo PageInfo { get; set; }
|
||||||
|
|
||||||
|
public List<MediaList> MediaList { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MediaPageData
|
||||||
|
{
|
||||||
|
public MediaPage Page { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MediaPageResponse
|
||||||
|
{
|
||||||
|
public MediaPageData Data { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class YMDBlock
|
||||||
|
{
|
||||||
|
public int? Year { get; set; }
|
||||||
|
|
||||||
|
public int? Month { get; set; }
|
||||||
|
|
||||||
|
public int? Day { get; set; }
|
||||||
|
|
||||||
|
public DateTime? Convert()
|
||||||
|
{
|
||||||
|
if (Year == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DateTime((int)Year, Month ?? 1, Day ?? 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,210 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
using FluentValidation.Results;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using NLog;
|
||||||
|
using NzbDrone.Common.Cache;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Common.Http;
|
||||||
|
using NzbDrone.Common.Serializer;
|
||||||
|
using NzbDrone.Core.Configuration;
|
||||||
|
using NzbDrone.Core.Parser;
|
||||||
|
using NzbDrone.Core.Parser.Model;
|
||||||
|
using NzbDrone.Core.Validation;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.ImportLists.AniList
|
||||||
|
{
|
||||||
|
public abstract class AniListImportBase<TSettings> : HttpImportListBase<TSettings>
|
||||||
|
where TSettings : AniListSettingsBase<TSettings>, new()
|
||||||
|
{
|
||||||
|
public override ImportListType ListType => ImportListType.Other;
|
||||||
|
public override TimeSpan MinRefreshInterval => TimeSpan.FromHours(12);
|
||||||
|
|
||||||
|
private const string _cacheKey = "animemappings";
|
||||||
|
private readonly TimeSpan _cacheInterval = TimeSpan.FromHours(20);
|
||||||
|
private readonly ICached<Dictionary<int, MediaMapping>> _cache;
|
||||||
|
|
||||||
|
public const string OAuthUrl = "https://anilist.co/api/v2/oauth/authorize";
|
||||||
|
public const string RedirectUri = "http://localhost:5000/anilist/auth";
|
||||||
|
public const string RenewUri = "http://localhost:5000/anilist/renew";
|
||||||
|
public const string ClientId = "13737";
|
||||||
|
|
||||||
|
private IImportListRepository _importListRepository;
|
||||||
|
|
||||||
|
protected AniListImportBase(IImportListRepository netImportRepository,
|
||||||
|
IHttpClient httpClient,
|
||||||
|
IImportListStatusService importListStatusService,
|
||||||
|
IConfigService configService,
|
||||||
|
IParsingService parsingService,
|
||||||
|
Logger logger,
|
||||||
|
ICacheManager cacheManager)
|
||||||
|
: base(httpClient, importListStatusService, configService, parsingService, logger)
|
||||||
|
{
|
||||||
|
_importListRepository = netImportRepository;
|
||||||
|
_cache = cacheManager.GetCache<Dictionary<int, MediaMapping>>(GetType());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<int, MediaMapping> Mappings => _cache.Get(_cacheKey, GetMappingData, _cacheInterval);
|
||||||
|
|
||||||
|
public override AniListParser GetParser()
|
||||||
|
{
|
||||||
|
return new AniListParser(_logger, Mappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new { };
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshMappings()
|
||||||
|
{
|
||||||
|
var mappings = GetMappingData();
|
||||||
|
_cache.Set(_cacheKey, mappings, _cacheInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IList<ImportListItemInfo> Fetch()
|
||||||
|
{
|
||||||
|
CheckToken();
|
||||||
|
return base.Fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual Dictionary<int, MediaMapping> GetMappingData()
|
||||||
|
{
|
||||||
|
var result = new Dictionary<int, MediaMapping>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new HttpRequest(Settings.MapSourceUrl, HttpAccept.Json);
|
||||||
|
var response = _httpClient.Execute(request);
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
var mappingList = STJson.Deserialize<List<MediaMapping>>(response.Content);
|
||||||
|
foreach (var item in mappingList)
|
||||||
|
{
|
||||||
|
if (item.Anilist.HasValue && item.Anilist > 0 && item.TVDB.HasValue && item.TVDB > 0)
|
||||||
|
{
|
||||||
|
result.Add((int)item.Anilist, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (WebException webException)
|
||||||
|
{
|
||||||
|
if (webException.Status == WebExceptionStatus.NameResolutionFailure ||
|
||||||
|
webException.Status == WebExceptionStatus.ConnectFailure)
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordConnectionFailure(Definition.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordFailure(Definition.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webException.Message.Contains("502") || webException.Message.Contains("503") ||
|
||||||
|
webException.Message.Contains("timed out"))
|
||||||
|
{
|
||||||
|
_logger.Warn("{0} server is currently unavailable. {1} {2}", this, Settings.MapSourceUrl, webException.Message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.Warn("{0} {1} {2}", this, Settings.MapSourceUrl, webException.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (HttpException ex)
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordFailure(Definition.Id);
|
||||||
|
_logger.Warn("{0} {1}", this, ex.Message);
|
||||||
|
}
|
||||||
|
catch (JsonSerializationException ex)
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordFailure(Definition.Id);
|
||||||
|
ex.WithData("MappingUrl", Settings.MapSourceUrl);
|
||||||
|
_logger.Error(ex, "Mapping source data is invalid. {0}", Settings.MapSourceUrl);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordFailure(Definition.Id);
|
||||||
|
ex.WithData("MappingUrl", Settings.MapSourceUrl);
|
||||||
|
_logger.Error(ex, "An error occurred while downloading mapping file. {0}", Settings.MapSourceUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override ValidationFailure TestConnection()
|
||||||
|
{
|
||||||
|
if (Mappings.Empty())
|
||||||
|
{
|
||||||
|
return new NzbDroneValidationFailure(string.Empty,
|
||||||
|
"Mapping source is not available or is invalid.")
|
||||||
|
{ IsWarning = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.TestConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void CheckToken()
|
||||||
|
{
|
||||||
|
Settings.Validate().Filter("AccessToken", "RefreshToken").ThrowOnError();
|
||||||
|
_logger.Trace($"Access token expires at {Settings.Expires}");
|
||||||
|
|
||||||
|
if (Settings.Expires < DateTime.UtcNow.AddMinutes(5))
|
||||||
|
{
|
||||||
|
_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 != null && 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 access token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,104 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
using NLog;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Common.Serializer;
|
||||||
|
using NzbDrone.Core.ImportLists.Exceptions;
|
||||||
|
using NzbDrone.Core.Parser.Model;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.ImportLists.AniList
|
||||||
|
{
|
||||||
|
public class AniListParser : IParseImportListResponse
|
||||||
|
{
|
||||||
|
private readonly Dictionary<int, MediaMapping> _mappings;
|
||||||
|
private readonly Logger _logger;
|
||||||
|
|
||||||
|
public AniListParser(Logger logger, Dictionary<int, MediaMapping> mappings)
|
||||||
|
{
|
||||||
|
_mappings = mappings;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual IList<ImportListItemInfo> ParseResponse(ImportListResponse importListResponse)
|
||||||
|
{
|
||||||
|
return ParseResponse(importListResponse, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IList<ImportListItemInfo> ParseResponse(ImportListResponse importListResponse, out PageInfo pageInfo)
|
||||||
|
{
|
||||||
|
var result = new List<ImportListItemInfo>();
|
||||||
|
|
||||||
|
if (!PreProcess(importListResponse))
|
||||||
|
{
|
||||||
|
pageInfo = null;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonResponse = STJson.Deserialize<MediaPageResponse>(importListResponse.Content);
|
||||||
|
|
||||||
|
if (jsonResponse?.Data?.Page?.MediaList == null)
|
||||||
|
{
|
||||||
|
pageInfo = null;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in jsonResponse.Data.Page.MediaList)
|
||||||
|
{
|
||||||
|
var media = item.Media;
|
||||||
|
|
||||||
|
if (_mappings.TryGetValue(media.Id, out var mapping))
|
||||||
|
{
|
||||||
|
// Base required data
|
||||||
|
var entry = new ImportListItemInfo()
|
||||||
|
{
|
||||||
|
TvdbId = mapping.TVDB.Value,
|
||||||
|
Title = media.Title.UserPreferred ?? media.Title.UserRomaji ?? default
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extra optional mappings
|
||||||
|
if (mapping.MyAnimeList.HasValue)
|
||||||
|
{
|
||||||
|
entry.MalId = mapping.MyAnimeList.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(mapping.IMDB))
|
||||||
|
{
|
||||||
|
entry.ImdbId = mapping.IMDB;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional Year/ReleaseDate data
|
||||||
|
if (media.StartDate?.Year != null)
|
||||||
|
{
|
||||||
|
entry.Year = (int)media.StartDate.Year;
|
||||||
|
entry.ReleaseDate = (System.DateTime)media.StartDate.Convert();
|
||||||
|
}
|
||||||
|
|
||||||
|
result.AddIfNotNull(entry);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.Warn("'{1}' (id:{0}) could not be imported, because there is no mapping available.", media.Id, media.Title.UserPreferred ?? media.Title.UserRomaji);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pageInfo = jsonResponse.Data.Page.PageInfo;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual bool PreProcess(ImportListResponse netImportResponse)
|
||||||
|
{
|
||||||
|
if (netImportResponse.HttpResponse.StatusCode != HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
throw new ImportListException(netImportResponse, "Anilist 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, "Anilist API responded with html content. Site is likely blocked or unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
using System;
|
||||||
|
using FluentValidation;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Core.Annotations;
|
||||||
|
using NzbDrone.Core.Validation;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.ImportLists.AniList
|
||||||
|
{
|
||||||
|
public class AniListSettingsBaseValidator<TSettings> : AbstractValidator<TSettings>
|
||||||
|
where TSettings : AniListSettingsBase<TSettings>
|
||||||
|
{
|
||||||
|
public AniListSettingsBaseValidator()
|
||||||
|
{
|
||||||
|
RuleFor(c => c.BaseUrl).ValidRootUrl();
|
||||||
|
|
||||||
|
RuleFor(c => c.AccessToken).NotEmpty()
|
||||||
|
.OverridePropertyName("SignIn")
|
||||||
|
.WithMessage("Must authenticate with AniList");
|
||||||
|
|
||||||
|
RuleFor(c => c.RefreshToken).NotEmpty()
|
||||||
|
.OverridePropertyName("SignIn")
|
||||||
|
.WithMessage("Must authenticate with AniList")
|
||||||
|
.When(c => c.AccessToken.IsNotNullOrWhiteSpace());
|
||||||
|
|
||||||
|
RuleFor(c => c.Expires).NotEmpty()
|
||||||
|
.OverridePropertyName("SignIn")
|
||||||
|
.WithMessage("Must authenticate with AniList")
|
||||||
|
.When(c => c.AccessToken.IsNotNullOrWhiteSpace() && c.RefreshToken.IsNotNullOrWhiteSpace());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AniListSettingsBase<TSettings> : IImportListSettings
|
||||||
|
where TSettings : AniListSettingsBase<TSettings>
|
||||||
|
{
|
||||||
|
protected virtual AbstractValidator<TSettings> Validator => new AniListSettingsBaseValidator<TSettings>();
|
||||||
|
|
||||||
|
public AniListSettingsBase()
|
||||||
|
{
|
||||||
|
BaseUrl = "https://graphql.anilist.co";
|
||||||
|
SignIn = "startOAuth";
|
||||||
|
MapSourceUrl = "https://raw.githubusercontent.com/Fribb/anime-lists/master/anime-list-full.json";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string BaseUrl { get; set; }
|
||||||
|
|
||||||
|
public string MapSourceUrl { 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(99, Label = "Authenticate with AniList", Type = FieldType.OAuth)]
|
||||||
|
public string SignIn { get; set; }
|
||||||
|
|
||||||
|
public NzbDroneValidationResult Validate()
|
||||||
|
{
|
||||||
|
return new NzbDroneValidationResult(Validator.Validate((TSettings)this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.ImportLists.AniList
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Media list status types
|
||||||
|
/// </summary>
|
||||||
|
public static class MediaListStatus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Currently Watching Anime Series
|
||||||
|
/// </summary>
|
||||||
|
public const string Current = "CURRENT";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan to Watch Anime Series
|
||||||
|
/// </summary>
|
||||||
|
public const string Planning = "PLANNING";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Completed Anime Series
|
||||||
|
/// </summary>
|
||||||
|
public const string Completed = "COMPLETED";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dropped Anime Series
|
||||||
|
/// </summary>
|
||||||
|
public const string Dropped = "DROPPED";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// On Hold Anime Series
|
||||||
|
/// </summary>
|
||||||
|
public const string Paused = "PAUSED";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rewatching Anime Series
|
||||||
|
/// </summary>
|
||||||
|
public const string Repeating = "REPEATING";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data during token refresh cycles
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mapping data between anime service providers
|
||||||
|
/// </summary>
|
||||||
|
public class MediaMapping
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Anilist ID
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("anilist_id")]
|
||||||
|
public int? Anilist { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The TVDB ID
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("thetvdb_id")]
|
||||||
|
public int? TVDB { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// My Anime List ID
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("mal_id")]
|
||||||
|
public int? MyAnimeList { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMDB ID
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("imdb_id")]
|
||||||
|
public string IMDB { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entry type such as TV or MOVIE.
|
||||||
|
///
|
||||||
|
/// Required when mapping between services that reuse ids for different content types.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string ItemType { get; set; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,152 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using NLog;
|
||||||
|
using NzbDrone.Common.Cache;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Common.Http;
|
||||||
|
using NzbDrone.Core.Configuration;
|
||||||
|
using NzbDrone.Core.Http.CloudFlare;
|
||||||
|
using NzbDrone.Core.ImportLists.Exceptions;
|
||||||
|
using NzbDrone.Core.Indexers.Exceptions;
|
||||||
|
using NzbDrone.Core.Parser;
|
||||||
|
using NzbDrone.Core.Parser.Model;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.ImportLists.AniList.List
|
||||||
|
{
|
||||||
|
internal class AniListImport : AniListImportBase<AniListSettings>
|
||||||
|
{
|
||||||
|
public AniListImport(IImportListRepository netImportRepository,
|
||||||
|
IHttpClient httpClient,
|
||||||
|
IImportListStatusService importListStatusService,
|
||||||
|
IConfigService configService,
|
||||||
|
IParsingService parsingService,
|
||||||
|
Logger logger,
|
||||||
|
ICacheManager cacheManager)
|
||||||
|
: base(netImportRepository, httpClient, importListStatusService, configService, parsingService, logger, cacheManager)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string Name => "AniList List";
|
||||||
|
|
||||||
|
public override AniListRequestGenerator GetRequestGenerator()
|
||||||
|
{
|
||||||
|
return new AniListRequestGenerator()
|
||||||
|
{
|
||||||
|
Settings = Settings,
|
||||||
|
ClientId = ClientId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anilist caps the result list to 50 items at maximum per query, so the data must be pulled in batches.
|
||||||
|
///
|
||||||
|
/// The number of pages are not known upfront, so the fetch logic must be changed to look at the returned page data.
|
||||||
|
/// </summary>
|
||||||
|
protected override IList<ImportListItemInfo> FetchItems(Func<IImportListRequestGenerator, ImportListPageableRequestChain> pageableRequestChainSelector, bool isRecent = false)
|
||||||
|
{
|
||||||
|
var releases = new List<ImportListItemInfo>();
|
||||||
|
var url = string.Empty;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var generator = GetRequestGenerator();
|
||||||
|
var parser = GetParser();
|
||||||
|
var pageIndex = 1;
|
||||||
|
var hasNextPage = false;
|
||||||
|
ImportListRequest currentRequest = null;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
// Build the query for the current page
|
||||||
|
currentRequest = generator.GetRequest(pageIndex);
|
||||||
|
url = currentRequest.Url.FullUri;
|
||||||
|
|
||||||
|
// Fetch and parse the response
|
||||||
|
var response = FetchImportListResponse(currentRequest);
|
||||||
|
var page = parser.ParseResponse(response, out var pageInfo).ToList();
|
||||||
|
releases.AddRange(page.Where(IsValidItem));
|
||||||
|
|
||||||
|
// Update page info
|
||||||
|
hasNextPage = pageInfo.HasNextPage; // server reports there is another page
|
||||||
|
pageIndex = pageInfo.CurrentPage + 1; // increment using the returned server index for the current page
|
||||||
|
}
|
||||||
|
while (hasNextPage);
|
||||||
|
|
||||||
|
_importListStatusService.RecordSuccess(Definition.Id);
|
||||||
|
}
|
||||||
|
catch (WebException webException)
|
||||||
|
{
|
||||||
|
if (webException.Status == WebExceptionStatus.NameResolutionFailure ||
|
||||||
|
webException.Status == WebExceptionStatus.ConnectFailure)
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordConnectionFailure(Definition.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordFailure(Definition.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webException.Message.Contains("502") || webException.Message.Contains("503") ||
|
||||||
|
webException.Message.Contains("timed out"))
|
||||||
|
{
|
||||||
|
_logger.Warn("{0} server is currently unavailable. {1} {2}", this, url, webException.Message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.Warn("{0} {1} {2}", this, url, webException.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TooManyRequestsException ex)
|
||||||
|
{
|
||||||
|
if (ex.RetryAfter != TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordFailure(Definition.Id, ex.RetryAfter);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordFailure(Definition.Id, TimeSpan.FromHours(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Warn("API Request Limit reached for {0}", this);
|
||||||
|
}
|
||||||
|
catch (HttpException ex)
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordFailure(Definition.Id);
|
||||||
|
_logger.Warn("{0} {1}", this, ex.Message);
|
||||||
|
}
|
||||||
|
catch (RequestLimitReachedException)
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordFailure(Definition.Id, TimeSpan.FromHours(1));
|
||||||
|
_logger.Warn("API Request Limit reached for {0}", this);
|
||||||
|
}
|
||||||
|
catch (CloudFlareCaptchaException ex)
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordFailure(Definition.Id);
|
||||||
|
ex.WithData("FeedUrl", url);
|
||||||
|
if (ex.IsExpired)
|
||||||
|
{
|
||||||
|
_logger.Error(ex, "Expired CAPTCHA token for {0}, please refresh in import list settings.", this);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.Error(ex, "CAPTCHA token required for {0}, check import list settings.", this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ImportListException ex)
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordFailure(Definition.Id);
|
||||||
|
_logger.Warn(ex, "{0}", url);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_importListStatusService.RecordFailure(Definition.Id);
|
||||||
|
ex.WithData("FeedUrl", url);
|
||||||
|
_logger.Error(ex, "An error occurred while processing feed. {0}", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CleanupListItems(releases);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,89 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Common.Http;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.ImportLists.AniList.List
|
||||||
|
{
|
||||||
|
public class AniListRequestGenerator : IImportListRequestGenerator
|
||||||
|
{
|
||||||
|
public AniListSettings Settings { get; set; }
|
||||||
|
public string ClientId { get; set; }
|
||||||
|
|
||||||
|
public ImportListPageableRequestChain GetListItems()
|
||||||
|
{
|
||||||
|
return GetListItems(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImportListPageableRequestChain GetListItems(int page)
|
||||||
|
{
|
||||||
|
var pageableRequests = new ImportListPageableRequestChain();
|
||||||
|
|
||||||
|
pageableRequests.Add(GetRequestList(page));
|
||||||
|
|
||||||
|
return pageableRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImportListRequest GetRequest(int page = 1)
|
||||||
|
{
|
||||||
|
var request = new ImportListRequest(Settings.BaseUrl.Trim(), HttpAccept.Json);
|
||||||
|
request.HttpRequest.Method = HttpMethod.Post;
|
||||||
|
request.HttpRequest.Headers.ContentType = "application/json";
|
||||||
|
request.HttpRequest.SetContent(AniListAPI.BuildQuery(AniListQuery.QueryList, new
|
||||||
|
{
|
||||||
|
id = Settings.Username,
|
||||||
|
statusType = BuildStatusFilter(),
|
||||||
|
page
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (Settings.AccessToken.IsNotNullOrWhiteSpace())
|
||||||
|
{
|
||||||
|
request.HttpRequest.Headers.Add("Authorization", "Bearer " + Settings.AccessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected IEnumerable<ImportListRequest> GetRequestList(int page = 1)
|
||||||
|
{
|
||||||
|
yield return GetRequest(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> BuildStatusFilter()
|
||||||
|
{
|
||||||
|
var filters = new List<string>();
|
||||||
|
|
||||||
|
if (Settings.ImportCompleted)
|
||||||
|
{
|
||||||
|
filters.Add(MediaListStatus.Completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings.ImportCurrent)
|
||||||
|
{
|
||||||
|
filters.Add(MediaListStatus.Current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings.ImportDropped)
|
||||||
|
{
|
||||||
|
filters.Add(MediaListStatus.Dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings.ImportPaused)
|
||||||
|
{
|
||||||
|
filters.Add(MediaListStatus.Paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings.ImportPlanning)
|
||||||
|
{
|
||||||
|
filters.Add(MediaListStatus.Planning);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings.ImportRepeating)
|
||||||
|
{
|
||||||
|
filters.Add(MediaListStatus.Repeating);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
using FluentValidation;
|
||||||
|
using NzbDrone.Core.Annotations;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.ImportLists.AniList.List
|
||||||
|
{
|
||||||
|
public class AniListSettingsValidator : AniListSettingsBaseValidator<AniListSettings>
|
||||||
|
{
|
||||||
|
public AniListSettingsValidator()
|
||||||
|
: base()
|
||||||
|
{
|
||||||
|
RuleFor(c => c.Username).NotEmpty();
|
||||||
|
|
||||||
|
RuleFor(c => c.ImportCurrent).NotEmpty()
|
||||||
|
.WithMessage("At least one status type must be selected")
|
||||||
|
.When(c => !(c.ImportPlanning || c.ImportCompleted || c.ImportDropped || c.ImportPaused || c.ImportRepeating));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AniListSettings : AniListSettingsBase<AniListSettings>
|
||||||
|
{
|
||||||
|
public const string sectionImport = "Import List Status";
|
||||||
|
public const string unitStatusType = "Status Type";
|
||||||
|
|
||||||
|
public AniListSettings()
|
||||||
|
: base()
|
||||||
|
{
|
||||||
|
ImportCurrent = true;
|
||||||
|
ImportPlanning = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override AbstractValidator<AniListSettings> Validator => new AniListSettingsValidator();
|
||||||
|
|
||||||
|
[FieldDefinition(1, Label = "Username", HelpText = "Username for the List to import from")]
|
||||||
|
public string Username { get; set; }
|
||||||
|
|
||||||
|
[FieldDefinition(0, Label = "Import Watching", Type = FieldType.Checkbox, Section = sectionImport, Unit = unitStatusType)]
|
||||||
|
public bool ImportCurrent { get; set; }
|
||||||
|
|
||||||
|
[FieldDefinition(0, Label = "Import Planning", Type = FieldType.Checkbox, Section = sectionImport, Unit = unitStatusType)]
|
||||||
|
public bool ImportPlanning { get; set; }
|
||||||
|
|
||||||
|
[FieldDefinition(0, Label = "Import Completed", Type = FieldType.Checkbox, Section = sectionImport, Unit = unitStatusType)]
|
||||||
|
public bool ImportCompleted { get; set; }
|
||||||
|
|
||||||
|
[FieldDefinition(0, Label = "Import Dropped", Type = FieldType.Checkbox, Section = sectionImport, Unit = unitStatusType)]
|
||||||
|
public bool ImportDropped { get; set; }
|
||||||
|
|
||||||
|
[FieldDefinition(0, Label = "Import Paused", Type = FieldType.Checkbox, Section = sectionImport, Unit = unitStatusType)]
|
||||||
|
public bool ImportPaused { get; set; }
|
||||||
|
|
||||||
|
[FieldDefinition(0, Label = "Import Repeating", Type = FieldType.Checkbox, Section = sectionImport, Unit = unitStatusType)]
|
||||||
|
public bool ImportRepeating { get; set; }
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue