Skip to content

Commit d37cb39

Browse files
author
Meyn
committed
Implement full Discogs integration with search, mapping, and caching
- Add Discogs API client for artist/release/master search functionality - Implement query sanitization for Unicode-only character compliance - Create mapping layer (DiscogsMappingHelper) to transform responses to Lidarr models - Introduce caching system to optimize API usage and performance - Remove Parker.Square.Discogs dependency - Add error handling for rate limiting and API exceptions
1 parent d7569db commit d37cb39

11 files changed

Lines changed: 1279 additions & 683 deletions

Tubifarry/ILRepack.targets

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
<InputAssemblies Include="$(OutputPath)Xabe.FFmpeg.dll" />
1010
<InputAssemblies Include="$(OutputPath)Xabe.FFmpeg.Downloader.dll" />
1111
<InputAssemblies Include="$(OutputPath)FuzzySharp.dll" />
12-
<InputAssemblies Include="$(OutputPath)ParkSquare.Discogs.dll" />
1312
<InputAssemblies Include="$(OutputPath)Microsoft.Extensions.Logging.Abstractions.dll" />
1413
<InputAssemblies Include="$(OutputPath)Acornima.dll" />
1514
<InputAssemblies Include="$(OutputPath)Jint.dll" />
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
using NLog;
2+
using NzbDrone.Common.Http;
3+
using NzbDrone.Common.Instrumentation;
4+
using System.Net;
5+
using System.Text.Json;
6+
7+
namespace Tubifarry.Metadata.Proxy.DiscogsProxy
8+
{
9+
public class DiscogsApiService
10+
{
11+
private readonly IHttpClient _httpClient;
12+
private readonly Logger _logger;
13+
14+
public string? AuthToken { get; set; }
15+
public string BaseUrl { get; set; } = "https://api.discogs.com";
16+
public int MaxRetries { get; set; } = 5;
17+
public int InitialRetryDelayMs { get; set; } = 1000;
18+
public int MaxPageLimit { get; set; } = 5;
19+
public int PageSize { get; set; } = 30;
20+
21+
private int _rateLimitTotal = 60;
22+
private int _rateLimitUsed = 0;
23+
private int _rateLimitRemaining = 60;
24+
private DateTime _lastRequestTime = DateTime.MinValue;
25+
26+
public DiscogsApiService(IHttpClient httpClient)
27+
{
28+
_httpClient = httpClient;
29+
_logger = NzbDroneLogger.GetLogger(this);
30+
}
31+
32+
public async Task<DiscogsRelease?> GetReleaseAsync(int releaseId, string? currency = null)
33+
{
34+
HttpRequestBuilder request = BuildRequest($"releases/{releaseId}");
35+
AddQueryParamIfNotNull(request, "curr_abbr", currency);
36+
JsonElement response = await ExecuteRequestWithRetryAsync(request);
37+
return response.ValueKind == JsonValueKind.Undefined ? null : JsonSerializer.Deserialize<DiscogsRelease>(response.GetRawText());
38+
}
39+
40+
public async Task<DiscogsMasterRelease?> GetMasterReleaseAsync(int masterId)
41+
{
42+
JsonElement response = await ExecuteRequestWithRetryAsync(BuildRequest($"masters/{masterId}"));
43+
return response.ValueKind == JsonValueKind.Undefined ? null : JsonSerializer.Deserialize<DiscogsMasterRelease>(response.GetRawText());
44+
}
45+
46+
public async Task<List<DiscogsMasterReleaseVersion>> GetMasterVersionsAsync(int masterId, int? maxPages = null, string? format = null, string? label = null, string? released = null, string? country = null, string? sort = null, string? sortOrder = null)
47+
{
48+
HttpRequestBuilder request = BuildRequest($"masters/{masterId}/versions");
49+
AddQueryParamIfNotNull(request, "format", format);
50+
AddQueryParamIfNotNull(request, "label", label);
51+
AddQueryParamIfNotNull(request, "released", released);
52+
AddQueryParamIfNotNull(request, "country", country);
53+
AddQueryParamIfNotNull(request, "sort", sort);
54+
AddQueryParamIfNotNull(request, "sort_order", sortOrder);
55+
return await FetchPaginatedResultsAsync<DiscogsMasterReleaseVersion>(request, maxPages ?? MaxPageLimit, PageSize) ?? new();
56+
}
57+
58+
public async Task<DiscogsArtist?> GetArtistAsync(int artistId)
59+
{
60+
JsonElement response = await ExecuteRequestWithRetryAsync(BuildRequest($"artists/{artistId}"));
61+
return response.ValueKind == JsonValueKind.Undefined ? null : JsonSerializer.Deserialize<DiscogsArtist>(response.GetRawText());
62+
}
63+
64+
public async Task<List<DiscogsArtistRelease>> GetArtistReleasesAsync(int artistId, int? maxPages = null, int? itemsPerPage = null, string? sort = null, string? sortOrder = null)
65+
{
66+
HttpRequestBuilder request = BuildRequest($"artists/{artistId}/releases");
67+
AddQueryParamIfNotNull(request, "sort", sort);
68+
AddQueryParamIfNotNull(request, "sort_order", sortOrder);
69+
return await FetchPaginatedResultsAsync<DiscogsArtistRelease>(request, maxPages ?? MaxPageLimit, itemsPerPage ?? PageSize) ?? new();
70+
}
71+
72+
public async Task<DiscogsLabel?> GetLabelAsync(int labelId)
73+
{
74+
JsonElement response = await ExecuteRequestWithRetryAsync(BuildRequest($"labels/{labelId}"));
75+
return response.ValueKind == JsonValueKind.Undefined ? null : JsonSerializer.Deserialize<DiscogsLabel>(response.GetRawText());
76+
}
77+
78+
public async Task<List<DiscogsLabelRelease>> GetLabelReleasesAsync(int labelId, int? maxPages = null)
79+
{
80+
return await FetchPaginatedResultsAsync<DiscogsLabelRelease>(BuildRequest($"labels/{labelId}/releases"), maxPages ?? MaxPageLimit, PageSize) ?? new();
81+
}
82+
83+
public async Task<List<DiscogsSearchItem>> SearchAsync(DiscogsSearchParameter searchRequest, int? maxPages = null)
84+
{
85+
HttpRequestBuilder request = BuildRequest("database/search");
86+
AddSearchParams(request, searchRequest);
87+
return await FetchPaginatedResultsAsync<DiscogsSearchItem>(request, maxPages ?? MaxPageLimit, PageSize) ?? new();
88+
}
89+
90+
public async Task<DiscogsStats?> GetReleaseStatsAsync(int releaseId)
91+
{
92+
JsonElement response = await ExecuteRequestWithRetryAsync(BuildRequest($"releases/{releaseId}/stats"));
93+
return response.ValueKind == JsonValueKind.Undefined ? null : JsonSerializer.Deserialize<DiscogsStats>(response.GetRawText());
94+
}
95+
96+
public async Task<DiscogsRating?> GetCommunityRatingAsync(int releaseId)
97+
{
98+
JsonElement response = await ExecuteRequestWithRetryAsync(BuildRequest($"releases/{releaseId}/rating"));
99+
return response.ValueKind == JsonValueKind.Undefined ? null : JsonSerializer.Deserialize<DiscogsRating>(response.GetRawText());
100+
}
101+
102+
private HttpRequestBuilder BuildRequest(string? endpoint)
103+
{
104+
HttpRequestBuilder req = new HttpRequestBuilder(BaseUrl)
105+
.Resource(endpoint);
106+
if (!string.IsNullOrWhiteSpace(AuthToken))
107+
req.SetHeader("Authorization", $"Discogs token={AuthToken}");
108+
req.AllowAutoRedirect = true;
109+
req.SuppressHttpError = true;
110+
_logger.Trace($"Building request for endpoint: {endpoint}");
111+
return req;
112+
}
113+
114+
private async Task<JsonElement> ExecuteRequestWithRetryAsync(HttpRequestBuilder requestBuilder, int retryCount = 0)
115+
{
116+
try
117+
{
118+
await WaitForRateLimit();
119+
HttpRequest request = requestBuilder.Build();
120+
HttpResponse response = await _httpClient.GetAsync(request);
121+
UpdateRateLimitTracking(response);
122+
123+
if (response.StatusCode == HttpStatusCode.TooManyRequests)
124+
{
125+
if (retryCount >= MaxRetries)
126+
return default;
127+
128+
int delayMs = InitialRetryDelayMs * (int)Math.Pow(2, retryCount);
129+
_logger.Warn($"Rate limit exceeded. Retrying in {delayMs}ms...");
130+
await Task.Delay(delayMs);
131+
return await ExecuteRequestWithRetryAsync(requestBuilder, retryCount + 1);
132+
}
133+
134+
if (response.StatusCode != HttpStatusCode.OK)
135+
{
136+
HandleErrorResponse(response);
137+
return default;
138+
}
139+
140+
using JsonDocument jsonDoc = JsonDocument.Parse(response.Content);
141+
return jsonDoc.RootElement.Clone();
142+
}
143+
catch (HttpException ex)
144+
{
145+
_logger.Warn($"API Error: {ex.Message}");
146+
return default;
147+
}
148+
}
149+
150+
private async Task WaitForRateLimit()
151+
{
152+
if (_rateLimitRemaining <= 0)
153+
{
154+
TimeSpan timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime;
155+
TimeSpan timeToWait = TimeSpan.FromSeconds(60) - timeSinceLastRequest;
156+
if (timeToWait > TimeSpan.Zero)
157+
{
158+
_logger.Debug($"Rate limit reached. Waiting for {timeToWait.TotalSeconds} seconds...");
159+
await Task.Delay(timeToWait);
160+
}
161+
_rateLimitRemaining = _rateLimitTotal;
162+
_rateLimitUsed = 0;
163+
}
164+
}
165+
166+
private void UpdateRateLimitTracking(HttpResponse response)
167+
{
168+
string? totalHeader = response.Headers.Get("X-Discogs-Ratelimit");
169+
string? usedHeader = response.Headers.Get("X-Discogs-Ratelimit-Used");
170+
string? remainingHeader = response.Headers.Get("X-Discogs-Ratelimit-Remaining");
171+
172+
if (!string.IsNullOrEmpty(totalHeader) && int.TryParse(totalHeader, out int total))
173+
_rateLimitTotal = total;
174+
175+
if (!string.IsNullOrEmpty(usedHeader) && int.TryParse(usedHeader, out int used))
176+
_rateLimitUsed = used;
177+
178+
if (!string.IsNullOrEmpty(remainingHeader) && int.TryParse(remainingHeader, out int remaining))
179+
_rateLimitRemaining = remaining;
180+
181+
_lastRequestTime = DateTime.UtcNow;
182+
_logger.Trace($"Rate limit updated - Total: {_rateLimitTotal}, Used: {_rateLimitUsed}, Remaining: {_rateLimitRemaining}");
183+
}
184+
185+
private async Task<List<T>?> FetchPaginatedResultsAsync<T>(HttpRequestBuilder requestBuilder, int? maxPages, int? itemsPerPage)
186+
{
187+
List<T> results = new();
188+
int page = 1;
189+
bool hasNextPage = true;
190+
191+
while (hasNextPage)
192+
{
193+
HttpRequestBuilder pagedRequest = requestBuilder
194+
.AddQueryParam("page", page.ToString(), true)
195+
.AddQueryParam("per_page", itemsPerPage.ToString(), true);
196+
JsonElement response = await ExecuteRequestWithRetryAsync(pagedRequest);
197+
198+
if (response.TryGetProperty("results", out JsonElement resultsElement) || response.TryGetProperty("releases", out resultsElement))
199+
{
200+
List<T>? pageResults = JsonSerializer.Deserialize<List<T>>(resultsElement.GetRawText());
201+
if (pageResults != null)
202+
results.AddRange(pageResults);
203+
}
204+
else break;
205+
206+
hasNextPage = response.TryGetProperty("pagination", out JsonElement pagination) && pagination.TryGetProperty("pages", out JsonElement pages) && pages.TryGetInt32(out int pagesInt) && pagesInt > page;
207+
208+
if (maxPages.HasValue && page >= maxPages.Value) break;
209+
else if (page >= MaxPageLimit) break;
210+
211+
212+
page++;
213+
}
214+
215+
_logger.Trace($"Fetched {results.Count} results across {page} pages.");
216+
return results;
217+
}
218+
219+
private static void AddSearchParams(HttpRequestBuilder requestBuilder, DiscogsSearchParameter searchRequest)
220+
{
221+
foreach (KeyValuePair<string, string> param in searchRequest.ToDictionary())
222+
requestBuilder.AddQueryParam(param.Key, param.Value);
223+
}
224+
225+
private void HandleErrorResponse(HttpResponse response)
226+
{
227+
try
228+
{
229+
using JsonDocument jsonDoc = JsonDocument.Parse(response.Content);
230+
JsonElement root = jsonDoc.RootElement;
231+
string errorMessage = root.GetProperty("message").GetString() ?? $"API Error: {response.StatusCode}";
232+
_logger.Warn(errorMessage);
233+
}
234+
catch (Exception ex)
235+
{
236+
_logger.Error(ex, $"Failed to parse API error response. Status Code: {response.StatusCode}");
237+
}
238+
}
239+
240+
private HttpRequestBuilder AddQueryParamIfNotNull(HttpRequestBuilder request, string key, string? value) => value != null ? request.AddQueryParam(key, value) : request;
241+
242+
}
243+
}

0 commit comments

Comments
 (0)