diff --git a/src/NzbDrone.Core.Test/ImportListTests/Discogs/DiscogsListsFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Discogs/DiscogsListsFixture.cs new file mode 100644 index 000000000..390b4f57c --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/Discogs/DiscogsListsFixture.cs @@ -0,0 +1,250 @@ +using System.Linq; +using System.Net; +using FluentAssertions; +using Moq; +using NLog; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.ImportLists.Discogs; +using NzbDrone.Core.ImportLists.Exceptions; + +namespace NzbDrone.Core.Test.ImportListTests.Discogs; + +[TestFixture] +public class DiscogsListsFixture +{ + private readonly HttpHeader _defaultHeaders = new () { ContentType = "application/json" }; + + private DiscogsListsParser _parser; + private Mock _httpClient; + private DiscogsListsSettings _settings; + private Logger _logger; + + [SetUp] + public void SetUp() + { + _httpClient = new Mock(); + _settings = new DiscogsListsSettings { Token = "token", ListId = "123", BaseUrl = "https://api.discogs.com" }; + _logger = LogManager.GetCurrentClassLogger(); + _parser = new DiscogsListsParser(_settings, _httpClient.Object, _logger); + } + + [Test] + public void should_parse_release_items() + { + const string resourceUrl = "https://api.discogs.com/releases/3"; + GivenReleaseDetails(resourceUrl, BuildReleaseResponse("Josh Wink", "Profound Sounds Vol. 1")); + + var response = BuildListResponse(@"{ + ""items"": [ + { + ""type"": ""release"", + ""id"": 3, + ""display_title"": ""Josh Wink - Profound Sounds Vol. 1"", + ""resource_url"": ""https://api.discogs.com/releases/3"" + } + ] + }"); + + var items = _parser.ParseResponse(response); + + items.Should().HaveCount(1); + items.First().Artist.Should().Be("Josh Wink"); + items.First().Album.Should().Be("Profound Sounds Vol. 1"); + } + + [Test] + public void should_parse_artist_items() + { + const string resourceUrl = "https://api.discogs.com/artists/3227"; + GivenArtistDetails(resourceUrl, BuildArtistResponse("Silent Phase")); + + var response = BuildListResponse(@"{ + ""items"": [ + { + ""type"": ""artist"", + ""id"": 3227, + ""display_title"": ""Silent Phase"", + ""resource_url"": ""https://api.discogs.com/artists/3227"" + } + ] + }"); + + var items = _parser.ParseResponse(response); + + items.Should().HaveCount(1); + items.First().Artist.Should().Be("Silent Phase"); + items.First().Album.Should().BeNull(); + } + + [Test] + public void should_parse_mixed_release_and_artist_items() + { + const string releaseUrl = "https://api.discogs.com/releases/4674"; + const string artistUrl = "https://api.discogs.com/artists/3227"; + GivenReleaseDetails(releaseUrl, BuildReleaseResponse("Silent Phase", "The Rewired Mixes")); + GivenArtistDetails(artistUrl, BuildArtistResponse("Silent Phase")); + + var response = BuildListResponse(@"{ + ""items"": [ + { + ""type"": ""release"", + ""id"": 4674, + ""display_title"": ""Silent Phase - The Rewired Mixes"", + ""resource_url"": ""https://api.discogs.com/releases/4674"" + }, + { + ""type"": ""artist"", + ""id"": 3227, + ""display_title"": ""Silent Phase"", + ""resource_url"": ""https://api.discogs.com/artists/3227"" + } + ] + }"); + + var items = _parser.ParseResponse(response); + + items.Should().HaveCount(2); + items.First().Artist.Should().Be("Silent Phase"); + items.First().Album.Should().Be("The Rewired Mixes"); + items.Last().Artist.Should().Be("Silent Phase"); + items.Last().Album.Should().BeNull(); + } + + [Test] + public void should_ignore_non_release_and_non_artist_items() + { + var response = BuildListResponse(@"{ + ""items"": [ + { + ""type"": ""label"", + ""id"": 7, + ""display_title"": ""Ignore me"", + ""resource_url"": ""https://api.discogs.com/labels/7"" + } + ] + }"); + + var items = _parser.ParseResponse(response); + + items.Should().BeEmpty(); + _httpClient.Verify(c => c.Execute(It.IsAny()), Times.Never); + } + + [Test] + public void should_skip_release_when_details_fail() + { + var response = BuildListResponse(@"{ + ""items"": [ + { + ""type"": ""release"", + ""id"": 3, + ""display_title"": ""Josh Wink - Profound Sounds Vol. 1"", + ""resource_url"": ""https://api.discogs.com/releases/3"" + } + ] + }"); + + _httpClient.Setup(c => c.Execute(It.IsAny())) + .Returns(new HttpResponse(new HttpRequest("https://api.discogs.com/releases/3"), _defaultHeaders, string.Empty, HttpStatusCode.NotFound)); + + var items = _parser.ParseResponse(response); + + items.Should().BeEmpty(); + } + + [Test] + public void should_skip_artist_when_details_fail() + { + var response = BuildListResponse(@"{ + ""items"": [ + { + ""type"": ""artist"", + ""id"": 3227, + ""display_title"": ""Silent Phase"", + ""resource_url"": ""https://api.discogs.com/artists/3227"" + } + ] + }"); + + _httpClient.Setup(c => c.Execute(It.IsAny())) + .Returns(new HttpResponse(new HttpRequest("https://api.discogs.com/artists/3227"), _defaultHeaders, string.Empty, HttpStatusCode.NotFound)); + + var items = _parser.ParseResponse(response); + + items.Should().BeEmpty(); + } + + [Test] + public void should_skip_artist_when_name_is_missing() + { + const string resourceUrl = "https://api.discogs.com/artists/3227"; + GivenArtistDetails(resourceUrl, @"{ ""id"": 3227 }"); + + var response = BuildListResponse(@"{ + ""items"": [ + { + ""type"": ""artist"", + ""id"": 3227, + ""display_title"": ""Silent Phase"", + ""resource_url"": ""https://api.discogs.com/artists/3227"" + } + ] + }"); + + var items = _parser.ParseResponse(response); + + items.Should().BeEmpty(); + } + + [Test] + public void should_throw_when_discogs_returns_html() + { + var response = BuildListResponse("", contentType: "text/html"); + + _parser.Invoking(p => p.ParseResponse(response)) + .Should().Throw() + .WithMessage("*HTML content*"); + } + + private ImportListResponse BuildListResponse(string content, HttpStatusCode statusCode = HttpStatusCode.OK, string contentType = "application/json") + { + var httpRequest = new HttpRequest("https://api.discogs.com/lists/123"); + var importListRequest = new ImportListRequest(httpRequest); + var headers = new HttpHeader { ContentType = contentType }; + var httpResponse = new HttpResponse(httpRequest, headers, content, statusCode); + + return new ImportListResponse(importListRequest, httpResponse); + } + + private void GivenReleaseDetails(string resourceUrl, string payload, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _httpClient.Setup(c => c.Execute(It.Is(r => r.Url.FullUri == resourceUrl))) + .Returns(new HttpResponse(new HttpRequest(resourceUrl), _defaultHeaders, payload, statusCode)); + } + + private void GivenArtistDetails(string resourceUrl, string payload, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _httpClient.Setup(c => c.Execute(It.Is(r => r.Url.FullUri == resourceUrl))) + .Returns(new HttpResponse(new HttpRequest(resourceUrl), _defaultHeaders, payload, statusCode)); + } + + private static string BuildReleaseResponse(string artist, string title) + { + return $@"{{ + ""title"": ""{title}"", + ""artists"": [ + {{ ""name"": ""{artist}"", ""id"": 3 }} + ] + }}"; + } + + private static string BuildArtistResponse(string name) + { + return $@"{{ + ""name"": ""{name}"", + ""id"": 3227 + }}"; + } +} diff --git a/src/NzbDrone.Core.Test/ImportListTests/Discogs/DiscogsWantlistFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Discogs/DiscogsWantlistFixture.cs new file mode 100644 index 000000000..0e54e6e71 --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/Discogs/DiscogsWantlistFixture.cs @@ -0,0 +1,134 @@ +using System.Linq; +using System.Net; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.ImportLists.Discogs; +using NzbDrone.Core.ImportLists.Exceptions; + +namespace NzbDrone.Core.Test.ImportListTests.Discogs; + +[TestFixture] +public class DiscogsWantlistFixture +{ + private readonly HttpHeader _defaultHeaders = new () { ContentType = "application/json" }; + + private DiscogsWantlistParser _parser; + private Mock _httpClient; + private DiscogsWantlistSettings _settings; + + [SetUp] + public void SetUp() + { + _httpClient = new Mock(); + _settings = new DiscogsWantlistSettings { Token = "token", Username = "user", BaseUrl = "https://api.discogs.com" }; + _parser = new DiscogsWantlistParser(_settings, _httpClient.Object); + } + + [Test] + public void should_parse_wantlist_items() + { + var response = BuildWantlistResponse(@"{ + ""wants"": [ + { + ""basic_information"": { + ""id"": 3, + ""title"": ""Profound Sounds Vol. 1"", + ""resource_url"": ""https://api.discogs.com/releases/3"", + ""artists"": [ + { ""name"": ""Josh Wink"", ""id"": 123 } + ] + } + } + ] + }"); + + var items = _parser.ParseResponse(response); + + items.Should().HaveCount(1); + items.First().Artist.Should().Be("Josh Wink"); + items.First().Album.Should().Be("Profound Sounds Vol. 1"); + _httpClient.Verify(c => c.Execute(It.IsAny()), Times.Never); + } + + [Test] + public void should_skip_entries_without_artists() + { + var response = BuildWantlistResponse(@"{ + ""wants"": [ + { + ""basic_information"": { + ""id"": 3, + ""title"": ""Missing artists"" + } + } + ] + }"); + + var items = _parser.ParseResponse(response); + + items.Should().BeEmpty(); + _httpClient.Verify(c => c.Execute(It.IsAny()), Times.Never); + } + + [Test] + public void should_skip_entries_without_title() + { + var response = BuildWantlistResponse(@"{ + ""wants"": [ + { + ""basic_information"": { + ""id"": 3, + ""artists"": [ + { ""name"": ""Test Artist"", ""id"": 123 } + ] + } + } + ] + }"); + + var items = _parser.ParseResponse(response); + + items.Should().BeEmpty(); + _httpClient.Verify(c => c.Execute(It.IsAny()), Times.Never); + } + + [Test] + public void should_skip_entries_with_null_basic_information() + { + var response = BuildWantlistResponse(@"{ + ""wants"": [ + { + ""basic_information"": null + } + ] + }"); + + var items = _parser.ParseResponse(response); + + items.Should().BeEmpty(); + _httpClient.Verify(c => c.Execute(It.IsAny()), Times.Never); + } + + [Test] + public void should_throw_when_discogs_returns_html() + { + var response = BuildWantlistResponse("", contentType: "text/html"); + + _parser.Invoking(p => p.ParseResponse(response)) + .Should().Throw() + .WithMessage("*HTML content*"); + } + + private ImportListResponse BuildWantlistResponse(string content, HttpStatusCode statusCode = HttpStatusCode.OK, string contentType = "application/json") + { + var httpRequest = new HttpRequest("https://api.discogs.com/users/user/wants"); + var importListRequest = new ImportListRequest(httpRequest); + var headers = new HttpHeader { ContentType = contentType }; + var httpResponse = new HttpResponse(httpRequest, headers, content, statusCode); + + return new ImportListResponse(importListRequest, httpResponse); + } +} diff --git a/src/NzbDrone.Core.Test/ImportListTests/ImportListServiceFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/ImportListServiceFixture.cs index 0be3a49cd..79e38590e 100644 --- a/src/NzbDrone.Core.Test/ImportListTests/ImportListServiceFixture.cs +++ b/src/NzbDrone.Core.Test/ImportListTests/ImportListServiceFixture.cs @@ -3,6 +3,7 @@ using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.ImportLists; +using NzbDrone.Core.ImportLists.Discogs; using NzbDrone.Core.ImportLists.LidarrLists; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Test.Framework; @@ -19,6 +20,7 @@ public void Setup() _importLists = new List(); _importLists.Add(Mocker.Resolve()); + _importLists.Add(Mocker.Resolve()); Mocker.SetConstant>(_importLists); } diff --git a/src/NzbDrone.Core/ImportLists/Discogs/DiscogsApi.cs b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsApi.cs new file mode 100644 index 000000000..a23e50cd5 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsApi.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.ImportLists.Discogs; + +public class DiscogsListResponse +{ + public List Items { get; set; } +} + +public class DiscogsListItem +{ + public string Type { get; set; } + public int Id { get; set; } + [JsonProperty("display_title")] + public string DisplayTitle { get; set; } + [JsonProperty("resource_url")] + public string ResourceUrl { get; set; } + public string Uri { get; set; } +} + +public class DiscogsReleaseResponse +{ + public string Title { get; set; } + public List Artists { get; set; } +} + +public class DiscogsReleaseArtist +{ + public string Name { get; set; } + public int Id { get; set; } +} + +public class DiscogsArtistResponse +{ + public string Name { get; set; } + public int Id { get; set; } +} + +public class DiscogsWantlistResponse +{ + public List Wants { get; set; } +} + +public class DiscogsWantlistItem +{ + [JsonProperty("basic_information")] + public DiscogsBasicInformation BasicInformation { get; set; } +} + +public class DiscogsBasicInformation +{ + public int Id { get; set; } + public string Title { get; set; } + [JsonProperty("resource_url")] + public string ResourceUrl { get; set; } + public List Artists { get; set; } +} diff --git a/src/NzbDrone.Core/ImportLists/Discogs/DiscogsLists.cs b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsLists.cs new file mode 100644 index 000000000..5fdecd611 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsLists.cs @@ -0,0 +1,35 @@ +using System; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.Discogs +{ + public class DiscogsLists : HttpImportListBase + { + public override string Name => "Discogs Lists"; + public override ImportListType ListType => ImportListType.Discogs; + public override TimeSpan MinRefreshInterval => TimeSpan.FromHours(12); + public override TimeSpan RateLimit => TimeSpan.FromSeconds(3); + + public DiscogsLists(IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new DiscogsListsRequestGenerator(Settings); + } + + public override IParseImportListResponse GetParser() + { + return new DiscogsListsParser(Settings, _httpClient, _logger); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Discogs/DiscogsListsParser.cs b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsListsParser.cs new file mode 100644 index 000000000..3aa745187 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsListsParser.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists.Discogs; + +public class DiscogsListsParser(DiscogsListsSettings settings, IHttpClient httpClient, Logger logger) + : IParseImportListResponse +{ + private readonly DiscogsListsSettings _settings = settings; + private readonly IHttpClient _httpClient = httpClient; + private readonly Logger _logger = logger; + + public IList ParseResponse(ImportListResponse importListResponse) + { + var items = new List(); + + if (!PreProcess(importListResponse)) + { + return items; + } + + var jsonResponse = Json.Deserialize(importListResponse.Content); + + if (jsonResponse?.Items == null) + { + return items; + } + + foreach (var item in jsonResponse.Items) + { + if (item.ResourceUrl.IsNullOrWhiteSpace()) + { + continue; + } + + try + { + ImportListItemInfo itemInfo = null; + + if (item.Type == "release") + { + itemInfo = FetchReleaseDetails(item.ResourceUrl); + } + else if (item.Type == "artist") + { + itemInfo = FetchArtistDetails(item.ResourceUrl); + } + + items.AddIfNotNull(itemInfo); + } + catch (Exception ex) + { + _logger.Error(ex, "Discogs API call resulted in an unexpected exception for {0} type item", item.Type ?? "unknown"); + } + } + + return items; + } + + private bool PreProcess(ImportListResponse importListResponse) + { + DiscogsParserHelper.EnsureValidResponse(importListResponse, + "Discogs API responded with HTML content. List may be too large or API may be unavailable."); + return true; + } + + private ImportListItemInfo FetchReleaseDetails(string resourceUrl) + { + return DiscogsParserHelper.FetchReleaseDetails(_httpClient, _settings.Token, resourceUrl); + } + + private ImportListItemInfo FetchArtistDetails(string resourceUrl) + { + return DiscogsParserHelper.FetchArtistDetails(_httpClient, _settings.Token, resourceUrl); + } +} diff --git a/src/NzbDrone.Core/ImportLists/Discogs/DiscogsListsRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsListsRequestGenerator.cs new file mode 100644 index 000000000..97eb139b1 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsListsRequestGenerator.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.ImportLists.Discogs +{ + public class DiscogsListsRequestGenerator : IImportListRequestGenerator + { + private readonly DiscogsListsSettings _settings; + + public DiscogsListsRequestGenerator(DiscogsListsSettings settings) + { + _settings = settings; + } + + public virtual ImportListPageableRequestChain GetListItems() + { + var pageableRequests = new ImportListPageableRequestChain(); + pageableRequests.Add(GetPagedRequests()); + + return pageableRequests; + } + + private IEnumerable GetPagedRequests() + { + var request = new HttpRequestBuilder(_settings.BaseUrl.TrimEnd('/')) + .Resource($"/lists/{_settings.ListId}") + .SetHeader("Authorization", $"Discogs token={_settings.Token}") + .Build(); + + yield return new ImportListRequest(request); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Discogs/DiscogsListsSettings.cs b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsListsSettings.cs new file mode 100644 index 000000000..ab3b5771d --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsListsSettings.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Discogs +{ + public class DiscogsListsSettingsValidator : AbstractValidator + { + public DiscogsListsSettingsValidator() + { + RuleFor(c => c.Token).NotEmpty(); + RuleFor(c => c.ListId).NotEmpty(); + } + } + + public class DiscogsListsSettings : IImportListSettings + { + private static readonly DiscogsListsSettingsValidator Validator = new (); + + public DiscogsListsSettings() + { + BaseUrl = "https://api.discogs.com"; + } + + public string BaseUrl { get; set; } + + [FieldDefinition(0, Label = "Token", Privacy = PrivacyLevel.ApiKey, HelpText = "Discogs Personal Access Token")] + public string Token { get; set; } + + [FieldDefinition(1, Label = "List ID", HelpText = "ID of the Discogs list to import")] + public string ListId { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Discogs/DiscogsParserHelper.cs b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsParserHelper.cs new file mode 100644 index 000000000..3765aaf28 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsParserHelper.cs @@ -0,0 +1,82 @@ +using System.Linq; +using System.Net; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.ImportLists.Exceptions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists.Discogs; + +internal static class DiscogsParserHelper +{ + public static ImportListItemInfo FetchReleaseDetails(IHttpClient httpClient, string token, string resourceUrl) + { + var request = new HttpRequestBuilder(resourceUrl) + .SetHeader("Authorization", $"Discogs token={token}") + .Build(); + + var response = httpClient.Execute(request); + + if (response.StatusCode != HttpStatusCode.OK) + { + return null; + } + + var releaseResponse = Json.Deserialize(response.Content); + + if (releaseResponse?.Artists?.Any() != true || releaseResponse.Title.IsNullOrWhiteSpace()) + { + return null; + } + + return new ImportListItemInfo + { + Artist = releaseResponse.Artists.First().Name, + Album = releaseResponse.Title + }; + } + + public static ImportListItemInfo FetchArtistDetails(IHttpClient httpClient, string token, string resourceUrl) + { + var request = new HttpRequestBuilder(resourceUrl) + .SetHeader("Authorization", $"Discogs token={token}") + .Build(); + + var response = httpClient.Execute(request); + + if (response.StatusCode != HttpStatusCode.OK) + { + return null; + } + + var artistResponse = Json.Deserialize(response.Content); + + if (artistResponse?.Name.IsNullOrWhiteSpace() == true) + { + return null; + } + + return new ImportListItemInfo + { + Artist = artistResponse.Name, + Album = null // Artists don't have a specific album, just the artist name + }; + } + + public static void EnsureValidResponse(ImportListResponse importListResponse, string htmlContentMessage) + { + if (importListResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new ImportListException(importListResponse, + "Discogs API call resulted in an unexpected StatusCode [{0}]", + importListResponse.HttpResponse.StatusCode); + } + + if (importListResponse.HttpResponse.Headers.ContentType != null && + importListResponse.HttpResponse.Headers.ContentType.Contains("text/html")) + { + throw new ImportListException(importListResponse, htmlContentMessage); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Discogs/DiscogsWantlist.cs b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsWantlist.cs new file mode 100644 index 000000000..7c80df13f --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsWantlist.cs @@ -0,0 +1,36 @@ +using System; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.Discogs +{ + public class DiscogsWantlist : HttpImportListBase + { + public override string Name => "Discogs Wantlist"; + public override ImportListType ListType => ImportListType.Discogs; + public override TimeSpan MinRefreshInterval => TimeSpan.FromHours(12); + public override TimeSpan RateLimit => TimeSpan.FromSeconds(3); // Conservative rate limiting to avoid 429 errors when fetching many releases + public override int PageSize => 50; // Discogs API supports pagination with page and per_page parameters + + public DiscogsWantlist(IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new DiscogsWantlistRequestGenerator { Settings = Settings }; + } + + public override IParseImportListResponse GetParser() + { + return new DiscogsWantlistParser(Settings, _httpClient); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Discogs/DiscogsWantlistParser.cs b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsWantlistParser.cs new file mode 100644 index 000000000..8074ea91f --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsWantlistParser.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists.Discogs; + +public class DiscogsWantlistParser : IParseImportListResponse +{ + private readonly DiscogsWantlistSettings _settings; + private readonly IHttpClient _httpClient; + + public DiscogsWantlistParser(DiscogsWantlistSettings settings, IHttpClient httpClient) + { + _settings = settings; + _httpClient = httpClient; + } + + public IList ParseResponse(ImportListResponse importListResponse) + { + var items = new List(); + + if (!PreProcess(importListResponse)) + { + return items; + } + + var jsonResponse = Json.Deserialize(importListResponse.Content); + + if (jsonResponse?.Wants == null) + { + return items; + } + + foreach (var want in jsonResponse.Wants) + { + var basicInfo = want?.BasicInformation; + + if (basicInfo == null) + { + continue; + } + + // The wantlist API includes artists and title in basic_information, so no need to fetch release details + // If you want is artists.First().Name and title, then fetching the release details is redundant according to their API. + if (basicInfo.Artists?.Any() != true || basicInfo.Title.IsNullOrWhiteSpace()) + { + continue; + } + + items.AddIfNotNull(new ImportListItemInfo + { + Artist = basicInfo.Artists.First().Name, + Album = basicInfo.Title + }); + } + + return items; + } + + private bool PreProcess(ImportListResponse importListResponse) + { + DiscogsParserHelper.EnsureValidResponse(importListResponse, + "Discogs API responded with HTML content. Wantlist may be too large or API may be unavailable."); + return true; + } +} diff --git a/src/NzbDrone.Core/ImportLists/Discogs/DiscogsWantlistRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsWantlistRequestGenerator.cs new file mode 100644 index 000000000..0e06ca64b --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsWantlistRequestGenerator.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.ImportLists.Discogs +{ + public class DiscogsWantlistRequestGenerator : IImportListRequestGenerator + { + public DiscogsWantlistSettings Settings { get; set; } + + public int MaxPages { get; set; } + public int PageSize { get; set; } + + public DiscogsWantlistRequestGenerator() + { + MaxPages = 10; // Allow fetching up to 10 pages + PageSize = 50; // Discogs API supports pagination with page and per_page parameters (max 100 per page) + } + + public virtual ImportListPageableRequestChain GetListItems() + { + var pageableRequests = new ImportListPageableRequestChain(); + pageableRequests.Add(GetPagedRequests()); + return pageableRequests; + } + + private IEnumerable GetPagedRequests() + { + for (var page = 1; page <= MaxPages; page++) + { + var request = new HttpRequestBuilder(Settings.BaseUrl.TrimEnd('/')) + .Resource($"/users/{Settings.Username}/wants") + .AddQueryParam("page", page) + .AddQueryParam("per_page", PageSize) + .SetHeader("Authorization", $"Discogs token={Settings.Token}") + .Build(); + + yield return new ImportListRequest(request); + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Discogs/DiscogsWantlistSettings.cs b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsWantlistSettings.cs new file mode 100644 index 000000000..8279e0355 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Discogs/DiscogsWantlistSettings.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Discogs +{ + public class DiscogsWantlistSettingsValidator : AbstractValidator + { + public DiscogsWantlistSettingsValidator() + { + RuleFor(c => c.Token).NotEmpty(); + RuleFor(c => c.Username).NotEmpty(); + } + } + + public class DiscogsWantlistSettings : IImportListSettings + { + private static readonly DiscogsWantlistSettingsValidator Validator = new DiscogsWantlistSettingsValidator(); + + public DiscogsWantlistSettings() + { + BaseUrl = "https://api.discogs.com"; + } + + public string BaseUrl { get; set; } + + [FieldDefinition(0, Label = "Token", Privacy = PrivacyLevel.ApiKey, HelpText = "Discogs Personal Access Token")] + public string Token { get; set; } + + [FieldDefinition(1, Label = "Username", HelpText = "Discogs username whose wantlist to import")] + public string Username { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListType.cs b/src/NzbDrone.Core/ImportLists/ImportListType.cs index 6dd76ef31..4be144c9c 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListType.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListType.cs @@ -1,11 +1,11 @@ -namespace NzbDrone.Core.ImportLists +namespace NzbDrone.Core.ImportLists; + +public enum ImportListType { - public enum ImportListType - { - Program, - Spotify, - LastFm, - Other, - Advanced - } + Program, + Spotify, + LastFm, + Discogs, + Other, + Advanced }