This commit is contained in:
aglowinthefield 2025-11-26 18:47:43 +01:00 committed by GitHub
commit 414ab9fe82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1041 additions and 9 deletions

3
.gitignore vendored
View file

@ -167,3 +167,6 @@ node_modules.nosync
# Ignore Jetbrains IntelliJ Workspace Directories
.idea/
# Ignore Claude
**/.claude

View file

@ -0,0 +1,283 @@
using System.Linq;
using System.Net;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Http;
using NzbDrone.Core.ImportLists.Discogs;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.ImportListTests.Discogs;
[TestFixture]
public class DiscogsListsFixture : CoreTest<DiscogsLists>
{
[SetUp]
public void Setup()
{
Subject.Definition = new ImportLists.ImportListDefinition
{
Id = 1,
Name = "Test Discogs List",
Settings = new DiscogsListsSettings
{
Token = "test_token_123456",
ListId = "123456",
BaseUrl = "https://api.discogs.com"
}
};
}
[Test]
public void should_parse_valid_list_response()
{
var listResponseJson = @"{
""items"": [
{
""type"": ""release"",
""id"": 3,
""display_title"": ""Josh Wink - Profound Sounds Vol. 1"",
""resource_url"": ""https://api.discogs.com/releases/3""
}
]
}";
var releaseResponseJson = @"{
""title"": ""Profound Sounds Vol. 1"",
""artists"": [
{
""name"": ""Josh Wink"",
""id"": 3
}
]
}";
var listCalled = false;
var releaseCalled = false;
var actualListUrl = "";
var actualReleaseUrl = "";
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.IsAny<HttpRequest>()))
.Callback<HttpRequest>(req =>
{
if (req.Url.FullUri.Contains("/lists/"))
{
listCalled = true;
actualListUrl = req.Url.FullUri;
}
else if (req.Url.FullUri.Contains("/releases/"))
{
releaseCalled = true;
actualReleaseUrl = req.Url.FullUri;
}
})
.Returns<HttpRequest>(req =>
{
if (req.Url.FullUri.Contains("/lists/"))
{
return new HttpResponse(req, new HttpHeader(), listResponseJson, HttpStatusCode.OK);
}
else if (req.Url.FullUri.Contains("/releases/"))
{
return new HttpResponse(req, new HttpHeader(), releaseResponseJson, HttpStatusCode.OK);
}
else
{
return new HttpResponse(req, new HttpHeader(), "", HttpStatusCode.NotFound);
}
});
var releases = Subject.Fetch();
// Debug output to see what happened
System.Console.WriteLine($"List called: {listCalled}, URL: {actualListUrl}");
System.Console.WriteLine($"Release called: {releaseCalled}, URL: {actualReleaseUrl}");
System.Console.WriteLine($"Releases count: {releases.Count}");
for (var i = 0; i < releases.Count; i++)
{
System.Console.WriteLine($"Release {i}: Artist='{releases[i].Artist}', Album='{releases[i].Album}'");
}
releases.Should().HaveCount(1);
releases.First().Artist.Should().Be("Josh Wink");
releases.First().Album.Should().Be("Profound Sounds Vol. 1");
}
[Test]
public void should_handle_empty_list_response()
{
var responseJson = @"{""items"": []}";
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.IsAny<HttpRequest>()))
.Returns(new HttpResponse(new HttpRequest("https://api.discogs.com/lists/123456"), new HttpHeader(), responseJson, HttpStatusCode.OK));
var releases = Subject.Fetch();
releases.Should().BeEmpty();
}
[Test]
public void debug_test_to_see_what_url_is_called()
{
var responseJson = @"{""items"": []}";
var actualUrl = "";
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.IsAny<HttpRequest>()))
.Callback<HttpRequest>(req => actualUrl = req.Url.FullUri)
.Returns(new HttpResponse(new HttpRequest("https://api.discogs.com/lists/123456"), new HttpHeader(), responseJson, HttpStatusCode.OK));
var releases = Subject.Fetch();
// This will show us what URL is actually being called
actualUrl.Should().NotBeEmpty();
}
[Test]
public void should_skip_non_release_items()
{
var responseJson = @"{
""items"": [
{
""type"": ""label"",
""id"": 1,
""display_title"": ""Some Label"",
""resource_url"": ""https://api.discogs.com/labels/1""
},
{
""type"": ""release"",
""id"": 3,
""display_title"": ""Josh Wink - Profound Sounds Vol. 1"",
""resource_url"": ""https://api.discogs.com/releases/3""
}
]
}";
var releaseResponseJson = @"{
""title"": ""Profound Sounds Vol. 1"",
""artists"": [
{
""name"": ""Josh Wink"",
""id"": 3
}
]
}";
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/lists/"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), responseJson, HttpStatusCode.OK));
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/releases/"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), releaseResponseJson, HttpStatusCode.OK));
var releases = Subject.Fetch();
releases.Should().HaveCount(1);
releases.First().Artist.Should().Be("Josh Wink");
}
[Test]
public void should_skip_items_when_release_fetch_fails()
{
var listResponseJson = @"{
""items"": [
{
""type"": ""release"",
""id"": 3,
""display_title"": ""Josh Wink - Profound Sounds Vol. 1"",
""resource_url"": ""https://api.discogs.com/releases/3""
}
]
}";
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/lists/"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), listResponseJson, HttpStatusCode.OK));
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/releases/"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), "", HttpStatusCode.NotFound));
var releases = Subject.Fetch();
releases.Should().BeEmpty();
}
[Test]
public void should_skip_releases_with_no_artists()
{
var listResponseJson = @"{
""items"": [
{
""type"": ""release"",
""id"": 3,
""display_title"": ""Various - Compilation"",
""resource_url"": ""https://api.discogs.com/releases/3""
}
]
}";
var releaseResponseJson = @"{
""title"": ""Compilation"",
""artists"": []
}";
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/lists/"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), listResponseJson, HttpStatusCode.OK));
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/releases/"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), releaseResponseJson, HttpStatusCode.OK));
var releases = Subject.Fetch();
releases.Should().BeEmpty();
}
[Test]
public void should_use_first_artist_when_multiple_artists()
{
var listResponseJson = @"{
""items"": [
{
""type"": ""release"",
""id"": 3,
""display_title"": ""Artist 1 & Artist 2 - Collaboration"",
""resource_url"": ""https://api.discogs.com/releases/3""
}
]
}";
var releaseResponseJson = @"{
""title"": ""Collaboration"",
""artists"": [
{
""name"": ""Artist 1"",
""id"": 1
},
{
""name"": ""Artist 2"",
""id"": 2
}
]
}";
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/lists/"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), listResponseJson, HttpStatusCode.OK));
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/releases/"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), releaseResponseJson, HttpStatusCode.OK));
var releases = Subject.Fetch();
releases.Should().HaveCount(1);
releases.First().Artist.Should().Be("Artist 1");
releases.First().Album.Should().Be("Collaboration");
}
}

View file

@ -0,0 +1,232 @@
using System.Linq;
using System.Net;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Http;
using NzbDrone.Core.ImportLists.Discogs;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.ImportListTests.Discogs;
[TestFixture]
public class DiscogsWantlistFixture : CoreTest<DiscogsWantlist>
{
[SetUp]
public void Setup()
{
Subject.Definition = new ImportLists.ImportListDefinition
{
Id = 1,
Name = "Test Discogs Wantlist",
Settings = new DiscogsWantlistSettings
{
Token = "test_token_123456",
Username = "test_user",
BaseUrl = "https://api.discogs.com"
}
};
}
[Test]
public void should_parse_valid_wantlist_response()
{
var wantlistResponseJson = @"{
""wants"": [
{
""basic_information"": {
""id"": 3,
""title"": ""Profound Sounds Vol. 1"",
""resource_url"": ""https://api.discogs.com/releases/3"",
""artists"": [
{
""name"": ""Josh Wink"",
""id"": 3
}
]
}
}
]
}";
var releaseResponseJson = @"{
""title"": ""Profound Sounds Vol. 1"",
""artists"": [
{
""name"": ""Josh Wink"",
""id"": 3
}
]
}";
var wantlistCalled = false;
var releaseCalled = false;
var actualWantlistUrl = "";
var actualReleaseUrl = "";
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.IsAny<HttpRequest>()))
.Callback<HttpRequest>(req =>
{
if (req.Url.FullUri.Contains("/wants"))
{
wantlistCalled = true;
actualWantlistUrl = req.Url.FullUri;
}
else if (req.Url.FullUri.Contains("/releases/"))
{
releaseCalled = true;
actualReleaseUrl = req.Url.FullUri;
}
})
.Returns<HttpRequest>(req =>
{
if (req.Url.FullUri.Contains("/wants"))
{
return new HttpResponse(req, new HttpHeader(), wantlistResponseJson, HttpStatusCode.OK);
}
else if (req.Url.FullUri.Contains("/releases/"))
{
return new HttpResponse(req, new HttpHeader(), releaseResponseJson, HttpStatusCode.OK);
}
else
{
return new HttpResponse(req, new HttpHeader(), "", HttpStatusCode.NotFound);
}
});
var releases = Subject.Fetch();
// Debug output to see what happened
System.Console.WriteLine($"Wantlist called: {wantlistCalled}, URL: {actualWantlistUrl}");
System.Console.WriteLine($"Release called: {releaseCalled}, URL: {actualReleaseUrl}");
System.Console.WriteLine($"Releases count: {releases.Count}");
for (var i = 0; i < releases.Count; i++)
{
System.Console.WriteLine($"Release {i}: Artist='{releases[i].Artist}', Album='{releases[i].Album}'");
}
releases.Should().HaveCount(1);
releases.First().Artist.Should().Be("Josh Wink");
releases.First().Album.Should().Be("Profound Sounds Vol. 1");
}
[Test]
public void should_handle_empty_wantlist_response()
{
var responseJson = @"{""wants"": []}";
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.IsAny<HttpRequest>()))
.Returns(new HttpResponse(new HttpRequest("https://api.discogs.com/users/test_user/wants"), new HttpHeader(), responseJson, HttpStatusCode.OK));
var releases = Subject.Fetch();
releases.Should().BeEmpty();
}
[Test]
public void should_skip_items_when_release_fetch_fails()
{
var wantlistResponseJson = @"{
""wants"": [
{
""basic_information"": {
""id"": 3,
""title"": ""Profound Sounds Vol. 1"",
""resource_url"": ""https://api.discogs.com/releases/3""
}
}
]
}";
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/wants"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), wantlistResponseJson, HttpStatusCode.OK));
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/releases/"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), "", HttpStatusCode.NotFound));
var releases = Subject.Fetch();
releases.Should().BeEmpty();
}
[Test]
public void should_skip_releases_with_no_artists()
{
var wantlistResponseJson = @"{
""wants"": [
{
""basic_information"": {
""id"": 3,
""title"": ""Compilation"",
""resource_url"": ""https://api.discogs.com/releases/3""
}
}
]
}";
var releaseResponseJson = @"{
""title"": ""Compilation"",
""artists"": []
}";
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/wants"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), wantlistResponseJson, HttpStatusCode.OK));
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/releases/"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), releaseResponseJson, HttpStatusCode.OK));
var releases = Subject.Fetch();
releases.Should().BeEmpty();
}
[Test]
public void should_use_first_artist_when_multiple_artists()
{
var wantlistResponseJson = @"{
""wants"": [
{
""basic_information"": {
""id"": 3,
""title"": ""Collaboration"",
""resource_url"": ""https://api.discogs.com/releases/3""
}
}
]
}";
var releaseResponseJson = @"{
""title"": ""Collaboration"",
""artists"": [
{
""name"": ""Artist 1"",
""id"": 1
},
{
""name"": ""Artist 2"",
""id"": 2
}
]
}";
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/wants"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), wantlistResponseJson, HttpStatusCode.OK));
Mocker.GetMock<IHttpClient>()
.Setup(s => s.Execute(It.Is<HttpRequest>(r => r.Url.ToString().Contains("/releases/"))))
.Returns(new HttpResponse(new HttpRequest("http://my.indexer.com"), new HttpHeader(), releaseResponseJson, HttpStatusCode.OK));
var releases = Subject.Fetch();
releases.Should().HaveCount(1);
releases.First().Artist.Should().Be("Artist 1");
releases.First().Album.Should().Be("Collaboration");
}
}

View file

@ -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<IImportList>();
_importLists.Add(Mocker.Resolve<LidarrLists>());
_importLists.Add(Mocker.Resolve<DiscogsLists>());
Mocker.SetConstant<IEnumerable<IImportList>>(_importLists);
}

View file

@ -0,0 +1,38 @@
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<DiscogsListsSettings>
{
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); // Conservative rate limiting to avoid 429 errors when fetching many releases
public override int PageSize => 0; // Discogs doesn't support pagination for lists
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 = Settings };
}
public override IParseImportListResponse GetParser()
{
var parser = new DiscogsListsParser();
parser.SetContext(_httpClient, Settings);
return parser;
}
}
}

View file

@ -0,0 +1,148 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using Newtonsoft.Json;
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;
public class DiscogsListsParser : IParseImportListResponse
{
private ImportListResponse _importListResponse;
private IHttpClient _httpClient;
private DiscogsListsSettings _settings;
public DiscogsListsParser()
{
}
public void SetContext(IHttpClient httpClient, DiscogsListsSettings settings)
{
_httpClient = httpClient;
_settings = settings;
}
public IList<ImportListItemInfo> ParseResponse(ImportListResponse importListResponse)
{
_importListResponse = importListResponse;
var items = new List<ImportListItemInfo>();
if (!PreProcess(_importListResponse))
{
return items;
}
var jsonResponse = Json.Deserialize<DiscogsListResponse>(_importListResponse.Content);
if (jsonResponse?.Items == null)
{
return items;
}
foreach (var item in jsonResponse.Items)
{
if (item?.Type == "release" && item.ResourceUrl.IsNotNullOrWhiteSpace())
{
try
{
if (_httpClient != null && _settings != null)
{
var releaseInfo = FetchReleaseDetails(item.ResourceUrl);
if (releaseInfo != null)
{
items.Add(releaseInfo);
}
}
}
catch
{
// If we can't fetch release details, skip this item
continue;
}
}
}
return items;
}
// Unfortunately discogs release details are nested in a given /release/N endpoint.
// We'll have to fetch each one to get proper details.
private ImportListItemInfo FetchReleaseDetails(string resourceUrl)
{
var request = new HttpRequestBuilder(resourceUrl)
.SetHeader("Authorization", $"Discogs token={_settings.Token}")
.Build();
var response = _httpClient.Execute(request);
if (response.StatusCode != HttpStatusCode.OK)
{
return null;
}
var releaseResponse = Json.Deserialize<DiscogsReleaseResponse>(response.Content);
if (releaseResponse?.Artists?.Any() == true && releaseResponse.Title.IsNotNullOrWhiteSpace())
{
return new ImportListItemInfo
{
Artist = releaseResponse.Artists.First().Name,
Album = releaseResponse.Title
};
}
return null;
}
protected virtual bool PreProcess(ImportListResponse importListResponse)
{
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,
"Discogs API responded with HTML content. List may be too large or API may be unavailable.");
}
return true;
}
}
public class DiscogsListResponse
{
public List<DiscogsListItem> 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<DiscogsReleaseArtist> Artists { get; set; }
}
public class DiscogsReleaseArtist
{
public string Name { get; set; }
public int Id { get; set; }
}

View file

@ -0,0 +1,36 @@
using System.Collections.Generic;
using NzbDrone.Common.Http;
namespace NzbDrone.Core.ImportLists.Discogs
{
public class DiscogsListsRequestGenerator : IImportListRequestGenerator
{
public DiscogsListsSettings Settings { get; set; }
public int MaxPages { get; set; }
public int PageSize { get; set; }
public DiscogsListsRequestGenerator()
{
MaxPages = 1;
PageSize = 0; // Discogs doesn't support pagination for lists currently
}
public virtual ImportListPageableRequestChain GetListItems()
{
var pageableRequests = new ImportListPageableRequestChain();
pageableRequests.Add(GetPagedRequests());
return pageableRequests;
}
private IEnumerable<ImportListRequest> GetPagedRequests()
{
var request = new HttpRequestBuilder(Settings.BaseUrl.TrimEnd('/'))
.Resource($"/lists/{Settings.ListId}")
.SetHeader("Authorization", $"Discogs token={Settings.Token}")
.Build();
yield return new ImportListRequest(request);
}
}
}

View file

@ -0,0 +1,38 @@
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.ImportLists.Discogs
{
public class DiscogsListsSettingsValidator : AbstractValidator<DiscogsListsSettings>
{
public DiscogsListsSettingsValidator()
{
RuleFor(c => c.Token).NotEmpty();
RuleFor(c => c.ListId).NotEmpty();
}
}
public class DiscogsListsSettings : IImportListSettings
{
private static readonly DiscogsListsSettingsValidator Validator = new DiscogsListsSettingsValidator();
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));
}
}
}

View file

@ -0,0 +1,38 @@
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<DiscogsWantlistSettings>
{
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 => 0; // Discogs doesn't support pagination for wantlists currently
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()
{
var parser = new DiscogsWantlistParser();
parser.SetContext(_httpClient, Settings);
return parser;
}
}
}

View file

@ -0,0 +1,140 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using Newtonsoft.Json;
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;
public class DiscogsWantlistParser : IParseImportListResponse
{
private ImportListResponse _importListResponse;
private IHttpClient _httpClient;
private DiscogsWantlistSettings _settings;
public DiscogsWantlistParser()
{
}
public void SetContext(IHttpClient httpClient, DiscogsWantlistSettings settings)
{
_httpClient = httpClient;
_settings = settings;
}
public IList<ImportListItemInfo> ParseResponse(ImportListResponse importListResponse)
{
_importListResponse = importListResponse;
var items = new List<ImportListItemInfo>();
if (!PreProcess(_importListResponse))
{
return items;
}
var jsonResponse = Json.Deserialize<DiscogsWantlistResponse>(_importListResponse.Content);
if (jsonResponse?.Wants == null)
{
return items;
}
foreach (var want in jsonResponse.Wants)
{
if (want?.BasicInformation != null)
{
try
{
if (_httpClient != null && _settings != null)
{
var releaseInfo = FetchReleaseDetails(want.BasicInformation.ResourceUrl);
if (releaseInfo != null)
{
items.Add(releaseInfo);
}
}
}
catch
{
// If we can't fetch release details, skip this item
continue;
}
}
}
return items;
}
// Unfortunately discogs release details are nested in a given /release/N endpoint.
// We'll have to fetch each one to get proper details.
private ImportListItemInfo FetchReleaseDetails(string resourceUrl)
{
var request = new HttpRequestBuilder(resourceUrl)
.SetHeader("Authorization", $"Discogs token={_settings.Token}")
.Build();
var response = _httpClient.Execute(request);
if (response.StatusCode != HttpStatusCode.OK)
{
return null;
}
var releaseResponse = Json.Deserialize<DiscogsReleaseResponse>(response.Content);
if (releaseResponse?.Artists?.Any() == true && releaseResponse.Title.IsNotNullOrWhiteSpace())
{
return new ImportListItemInfo
{
Artist = releaseResponse.Artists.First().Name,
Album = releaseResponse.Title
};
}
return null;
}
protected virtual bool PreProcess(ImportListResponse importListResponse)
{
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,
"Discogs API responded with HTML content. Wantlist may be too large or API may be unavailable.");
}
return true;
}
}
public class DiscogsWantlistResponse
{
public List<DiscogsWantlistItem> 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<DiscogsReleaseArtist> Artists { get; set; }
}

View file

@ -0,0 +1,36 @@
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 = 1;
PageSize = 0; // Discogs doesn't support pagination for wantlists currently
}
public virtual ImportListPageableRequestChain GetListItems()
{
var pageableRequests = new ImportListPageableRequestChain();
pageableRequests.Add(GetPagedRequests());
return pageableRequests;
}
private IEnumerable<ImportListRequest> GetPagedRequests()
{
var request = new HttpRequestBuilder(Settings.BaseUrl.TrimEnd('/'))
.Resource($"/users/{Settings.Username}/wants")
.SetHeader("Authorization", $"Discogs token={Settings.Token}")
.Build();
yield return new ImportListRequest(request);
}
}
}

View file

@ -0,0 +1,38 @@
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.ImportLists.Discogs
{
public class DiscogsWantlistSettingsValidator : AbstractValidator<DiscogsWantlistSettings>
{
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));
}
}
}

View file

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