Skip to content

Commit 354df65

Browse files
author
Meyn
committed
Improved ListenBrainz ImportLists
1 parent 06974ad commit 354df65

22 files changed

+971
-1060
lines changed
Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,72 @@
1-
using FluentValidation.Results;
1+
using FluentValidation.Results;
22
using NLog;
33
using NzbDrone.Common.Extensions;
44
using NzbDrone.Common.Http;
55
using NzbDrone.Core.Configuration;
66
using NzbDrone.Core.ImportLists;
77
using NzbDrone.Core.ImportLists.Exceptions;
88
using NzbDrone.Core.Parser;
9+
using NzbDrone.Core.Parser.Model;
910
using System.Net;
1011

1112
namespace Tubifarry.ImportLists.ListenBrainz.ListenBrainzCFRecommendations
1213
{
1314
public class ListenBrainzCFRecommendationsImportList : HttpImportListBase<ListenBrainzCFRecommendationsSettings>
1415
{
15-
private readonly IHttpClient _httpClient;
16-
1716
public override string Name => "ListenBrainz Recording Recommend";
1817
public override ImportListType ListType => ImportListType.Other;
1918
public override TimeSpan MinRefreshInterval => TimeSpan.FromDays(1);
20-
public override int PageSize => 0; // No pagination
19+
public override int PageSize => 0;
2120
public override TimeSpan RateLimit => TimeSpan.FromMilliseconds(200);
2221

2322
public ListenBrainzCFRecommendationsImportList(IHttpClient httpClient,
2423
IImportListStatusService importListStatusService,
2524
IConfigService configService,
2625
IParsingService parsingService,
2726
Logger logger)
28-
: base(httpClient, importListStatusService, configService, parsingService, logger)
29-
{
30-
_httpClient = httpClient;
31-
}
27+
: base(httpClient, importListStatusService, configService, parsingService, logger) { }
3228

33-
public override IImportListRequestGenerator GetRequestGenerator()
34-
{
35-
return new ListenBrainzCFRecommendationsRequestGenerator(Settings);
36-
}
29+
public override IImportListRequestGenerator GetRequestGenerator() =>
30+
new ListenBrainzCFRecommendationsRequestGenerator(Settings);
3731

38-
public override IParseImportListResponse GetParser()
39-
{
40-
return new ListenBrainzCFRecommendationsParser(Settings, _httpClient);
41-
}
32+
public override IParseImportListResponse GetParser() =>
33+
new ListenBrainzCFRecommendationsParser();
4234

43-
protected override void Test(List<ValidationFailure> failures)
44-
{
35+
protected override bool IsValidRelease(ImportListItemInfo release) =>
36+
release.AlbumMusicBrainzId.IsNotNullOrWhiteSpace() ||
37+
release.ArtistMusicBrainzId.IsNotNullOrWhiteSpace() ||
38+
(!release.Album.IsNullOrWhiteSpace() || !release.Artist.IsNullOrWhiteSpace());
39+
40+
protected override void Test(List<ValidationFailure> failures) =>
4541
failures.AddIfNotNull(TestConnection());
46-
}
4742

4843
protected override ValidationFailure TestConnection()
4944
{
5045
try
5146
{
52-
IImportListRequestGenerator generator = GetRequestGenerator();
53-
ImportListPageableRequest requests = generator.GetListItems().GetAllTiers().First();
47+
ImportListRequest? firstRequest = GetRequestGenerator()
48+
.GetListItems()
49+
.GetAllTiers()
50+
.FirstOrDefault()?
51+
.FirstOrDefault();
5452

55-
if (!requests.Any())
53+
if (firstRequest == null)
5654
{
57-
return new ValidationFailure(string.Empty, "No requests generated. Check your configuration.");
55+
return new ValidationFailure(string.Empty, "No requests generated, check your configuration");
5856
}
5957

60-
ImportListRequest firstRequest = requests.First();
6158
ImportListResponse response = FetchImportListResponse(firstRequest);
6259

6360
if (response.HttpResponse.StatusCode == HttpStatusCode.NoContent)
6461
{
65-
return new ValidationFailure(string.Empty, "No recording recommendations available for this user. These are generated based on collaborative filtering and may not be available for all users.");
62+
return new ValidationFailure(string.Empty, "No recording recommendations available for this user. These are generated based on collaborative filtering and may not be available for all users");
6663
}
6764

6865
if (response.HttpResponse.StatusCode != HttpStatusCode.OK)
6966
{
70-
return new ValidationFailure(string.Empty,
71-
$"Connection failed: HTTP {(int)response.HttpResponse.StatusCode} ({response.HttpResponse.StatusCode})");
67+
return new ValidationFailure(string.Empty, $"Connection failed with HTTP {(int)response.HttpResponse.StatusCode} ({response.HttpResponse.StatusCode})");
7268
}
73-
74-
_logger.Info("Test successful - recording recommendations endpoint accessible");
75-
return null;
69+
return null!;
7670
}
7771
catch (ImportListException ex)
7872
{
@@ -82,8 +76,8 @@ protected override ValidationFailure TestConnection()
8276
catch (Exception ex)
8377
{
8478
_logger.Error(ex, "Test connection failed");
85-
return new ValidationFailure(string.Empty, "Configuration error - check logs for details");
79+
return new ValidationFailure(string.Empty, "Configuration error, check logs for details");
8680
}
8781
}
8882
}
89-
}
83+
}
Lines changed: 25 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
using NLog;
2-
using NzbDrone.Common.Http;
1+
using NLog;
32
using NzbDrone.Common.Instrumentation;
43
using NzbDrone.Core.ImportLists;
54
using NzbDrone.Core.ImportLists.Exceptions;
@@ -11,146 +10,58 @@ namespace Tubifarry.ImportLists.ListenBrainz.ListenBrainzCFRecommendations
1110
{
1211
public class ListenBrainzCFRecommendationsParser : IParseImportListResponse
1312
{
14-
private readonly ListenBrainzCFRecommendationsSettings _settings;
15-
private readonly IHttpClient _httpClient;
1613
private readonly Logger _logger;
1714

18-
public ListenBrainzCFRecommendationsParser(ListenBrainzCFRecommendationsSettings settings, IHttpClient httpClient)
15+
public ListenBrainzCFRecommendationsParser()
1916
{
20-
_settings = settings;
21-
_httpClient = httpClient;
2217
_logger = NzbDroneLogger.GetLogger(this);
2318
}
2419

2520
public IList<ImportListItemInfo> ParseResponse(ImportListResponse importListResponse)
2621
{
27-
List<ImportListItemInfo> items = new();
28-
2922
if (!PreProcess(importListResponse))
30-
return items;
23+
return new List<ImportListItemInfo>();
3124

3225
try
3326
{
34-
items.AddRange(ParseRecordingRecommendations(importListResponse.Content));
27+
IList<ImportListItemInfo> items = ParseRecordingRecommendations(importListResponse.Content);
28+
_logger.Trace("Successfully parsed {0} recording recommendations", items.Count);
29+
return items;
3530
}
3631
catch (Exception ex)
3732
{
38-
_logger.Error(ex, "Error parsing ListenBrainz recording recommendations response");
39-
throw new ImportListException(importListResponse, "Error parsing response", ex);
33+
_logger.Error(ex, "Failed to parse ListenBrainz recording recommendations");
34+
throw new ImportListException(importListResponse, "Failed to parse response", ex);
4035
}
41-
42-
_logger.Debug($"Parsed {items.Count} items from ListenBrainz recording recommendations");
43-
return items;
4436
}
4537

4638
private IList<ImportListItemInfo> ParseRecordingRecommendations(string content)
4739
{
48-
List<ImportListItemInfo> items = new();
4940
RecordingRecommendationResponse? response = JsonSerializer.Deserialize<RecordingRecommendationResponse>(content, GetJsonOptions());
41+
IReadOnlyList<RecordingRecommendation>? recommendations = response?.Payload?.Mbids;
5042

51-
if (response?.Payload?.Mbids == null)
43+
if (recommendations?.Any() != true)
5244
{
53-
_logger.Debug("No recording recommendations found");
54-
return items;
45+
_logger.Debug("No recording recommendations available");
46+
return new List<ImportListItemInfo>();
5547
}
5648

57-
_logger.Debug($"Found {response.Payload.Mbids.Count} recording recommendations");
49+
_logger.Trace("Processing {0} recording recommendations", recommendations.Count);
5850

59-
// Group recordings by their MBIDs to batch the MusicBrainz lookup
60-
List<string> recordingMbids = response.Payload.Mbids.Select(r => r.RecordingMbid).ToList();
61-
62-
// We need to look up the artist information for each recording via MusicBrainz API
63-
// Since we can't use the MusicBrainz API directly here, we'll use ListenBrainz's lookup
64-
HashSet<string> artistMbids = new();
65-
66-
foreach (RecordingRecommendation recommendation in response.Payload.Mbids)
67-
{
68-
try
51+
return recommendations
52+
.Where(r => !string.IsNullOrWhiteSpace(r.RecordingMbid))
53+
.Select(r => new ImportListItemInfo
6954
{
70-
List<string> artistInfo = LookupRecordingArtist(recommendation.RecordingMbid);
71-
if (artistInfo != null)
72-
{
73-
foreach (string artistId in artistInfo)
74-
{
75-
artistMbids.Add(artistId);
76-
}
77-
}
78-
}
79-
catch (Exception ex)
80-
{
81-
_logger.Debug(ex, $"Error looking up artist for recording {recommendation.RecordingMbid}");
82-
}
83-
}
84-
85-
// Convert artist MBIDs to ImportListItemInfo
86-
foreach (string artistMbid in artistMbids)
87-
{
88-
items.Add(new ImportListItemInfo
89-
{
90-
ArtistMusicBrainzId = artistMbid
91-
});
92-
}
93-
94-
return items;
55+
AlbumMusicBrainzId = r.RecordingMbid,
56+
Album = r.RecordingMbid
57+
})
58+
.ToList();
9559
}
9660

97-
private List<string> LookupRecordingArtist(string recordingMbid)
61+
private static JsonSerializerOptions GetJsonOptions() => new()
9862
{
99-
List<string> artistMbids = new();
100-
101-
try
102-
{
103-
// Use MusicBrainz API to look up recording details
104-
HttpRequestBuilder request = new HttpRequestBuilder("https://musicbrainz.org")
105-
.AddQueryParam("fmt", "json")
106-
.AddQueryParam("inc", "artist-credits")
107-
.Accept(HttpAccept.Json);
108-
109-
HttpRequest httpRequest = request.Build();
110-
httpRequest.Url = new HttpUri($"https://musicbrainz.org/ws/2/recording/{recordingMbid}?fmt=json&inc=artist-credits");
111-
112-
// Add User-Agent as required by MusicBrainz
113-
httpRequest.Headers.Add("User-Agent", "Lidarr-ListenBrainz-Plugin/1.0 (https://github.com/Lidarr/Lidarr)");
114-
115-
// Rate limit for MusicBrainz (1 request per second)
116-
Thread.Sleep(1000);
117-
118-
HttpResponse response = _httpClient.Execute(httpRequest);
119-
120-
if (response.StatusCode != HttpStatusCode.OK)
121-
{
122-
_logger.Debug($"Failed to lookup recording {recordingMbid}: HTTP {response.StatusCode}");
123-
return artistMbids;
124-
}
125-
126-
MusicBrainzRecordingResponse? recordingData = JsonSerializer.Deserialize<MusicBrainzRecordingResponse>(response.Content, GetJsonOptions());
127-
128-
if (recordingData?.ArtistCredits != null)
129-
{
130-
foreach (MusicBrainzArtistCredit credit in recordingData.ArtistCredits)
131-
{
132-
if (!string.IsNullOrEmpty(credit.Artist?.Id))
133-
{
134-
artistMbids.Add(credit.Artist.Id);
135-
}
136-
}
137-
}
138-
}
139-
catch (Exception ex)
140-
{
141-
_logger.Debug(ex, $"Error looking up recording {recordingMbid} in MusicBrainz");
142-
}
143-
144-
return artistMbids;
145-
}
146-
147-
private JsonSerializerOptions GetJsonOptions()
148-
{
149-
return new JsonSerializerOptions
150-
{
151-
PropertyNameCaseInsensitive = true
152-
};
153-
}
63+
PropertyNameCaseInsensitive = true
64+
};
15465

15566
private bool PreProcess(ImportListResponse importListResponse)
15667
{
@@ -162,10 +73,10 @@ private bool PreProcess(ImportListResponse importListResponse)
16273

16374
if (importListResponse.HttpResponse.StatusCode != HttpStatusCode.OK)
16475
{
165-
throw new ImportListException(importListResponse, "Unexpected StatusCode [{0}]", importListResponse.HttpResponse.StatusCode);
76+
throw new ImportListException(importListResponse, "Unexpected status code {0}", importListResponse.HttpResponse.StatusCode);
16677
}
16778

16879
return true;
16980
}
17081
}
171-
}
82+
}
Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
using NzbDrone.Common.Http;
1+
using NzbDrone.Common.Http;
22
using NzbDrone.Core.ImportLists;
33

44
namespace Tubifarry.ImportLists.ListenBrainz.ListenBrainzCFRecommendations
55
{
66
public class ListenBrainzCFRecommendationsRequestGenerator : IImportListRequestGenerator
77
{
88
private readonly ListenBrainzCFRecommendationsSettings _settings;
9-
private const int MAX_ITEMS_PER_REQUEST = 100; // ListenBrainz API limit
9+
private const int MaxItemsPerRequest = 100;
1010

1111
public ListenBrainzCFRecommendationsRequestGenerator(ListenBrainzCFRecommendationsSettings settings)
1212
{
@@ -22,34 +22,30 @@ public virtual ImportListPageableRequestChain GetListItems()
2222

2323
private IEnumerable<ImportListRequest> GetPagedRequests()
2424
{
25-
int totalToFetch = _settings.Count;
26-
int totalRequested = 0;
27-
int offset = 0;
25+
int requestsNeeded = (_settings.Count + MaxItemsPerRequest - 1) / MaxItemsPerRequest;
2826

29-
// Generate multiple paginated requests if count > MAX_ITEMS_PER_REQUEST
30-
while (totalRequested < totalToFetch)
31-
{
32-
int currentPageSize = Math.Min(totalToFetch - totalRequested, MAX_ITEMS_PER_REQUEST);
33-
34-
HttpRequestBuilder requestBuilder = new HttpRequestBuilder(_settings.BaseUrl)
35-
.Accept(HttpAccept.Json);
27+
return Enumerable.Range(0, requestsNeeded)
28+
.Select(page => CreateRequest(page * MaxItemsPerRequest, Math.Min(MaxItemsPerRequest, _settings.Count - (page * MaxItemsPerRequest))))
29+
.Where(request => request != null);
30+
}
3631

37-
if (!string.IsNullOrEmpty(_settings.UserToken))
38-
{
39-
requestBuilder.SetHeader("Authorization", $"Token {_settings.UserToken}");
40-
}
32+
private ImportListRequest CreateRequest(int offset, int count)
33+
{
34+
if (count <= 0)
35+
return null!;
4136

42-
HttpRequest request = requestBuilder.Build();
43-
request.Url = new HttpUri($"{_settings.BaseUrl}/1/cf/recommendation/user/{_settings.UserName}/recording?count={currentPageSize}&offset={offset}");
37+
HttpRequestBuilder requestBuilder = new HttpRequestBuilder(_settings.BaseUrl)
38+
.Accept(HttpAccept.Json);
4439

45-
yield return new ImportListRequest(request);
40+
if (!string.IsNullOrEmpty(_settings.UserToken))
41+
{
42+
requestBuilder.SetHeader("Authorization", $"Token {_settings.UserToken}");
43+
}
4644

47-
totalRequested += currentPageSize;
48-
offset += currentPageSize;
45+
HttpRequest request = requestBuilder.Build();
46+
request.Url = new HttpUri($"{_settings.BaseUrl}/1/cf/recommendation/user/{_settings.UserName}/recording?count={count}&offset={offset}");
4947

50-
if (currentPageSize < MAX_ITEMS_PER_REQUEST)
51-
break;
52-
}
48+
return new ImportListRequest(request);
5349
}
5450
}
55-
}
51+
}

Tubifarry/ImportLists/ListenBrainz/ListenBrainzCFRecommendations/ListenBrainzCFRecommendationsSettings.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
// ListenBrainzCFRecommendationsSettings.cs
2-
using FluentValidation;
1+
using FluentValidation;
32
using NzbDrone.Core.Annotations;
43
using NzbDrone.Core.ImportLists;
54
using NzbDrone.Core.Validation;

0 commit comments

Comments
 (0)