diff --git a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs index eccbed341..291ecba27 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs @@ -190,6 +190,23 @@ namespace NzbDrone.Core.ImportLists item.Title = mappedSeries.Title; } + // Map by MyAniList ID if we have it + if (item.TvdbId <= 0 && item.MalId > 0) + { + var mappedSeries = _seriesSearchService.SearchForNewSeriesByMyAnimeListId(item.MalId) + .FirstOrDefault(); + + if (mappedSeries == null) + { + _logger.Debug("Rejected, unable to find matching TVDB ID for MAL ID: {0} [{1}]", item.MalId, item.Title); + + continue; + } + + item.TvdbId = mappedSeries.TvdbId; + item.Title = mappedSeries.Title; + } + if (item.TvdbId == 0) { _logger.Debug("[{0}] Rejected, unable to find TVDB ID", item.Title); diff --git a/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListImport.cs b/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListImport.cs new file mode 100644 index 000000000..1ba6898c9 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListImport.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using NLog; +using NzbDrone.Common.Cloud; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Localization; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.MyAnimeList +{ + public class MyAnimeListImport : HttpImportListBase + { + public const string OAuthPath = "oauth/myanimelist/authorize"; + public const string RedirectUriPath = "oauth/myanimelist/auth"; + public const string RenewUriPath = "oauth/myanimelist/renew"; + + public override string Name => "MyAnimeList"; + public override ImportListType ListType => ImportListType.Other; + public override TimeSpan MinRefreshInterval => TimeSpan.FromHours(6); + + private readonly IImportListRepository _importListRepository; + private readonly IHttpRequestBuilderFactory _requestBuilder; + + // This constructor the first thing that is called when sonarr creates a button + public MyAnimeListImport(IImportListRepository netImportRepository, IHttpClient httpClient, IImportListStatusService importListStatusService, IConfigService configService, IParsingService parsingService, ILocalizationService localizationService, ISonarrCloudRequestBuilder requestBuilder, Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, localizationService, logger) + { + _importListRepository = netImportRepository; + _requestBuilder = requestBuilder.Services; + } + + public override ImportListFetchResult Fetch() + { + if (Settings.Expires < DateTime.UtcNow.AddMinutes(5)) + { + RefreshToken(); + } + + return FetchItems(g => g.GetListItems()); + } + + // MAL OAuth info: https://myanimelist.net/blog.php?eid=835707 + // The whole process is handled through Sonarr's services. + public override object RequestAction(string action, IDictionary query) + { + if (action == "startOAuth") + { + var request = _requestBuilder.Create() + .Resource(OAuthPath) + .AddQueryParam("state", query["callbackUrl"]) + .AddQueryParam("redirect_uri", _requestBuilder.Create().Resource(RedirectUriPath).Build().Url.ToString()) + .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 override IParseImportListResponse GetParser() + { + return new MyAnimeListParser(); + } + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new MyAnimeListRequestGenerator() + { + Settings = Settings, + }; + } + + private void RefreshToken() + { + _logger.Trace("Refreshing Token"); + + Settings.Validate().Filter("RefreshToken").ThrowOnError(); + + var httpReq = _requestBuilder.Create() + .Resource(RenewUriPath) + .AddQueryParam("refresh_token", Settings.RefreshToken) + .Build(); + try + { + var httpResp = _httpClient.Get(httpReq); + + if (httpResp?.Resource != null) + { + var token = httpResp.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 (HttpRequestException) + { + _logger.Error("Error trying to refresh MAL access token."); + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListParser.cs b/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListParser.cs new file mode 100644 index 000000000..23a74b1f4 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListParser.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists.MyAnimeList +{ + public class MyAnimeListParser : IParseImportListResponse + { + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(MyAnimeListParser)); + + public IList ParseResponse(ImportListResponse importListResponse) + { + var jsonResponse = Json.Deserialize(importListResponse.Content); + var series = new List(); + + foreach (var show in jsonResponse.Animes) + { + series.AddIfNotNull(new ImportListItemInfo + { + Title = show.AnimeListInfo.Title, + MalId = show.AnimeListInfo.Id + }); + } + + return series; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListRequestGenerator.cs new file mode 100644 index 000000000..7bf62254a --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListRequestGenerator.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.ImportLists.MyAnimeList +{ + public class MyAnimeListRequestGenerator : IImportListRequestGenerator + { + public MyAnimeListSettings Settings { get; set; } + + private static readonly Dictionary StatusMapping = new Dictionary + { + { MyAnimeListStatus.Watching, "watching" }, + { MyAnimeListStatus.Completed, "completed" }, + { MyAnimeListStatus.OnHold, "on_hold" }, + { MyAnimeListStatus.Dropped, "dropped" }, + { MyAnimeListStatus.PlanToWatch, "plan_to_watch" }, + }; + + public virtual ImportListPageableRequestChain GetListItems() + { + var pageableReq = new ImportListPageableRequestChain(); + + pageableReq.Add(GetSeriesRequest()); + + return pageableReq; + } + + private IEnumerable GetSeriesRequest() + { + var status = (MyAnimeListStatus)Settings.ListStatus; + var requestBuilder = new HttpRequestBuilder(Settings.BaseUrl.Trim()); + + requestBuilder.Resource("users/@me/animelist"); + requestBuilder.AddQueryParam("fields", "list_status"); + requestBuilder.AddQueryParam("limit", "1000"); + requestBuilder.Accept(HttpAccept.Json); + + if (status != MyAnimeListStatus.All && StatusMapping.TryGetValue(status, out var statusName)) + { + requestBuilder.AddQueryParam("status", statusName); + } + + var httpReq = new ImportListRequest(requestBuilder.Build()); + + httpReq.HttpRequest.Headers.Add("Authorization", $"Bearer {Settings.AccessToken}"); + + yield return httpReq; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListResponses.cs b/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListResponses.cs new file mode 100644 index 000000000..9c55eecd6 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListResponses.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.ImportLists.MyAnimeList +{ + public class MyAnimeListResponse + { + [JsonProperty("data")] + public List Animes { get; set; } + } + + public class MyAnimeListItem + { + [JsonProperty("node")] + public MyAnimeListItemInfo AnimeListInfo { get; set; } + + [JsonProperty("list_status")] + public MyAnimeListStatusResult ListStatus { get; set; } + } + + public class MyAnimeListStatusResult + { + public string Status { get; set; } + } + + public class MyAnimeListItemInfo + { + public int Id { get; set; } + public string Title { get; set; } + } + + public class MyAnimeListIds + { + [JsonProperty("mal_id")] + public int MalId { get; set; } + + [JsonProperty("thetvdb_id")] + public int TvdbId { get; set; } + } + + public class MyAnimeListAuthToken + { + [JsonProperty("token_type")] + public string TokenType { get; set; } + + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + + [JsonProperty("access_token")] + public string AccessToken { get; set; } + + [JsonProperty("refresh_token")] + public string RefreshToken { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListSettings.cs b/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListSettings.cs new file mode 100644 index 000000000..aad6257c8 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListSettings.cs @@ -0,0 +1,58 @@ +using System; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.MyAnimeList +{ + public class MalSettingsValidator : AbstractValidator + { + public MalSettingsValidator() + { + RuleFor(c => c.BaseUrl).ValidRootUrl(); + RuleFor(c => c.AccessToken).NotEmpty() + .OverridePropertyName("SignIn") + .WithMessage("Must authenticate with MyAnimeList"); + + RuleFor(c => c.ListStatus).Custom((status, context) => + { + if (!Enum.IsDefined(typeof(MyAnimeListStatus), status)) + { + context.AddFailure($"Invalid status: {status}"); + } + }); + } + } + + public class MyAnimeListSettings : IImportListSettings + { + public string BaseUrl { get; set; } + + protected AbstractValidator Validator => new MalSettingsValidator(); + + public MyAnimeListSettings() + { + BaseUrl = "https://api.myanimelist.net/v2"; + } + + [FieldDefinition(0, Label = "ImportListsMyAnimeListSettingsListStatus", Type = FieldType.Select, SelectOptions = typeof(MyAnimeListStatus), HelpText = "ImportListsMyAnimeListSettingsListStatusHelpText")] + public int ListStatus { get; set; } + + [FieldDefinition(0, Label = "ImportListsSettingsAccessToken", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public string AccessToken { get; set; } + + [FieldDefinition(0, Label = "ImportListsSettingsRefreshToken", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public string RefreshToken { get; set; } + + [FieldDefinition(0, Label = "ImportListsSettingsExpires", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public DateTime Expires { get; set; } + + [FieldDefinition(99, Label = "ImportListsMyAnimeListSettingsAuthenticateWithMyAnimeList", Type = FieldType.OAuth)] + public string SignIn { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListStatus.cs b/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListStatus.cs new file mode 100644 index 000000000..b08c9e41f --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListStatus.cs @@ -0,0 +1,25 @@ +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.ImportLists.MyAnimeList +{ + public enum MyAnimeListStatus + { + [FieldOption(label: "All")] + All = 0, + + [FieldOption(label: "Watching")] + Watching = 1, + + [FieldOption(label: "Completed")] + Completed = 2, + + [FieldOption(label: "On Hold")] + OnHold = 3, + + [FieldOption(label: "Dropped")] + Dropped = 4, + + [FieldOption(label: "Plan to Watch")] + PlanToWatch = 5 + } +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index ce503dd49..b5d0cb157 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -838,6 +838,9 @@ "ImportListsImdbSettingsListId": "List ID", "ImportListsImdbSettingsListIdHelpText": "IMDb list ID (e.g ls12345678)", "ImportListsLoadError": "Unable to load Import Lists", + "ImportListsMyAnimeListSettingsAuthenticateWithMyAnimeList": "Authenticate with MyAnimeList", + "ImportListsMyAnimeListSettingsListStatus": "List Status", + "ImportListsMyAnimeListSettingsListStatusHelpText": "Type of list you want to import from, set to 'All' for all lists", "ImportListsPlexSettingsAuthenticateWithPlex": "Authenticate with Plex.tv", "ImportListsPlexSettingsWatchlistName": "Plex Watchlist", "ImportListsPlexSettingsWatchlistRSSName": "Plex Watchlist RSS", diff --git a/src/NzbDrone.Core/MetadataSource/ISearchForNewSeries.cs b/src/NzbDrone.Core/MetadataSource/ISearchForNewSeries.cs index f8aef8654..c5d89bbaf 100644 --- a/src/NzbDrone.Core/MetadataSource/ISearchForNewSeries.cs +++ b/src/NzbDrone.Core/MetadataSource/ISearchForNewSeries.cs @@ -9,5 +9,6 @@ namespace NzbDrone.Core.MetadataSource List SearchForNewSeriesByImdbId(string imdbId); List SearchForNewSeriesByAniListId(int aniListId); List SearchForNewSeriesByTmdbId(int tmdbId); + List SearchForNewSeriesByMyAnimeListId(int malId); } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index 76efea07d..9313b0661 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -90,6 +90,13 @@ namespace NzbDrone.Core.MetadataSource.SkyHook return results; } + public List SearchForNewSeriesByMyAnimeListId(int malId) + { + var results = SearchForNewSeries($"mal:{malId}"); + + return results; + } + public List SearchForNewSeriesByTmdbId(int tmdbId) { var results = SearchForNewSeries($"tmdb:{tmdbId}");