Skip to content

Commit

Permalink
Revert back to custom MusicBrainz client (#89)
Browse files Browse the repository at this point in the history
  • Loading branch information
lyarenei authored Feb 23, 2024
1 parent 7f3d475 commit c3948d9
Show file tree
Hide file tree
Showing 22 changed files with 270 additions and 155 deletions.
7 changes: 7 additions & 0 deletions Jellyfin.Plugin.ListenBrainz.sln
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrain
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrainz.Tests", "tests\Jellyfin.Plugin.ListenBrainz.Tests\Jellyfin.Plugin.ListenBrainz.Tests.csproj", "{773CDB41-E866-439A-A6F1-79308AF97AB8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrainz.Common", "src\Jellyfin.Plugin.ListenBrainz.Common\Jellyfin.Plugin.ListenBrainz.Common.csproj", "{9EB1DCAD-69EE-4AAF-813C-454FB8E87417}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -58,6 +60,10 @@ Global
{773CDB41-E866-439A-A6F1-79308AF97AB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{773CDB41-E866-439A-A6F1-79308AF97AB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{773CDB41-E866-439A-A6F1-79308AF97AB8}.Release|Any CPU.Build.0 = Release|Any CPU
{9EB1DCAD-69EE-4AAF-813C-454FB8E87417}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9EB1DCAD-69EE-4AAF-813C-454FB8E87417}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9EB1DCAD-69EE-4AAF-813C-454FB8E87417}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9EB1DCAD-69EE-4AAF-813C-454FB8E87417}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{33145D22-8C66-4C69-A409-3934840EAF4C} = {BCFEC1CA-66E1-462A-BFA8-00D61DCCD970}
Expand All @@ -68,5 +74,6 @@ Global
{7C6FAEC4-5D44-4035-8590-2FCDFF1ABC2B} = {99C564F4-BF8F-4555-AAB7-3A379C784CC4}
{5E3664B9-03C9-4357-86C5-B9B6E18C1098} = {BCFEC1CA-66E1-462A-BFA8-00D61DCCD970}
{773CDB41-E866-439A-A6F1-79308AF97AB8} = {99C564F4-BF8F-4555-AAB7-3A379C784CC4}
{9EB1DCAD-69EE-4AAF-813C-454FB8E87417} = {BCFEC1CA-66E1-462A-BFA8-00D61DCCD970}
EndGlobalSection
EndGlobal
14 changes: 6 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,13 @@ Once the SDK is installed, you should be able to compile the plugin in either de
Once the build is completed, the compiled DLLs should be available at:
`src/Jellyfin.Plugin.Listenbrainz/bin/<Debug|Release>/net6.0/`

To install the plugin for the first time, copy the following **DLL** files to the plugin directory in your
Jellyfin installation (`${CONFIG_DIR}/plugins/Jellyfin.Plugin.ListenBrainz`). Create the `Jellyfin.Plugin.ListenBrainz`
directory if it does not exist, and make sure Jellyfin has correct permissions to access it.
To install the plugin for the first time, copy all **DLL** files starting with `Jellyfin.Plugin.ListenBrainz` to the
plugin directory in your Jellyfin config directory (`${CONFIG_DIR}/plugins/Jellyfin.Plugin.ListenBrainz_1.0.0.0`).
Create the plugin directory if it does not exist, and make sure Jellyfin has correct permissions to access it.

- All files starting with `Jellyfin.Plugin.ListenBrainz`
- All files starting with `MetaBrainz`

After restarting Jellyfin, the plugin should be recognized and activated. If you forgot any of these files, then the
plugin will crash during initialization and in the log, you should see which DLL is missing.
Then copy all the DLL files and restart the Jellyfin server. After restarting Jellyfin, the plugin should be recognized
and activated. If you forgot any of these files, then the plugin will crash during initialization and in the log, you
should see which DLL is missing.

It is not necessary to copy all the files every time. For subsequent builds of the plugin it is enough to copy only the
recompiled files.
Expand Down
17 changes: 5 additions & 12 deletions build.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: "ListenBrainz"
guid: "59B20823-AAFE-454C-A393-17427F518631"
version: "3.4.0.1"
version: "3.4.1.0"
targetAbi: "10.8.0.0"
framework: "net6.0"
overview: "Track your music habits with ListenBrainz."
Expand All @@ -11,17 +11,10 @@ category: "General"
owner: "lyarenei"
artifacts:
- "Jellyfin.Plugin.ListenBrainz.dll"
- "Jellyfin.Plugin.ListenBrainz.Http.dll"
- "Jellyfin.Plugin.ListenBrainz.Api.dll"
- "MetaBrainz.Common.dll"
- "MetaBrainz.Common.Json.dll"
- "MetaBrainz.MusicBrainz.dll"
- "Jellyfin.Plugin.ListenBrainz.Common.dll"
- "Jellyfin.Plugin.ListenBrainz.Http.dll"
- "Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.dll"
changelog: >
New
- Option to synchronize favorite tracks to ListenBrainz immediately (#82 @lyarenei)
- Manual task for synchronizing loved listens from ListenBrainz (#80 @lyarenei)
Maintenance
- Refactor even processing; improve logging (#76 @lyarenei)
- Use logging scopes (#77 @lyarenei)
- Deprecate MusicBrainz custom client in favor of the recommended one (#78 @lyarenei)
- Various documentation updates (#79 @lyarenei)
- Revert back to custom MusicBrainz client to avoid dependency conflicts (#89 @lyarenei)
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace Jellyfin.Plugin.ListenBrainz.Common.Exceptions;

/// <summary>
/// Exception thrown when there's no data available.
/// </summary>
public class NoDataException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="NoDataException"/> class.
/// </summary>
public NoDataException()
{
}

/// <summary>
/// Initializes a new instance of the <see cref="NoDataException"/> class.
/// </summary>
/// <param name="msg">Exception message.</param>
public NoDataException(string msg) : base(msg)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="NoDataException"/> class.
/// </summary>
/// <param name="msg">Exception message.</param>
/// <param name="inner">Inner exception.</param>
public NoDataException(string msg, Exception inner) : base(msg, inner)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace Jellyfin.Plugin.ListenBrainz.Common.Exceptions;

/// <summary>
/// Exception thrown when a service is rate limited.
/// </summary>
public class RateLimitException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="RateLimitException"/> class.
/// </summary>
public RateLimitException()
{
}

/// <summary>
/// Initializes a new instance of the <see cref="RateLimitException"/> class.
/// </summary>
/// <param name="msg">Exception message.</param>
public RateLimitException(string msg) : base(msg)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="RateLimitException"/> class.
/// </summary>
/// <param name="msg">Exception message.</param>
/// <param name="inner">Inner exception.</param>
public RateLimitException(string msg, Exception inner) : base(msg, inner)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<CodeAnalysisRuleSet>../../code.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" />
<PackageReference Include="SmartAnalyzers.ExceptionAnalyzer" Version="1.0.10" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.507">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
23 changes: 23 additions & 0 deletions src/Jellyfin.Plugin.ListenBrainz.Common/LoggerExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.Extensions.Logging;

namespace Jellyfin.Plugin.ListenBrainz.Common;

/// <summary>
/// Extensions for <see cref="ILogger"/>.
/// </summary>
public static class LoggerExtensions
{
/// <summary>
/// Add a new scope for specified event ID.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="eventKey">Event key. Defaults to "EventId" if null.</param>
/// <param name="eventVal">Event value. Defaults to a value from <see cref="Utils.GetNewId"/> if null.</param>
/// <returns>Disposable logger scope.</returns>
public static IDisposable AddNewScope(this ILogger logger, string? eventKey = null, string? eventVal = null)
{
var key = eventKey ?? "EventId";
var val = eventVal ?? Utils.GetNewId();
return logger.BeginScope(new Dictionary<string, object> { { key, val } });
}
}
16 changes: 16 additions & 0 deletions src/Jellyfin.Plugin.ListenBrainz.Common/Utils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Jellyfin.Plugin.ListenBrainz.Common;

/// <summary>
/// Various functions which can be used across the project.
/// </summary>
public static class Utils
{
/// <summary>
/// Get a new ID. The ID is 7 characters long.
/// </summary>
/// <returns>New ID.</returns>
public static string GetNewId()
{
return Guid.NewGuid().ToString("N")[..7];
}
}
103 changes: 97 additions & 6 deletions src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/BaseClient.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Web;
using Jellyfin.Plugin.ListenBrainz.Common;
using Jellyfin.Plugin.ListenBrainz.Common.Exceptions;
using Jellyfin.Plugin.ListenBrainz.Http.Exceptions;
using Jellyfin.Plugin.ListenBrainz.Http.Interfaces;
using Jellyfin.Plugin.ListenBrainz.Http.Services;
using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Interfaces;
using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Json;
using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Resources;
Expand All @@ -15,7 +19,7 @@ namespace Jellyfin.Plugin.ListenBrainz.MusicBrainzApi;
/// <summary>
/// Base MusicBrainz API client.
/// </summary>
public class BaseClient : HttpClient
public class BaseClient : HttpClient, IDisposable
{
/// <summary>
/// Serializer options.
Expand All @@ -26,9 +30,15 @@ public class BaseClient : HttpClient
PropertyNamingPolicy = KebabCaseNamingPolicy.Instance
};

private const int RateLimitAttempts = 50;
private readonly string _clientName;
private readonly string _clientVersion;
private readonly string _contactUrl;
private readonly ILogger _logger;
private readonly SemaphoreSlim _rateLimiter;
private readonly ISleepService _sleepService;

private bool _isDisposed;

/// <summary>
/// Initializes a new instance of the <see cref="BaseClient"/> class.
Expand All @@ -50,6 +60,37 @@ protected BaseClient(
_clientName = clientName;
_clientVersion = clientVersion;
_contactUrl = contactUrl;
_logger = logger;
_rateLimiter = new SemaphoreSlim(1, 1);
_sleepService = sleepService ?? new DefaultSleepService();

_isDisposed = false;
}

/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

/// <summary>
/// Disposes managed and unmanaged (own) resources.
/// </summary>
/// <param name="disposing">Dispose managed resources.</param>
protected virtual void Dispose(bool disposing)
{
if (_isDisposed)
{
return;
}

if (disposing)
{
_rateLimiter.Dispose();
}

_isDisposed = true;
}

/// <summary>
Expand All @@ -60,7 +101,7 @@ protected BaseClient(
/// <typeparam name="TRequest">Data type of the request.</typeparam>
/// <typeparam name="TResponse">Data type of the response.</typeparam>
/// <returns>Request response. Null if error.</returns>
protected async Task<TResponse?> Get<TRequest, TResponse>(TRequest request, CancellationToken cancellationToken)
protected async Task<TResponse> Get<TRequest, TResponse>(TRequest request, CancellationToken cancellationToken)
where TRequest : IMusicBrainzRequest
where TResponse : IMusicBrainzResponse
{
Expand All @@ -82,15 +123,33 @@ protected BaseClient(
using (requestMessage) return await DoRequest<TResponse>(requestMessage, cancellationToken);
}

private Uri BuildRequestUri(string baseUrl, string endpoint) => new($"{baseUrl}/ws/{Api.Version}/{endpoint}");
private static Uri BuildRequestUri(string baseUrl, string endpoint) => new($"{baseUrl}/ws/{Api.Version}/{endpoint}");

private async Task<TResponse?> DoRequest<TResponse>(HttpRequestMessage requestMessage, CancellationToken cancellationToken)
private async Task<TResponse> DoRequest<TResponse>(HttpRequestMessage requestMessage, CancellationToken cancellationToken)
where TResponse : IMusicBrainzResponse
{
var response = await SendRequest(requestMessage, cancellationToken);
using var scope = _logger.AddNewScope("ClientRequestId");
HttpResponseMessage response;
_logger.LogDebug("Waiting for previous request to complete (if any)");
await _rateLimiter.WaitAsync(cancellationToken);
try
{
_logger.LogDebug("Sending request...");
response = await DoRequestWithRetry(requestMessage, cancellationToken);
}
finally
{
_logger.LogDebug("Request has been processed, freeing up resources");
_rateLimiter.Release();
}

var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
var result = await JsonSerializer.DeserializeAsync<TResponse>(responseStream, SerializerOptions, cancellationToken);
if (result is null) throw new InvalidResponseException("Response deserialized to NULL");
if (result is null)
{
throw new NoDataException("Response deserialized to NULL");
}

return result;
}

Expand All @@ -114,4 +173,36 @@ private static string ToMusicbrainzQuery(Dictionary<string, string> requestData)

return query;
}

private async Task<HttpResponseMessage> DoRequestWithRetry(HttpRequestMessage requestMessage, CancellationToken cancellationToken)
{
for (int i = 0; i < RateLimitAttempts; i++)
{
var response = await SendRequest(requestMessage, cancellationToken);

// MusicBrainz will return 503 if over rate limit
if (response.StatusCode == HttpStatusCode.ServiceUnavailable)
{
if (i + 1 == RateLimitAttempts)
{
throw new RateLimitException($"Could not fit into a rate limit window {RateLimitAttempts} times");
}

_logger.LogDebug("Rate limit reached, will retry after new window opens");
await HandleRateLimit();
continue;
}

_logger.LogDebug("Did not hit any rate limits, all OK");
return response;
}

throw new InvalidResponseException("No response available from MusicBrainz server");
}

private async Task HandleRateLimit()
{
// MusicBrainz documentation says the rate limit is on average 1 rps.
await _sleepService.SleepAsync(1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ public interface IMusicBrainzApiClient
/// <param name="request">Recording request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Recording response.</returns>
public Task<RecordingResponse?> GetRecording(RecordingRequest request, CancellationToken cancellationToken);
public Task<RecordingResponse> GetRecordingAsync(RecordingRequest request, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Jellyfin.Plugin.ListenBrainz.Common\Jellyfin.Plugin.ListenBrainz.Common.csproj" />
<ProjectReference Include="..\Jellyfin.Plugin.ListenBrainz.Http\Jellyfin.Plugin.ListenBrainz.Http.csproj" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public MusicBrainzApiClient(
}

/// <inheritdoc />
public async Task<RecordingResponse?> GetRecording(RecordingRequest request, CancellationToken cancellationToken)
public async Task<RecordingResponse> GetRecordingAsync(RecordingRequest request, CancellationToken cancellationToken)
{
return await Get<RecordingRequest, RecordingResponse>(request, cancellationToken);
}
Expand Down
Loading

0 comments on commit c3948d9

Please sign in to comment.