diff --git a/src/NzbDrone.Core/ImportLists/AniList/AniListAPI.cs b/src/NzbDrone.Core/ImportLists/AniList/AniListAPI.cs new file mode 100644 index 000000000..c47f331aa --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/AniList/AniListAPI.cs @@ -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 { 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); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/AniList/AniListImportBase.cs b/src/NzbDrone.Core/ImportLists/AniList/AniListImportBase.cs new file mode 100644 index 000000000..c3d876ec2 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/AniList/AniListImportBase.cs @@ -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 : HttpImportListBase + where TSettings : AniListSettingsBase, 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> _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>(GetType()); + } + + public Dictionary Mappings => _cache.Get(_cacheKey, GetMappingData, _cacheInterval); + + public override AniListParser GetParser() + { + return new AniListParser(_logger, Mappings); + } + + 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"], + }; + } + + return new { }; + } + + public void RefreshMappings() + { + var mappings = GetMappingData(); + _cache.Set(_cacheKey, mappings, _cacheInterval); + } + + public override IList Fetch() + { + CheckToken(); + return base.Fetch(); + } + + protected virtual Dictionary GetMappingData() + { + var result = new Dictionary(); + try + { + var request = new HttpRequest(Settings.MapSourceUrl, HttpAccept.Json); + var response = _httpClient.Execute(request); + + if (response.StatusCode == HttpStatusCode.OK) + { + var mappingList = STJson.Deserialize>(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(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"); + } + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/AniList/AniListParser.cs b/src/NzbDrone.Core/ImportLists/AniList/AniListParser.cs new file mode 100644 index 000000000..9388c76ea --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/AniList/AniListParser.cs @@ -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 _mappings; + private readonly Logger _logger; + + public AniListParser(Logger logger, Dictionary mappings) + { + _mappings = mappings; + _logger = logger; + } + + public virtual IList ParseResponse(ImportListResponse importListResponse) + { + return ParseResponse(importListResponse, out _); + } + + public IList ParseResponse(ImportListResponse importListResponse, out PageInfo pageInfo) + { + var result = new List(); + + if (!PreProcess(importListResponse)) + { + pageInfo = null; + return result; + } + + var jsonResponse = STJson.Deserialize(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; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/AniList/AniListSettingsBase.cs b/src/NzbDrone.Core/ImportLists/AniList/AniListSettingsBase.cs new file mode 100644 index 000000000..9d1ebdb54 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/AniList/AniListSettingsBase.cs @@ -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 : AbstractValidator + where TSettings : AniListSettingsBase + { + 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 : IImportListSettings + where TSettings : AniListSettingsBase + { + protected virtual AbstractValidator Validator => new AniListSettingsBaseValidator(); + + 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)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/AniList/AniListTypes.cs b/src/NzbDrone.Core/ImportLists/AniList/AniListTypes.cs new file mode 100644 index 000000000..373f01c3c --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/AniList/AniListTypes.cs @@ -0,0 +1,92 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace NzbDrone.Core.ImportLists.AniList +{ + /// + /// Media list status types + /// + public static class MediaListStatus + { + /// + /// Currently Watching Anime Series + /// + public const string Current = "CURRENT"; + + /// + /// Plan to Watch Anime Series + /// + public const string Planning = "PLANNING"; + + /// + /// Completed Anime Series + /// + public const string Completed = "COMPLETED"; + + /// + /// Dropped Anime Series + /// + public const string Dropped = "DROPPED"; + + /// + /// On Hold Anime Series + /// + public const string Paused = "PAUSED"; + + /// + /// Rewatching Anime Series + /// + public const string Repeating = "REPEATING"; + } + + /// + /// Data during token refresh cycles + /// + 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; } + } + + /// + /// Mapping data between anime service providers + /// + public class MediaMapping + { + /// + /// Anilist ID + /// + [JsonPropertyName("anilist_id")] + public int? Anilist { get; set; } + + /// + /// The TVDB ID + /// + [JsonPropertyName("thetvdb_id")] + public int? TVDB { get; set; } + + /// + /// My Anime List ID + /// + [JsonPropertyName("mal_id")] + public int? MyAnimeList { get; set; } + + /// + /// IMDB ID + /// + [JsonPropertyName("imdb_id")] + public string IMDB { get; set; } + + /// + /// Entry type such as TV or MOVIE. + /// + /// Required when mapping between services that reuse ids for different content types. + /// + [JsonPropertyName("type")] + public string ItemType { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/AniList/List/AniListImport.cs b/src/NzbDrone.Core/ImportLists/AniList/List/AniListImport.cs new file mode 100644 index 000000000..4f49d7d75 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/AniList/List/AniListImport.cs @@ -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 + { + 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 + }; + } + + /// + /// 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. + /// + protected override IList FetchItems(Func pageableRequestChainSelector, bool isRecent = false) + { + var releases = new List(); + 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); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/AniList/List/AniListRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/AniList/List/AniListRequestGenerator.cs new file mode 100644 index 000000000..ecffd8b00 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/AniList/List/AniListRequestGenerator.cs @@ -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 GetRequestList(int page = 1) + { + yield return GetRequest(page); + } + + private List BuildStatusFilter() + { + var filters = new List(); + + 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; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/AniList/List/AniListSettings.cs b/src/NzbDrone.Core/ImportLists/AniList/List/AniListSettings.cs new file mode 100644 index 000000000..cf36ed3ab --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/AniList/List/AniListSettings.cs @@ -0,0 +1,54 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.ImportLists.AniList.List +{ + public class AniListSettingsValidator : AniListSettingsBaseValidator + { + 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 + { + public const string sectionImport = "Import List Status"; + public const string unitStatusType = "Status Type"; + + public AniListSettings() + : base() + { + ImportCurrent = true; + ImportPlanning = true; + } + + protected override AbstractValidator 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; } + } +}