New: My Anime List import list

Closes #5148
This commit is contained in:
iceypotato 2023-07-17 10:54:09 -07:00 committed by Mark McDowall
parent d338425951
commit 1335efd487
10 changed files with 368 additions and 0 deletions

View File

@ -190,6 +190,23 @@ namespace NzbDrone.Core.ImportLists
item.Title = mappedSeries.Title; 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) if (item.TvdbId == 0)
{ {
_logger.Debug("[{0}] Rejected, unable to find TVDB ID", item.Title); _logger.Debug("[{0}] Rejected, unable to find TVDB ID", item.Title);

View File

@ -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<MyAnimeListSettings>
{
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<string, string> 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<MyAnimeListAuthToken>(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.");
}
}
}
}

View File

@ -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<ImportListItemInfo> ParseResponse(ImportListResponse importListResponse)
{
var jsonResponse = Json.Deserialize<MyAnimeListResponse>(importListResponse.Content);
var series = new List<ImportListItemInfo>();
foreach (var show in jsonResponse.Animes)
{
series.AddIfNotNull(new ImportListItemInfo
{
Title = show.AnimeListInfo.Title,
MalId = show.AnimeListInfo.Id
});
}
return series;
}
}
}

View File

@ -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<MyAnimeListStatus, string> StatusMapping = new Dictionary<MyAnimeListStatus, string>
{
{ 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<ImportListRequest> 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;
}
}
}

View File

@ -0,0 +1,55 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace NzbDrone.Core.ImportLists.MyAnimeList
{
public class MyAnimeListResponse
{
[JsonProperty("data")]
public List<MyAnimeListItem> 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; }
}
}

View File

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

View File

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

View File

@ -838,6 +838,9 @@
"ImportListsImdbSettingsListId": "List ID", "ImportListsImdbSettingsListId": "List ID",
"ImportListsImdbSettingsListIdHelpText": "IMDb list ID (e.g ls12345678)", "ImportListsImdbSettingsListIdHelpText": "IMDb list ID (e.g ls12345678)",
"ImportListsLoadError": "Unable to load Import Lists", "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", "ImportListsPlexSettingsAuthenticateWithPlex": "Authenticate with Plex.tv",
"ImportListsPlexSettingsWatchlistName": "Plex Watchlist", "ImportListsPlexSettingsWatchlistName": "Plex Watchlist",
"ImportListsPlexSettingsWatchlistRSSName": "Plex Watchlist RSS", "ImportListsPlexSettingsWatchlistRSSName": "Plex Watchlist RSS",

View File

@ -9,5 +9,6 @@ namespace NzbDrone.Core.MetadataSource
List<Series> SearchForNewSeriesByImdbId(string imdbId); List<Series> SearchForNewSeriesByImdbId(string imdbId);
List<Series> SearchForNewSeriesByAniListId(int aniListId); List<Series> SearchForNewSeriesByAniListId(int aniListId);
List<Series> SearchForNewSeriesByTmdbId(int tmdbId); List<Series> SearchForNewSeriesByTmdbId(int tmdbId);
List<Series> SearchForNewSeriesByMyAnimeListId(int malId);
} }
} }

View File

@ -90,6 +90,13 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
return results; return results;
} }
public List<Series> SearchForNewSeriesByMyAnimeListId(int malId)
{
var results = SearchForNewSeries($"mal:{malId}");
return results;
}
public List<Series> SearchForNewSeriesByTmdbId(int tmdbId) public List<Series> SearchForNewSeriesByTmdbId(int tmdbId)
{ {
var results = SearchForNewSeries($"tmdb:{tmdbId}"); var results = SearchForNewSeries($"tmdb:{tmdbId}");