< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.Plugins.MusicBrainz.MusicBrainzQueryExtensions
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs
Line coverage
44%
Covered lines: 35
Uncovered lines: 43
Coverable lines: 78
Total lines: 256
Line coverage: 44.8%
Branch coverage
66%
Covered branches: 20
Total branches: 30
Branch coverage: 66.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 10/29/2026 - 12:17:11 AM Line coverage: 44.8% (35/78) Branch coverage: 66.6% (20/30) Total lines: 256 10/29/2026 - 12:17:11 AM Line coverage: 44.8% (35/78) Branch coverage: 66.6% (20/30) Total lines: 256

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
ParseMusicBrainzId(...)0%2040%
LookupReleaseOrNullAsync(...)100%210%
LookupReleaseGroupOrNullAsync(...)100%210%
LookupArtistOrNullAsync(...)100%210%
FindArtistsWithRetryAsync(...)100%210%
FindReleasesWithRetryAsync(...)100%210%
RetryOnTransientErrorAsync()100%11100%
IsTransient(...)75%44100%
GetRetryDelay(...)87.5%8885.71%
GetServerHint(...)71.42%171475%
NotFoundAsNullAsync()100%210%

File(s)

/srv/git/jellyfin/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs

#LineLine coverage
 1using System;
 2using System.Net;
 3using System.Threading;
 4using System.Threading.Tasks;
 5using MetaBrainz.Common;
 6using MetaBrainz.MusicBrainz;
 7using MetaBrainz.MusicBrainz.Interfaces.Entities;
 8using MetaBrainz.MusicBrainz.Interfaces.Searches;
 9using Microsoft.Extensions.Logging;
 10
 11namespace MediaBrowser.Providers.Plugins.MusicBrainz;
 12
 13/// <summary>
 14/// Helpers for talking to MusicBrainz with identifiers that are not guaranteed to be valid.
 15/// </summary>
 16internal static class MusicBrainzQueryExtensions
 17{
 18    /// <summary>
 19    /// The number of extra attempts made when MusicBrainz reports a transient failure.
 20    /// </summary>
 21    private const int MaxRetries = 2;
 22
 123    private static readonly TimeSpan _minimumRetryDelay = TimeSpan.FromSeconds(1);
 24
 125    private static readonly TimeSpan _maximumRetryDelay = TimeSpan.FromSeconds(15);
 26
 27    /// <summary>
 28    /// Parses a MusicBrainz identifier, which may come from user-supplied tags or NFO files and is therefore not
 29    /// guaranteed to be a valid GUID.
 30    /// </summary>
 31    /// <param name="id">The identifier to parse.</param>
 32    /// <param name="entityType">The type of entity the identifier refers to, used for logging.</param>
 33    /// <param name="logger">The logger.</param>
 34    /// <returns>The parsed identifier, or <see langword="null"/> if it is missing or malformed.</returns>
 35    public static Guid? ParseMusicBrainzId(string? id, string entityType, ILogger logger)
 36    {
 037        if (string.IsNullOrWhiteSpace(id))
 38        {
 039            return null;
 40        }
 41
 042        if (!Guid.TryParse(id, out var parsedId))
 43        {
 044            logger.LogDebug("Ignoring malformed MusicBrainz {EntityType} id {Id}", entityType, id);
 045            return null;
 46        }
 47
 048        return parsedId;
 49    }
 50
 51    /// <summary>
 52    /// Looks up a release, treating an unknown identifier as missing data rather than an error.
 53    /// </summary>
 54    /// <param name="query">The MusicBrainz query client.</param>
 55    /// <param name="releaseId">The release identifier.</param>
 56    /// <param name="include">The additional data to include in the lookup.</param>
 57    /// <param name="logger">The logger.</param>
 58    /// <param name="cancellationToken">The cancellation token.</param>
 59    /// <returns>The release, or <see langword="null"/> if MusicBrainz does not have it.</returns>
 60    public static Task<IRelease?> LookupReleaseOrNullAsync(this Query query, Guid releaseId, Include include, ILogger lo
 061        => NotFoundAsNullAsync(
 062            token => query.LookupReleaseAsync(releaseId, include, token),
 063            "release",
 064            releaseId,
 065            logger,
 066            cancellationToken);
 67
 68    /// <summary>
 69    /// Looks up a release group, treating an unknown identifier as missing data rather than an error.
 70    /// </summary>
 71    /// <param name="query">The MusicBrainz query client.</param>
 72    /// <param name="releaseGroupId">The release group identifier.</param>
 73    /// <param name="include">The additional data to include in the lookup.</param>
 74    /// <param name="logger">The logger.</param>
 75    /// <param name="cancellationToken">The cancellation token.</param>
 76    /// <returns>The release group, or <see langword="null"/> if MusicBrainz does not have it.</returns>
 77    public static Task<IReleaseGroup?> LookupReleaseGroupOrNullAsync(this Query query, Guid releaseGroupId, Include incl
 078        => NotFoundAsNullAsync(
 079            token => query.LookupReleaseGroupAsync(releaseGroupId, include, null, token),
 080            "release group",
 081            releaseGroupId,
 082            logger,
 083            cancellationToken);
 84
 85    /// <summary>
 86    /// Looks up an artist, treating an unknown identifier as missing data rather than an error.
 87    /// </summary>
 88    /// <param name="query">The MusicBrainz query client.</param>
 89    /// <param name="artistId">The artist identifier.</param>
 90    /// <param name="include">The additional data to include in the lookup.</param>
 91    /// <param name="logger">The logger.</param>
 92    /// <param name="cancellationToken">The cancellation token.</param>
 93    /// <returns>The artist, or <see langword="null"/> if MusicBrainz does not have it.</returns>
 94    public static Task<IArtist?> LookupArtistOrNullAsync(this Query query, Guid artistId, Include include, ILogger logge
 095        => NotFoundAsNullAsync(
 096            token => query.LookupArtistAsync(artistId, include, null, null, token),
 097            "artist",
 098            artistId,
 099            logger,
 0100            cancellationToken);
 101
 102    /// <summary>
 103    /// Searches for artists, retrying when the MusicBrainz server is too busy to answer.
 104    /// </summary>
 105    /// <param name="query">The MusicBrainz query client.</param>
 106    /// <param name="searchQuery">The search query.</param>
 107    /// <param name="logger">The logger.</param>
 108    /// <param name="cancellationToken">The cancellation token.</param>
 109    /// <returns>The search results.</returns>
 110    public static Task<ISearchResults<ISearchResult<IArtist>>> FindArtistsWithRetryAsync(this Query query, string search
 0111        => RetryOnTransientErrorAsync(
 0112            token => query.FindArtistsAsync(searchQuery, null, null, false, token),
 0113            "artist search",
 0114            logger,
 0115            cancellationToken);
 116
 117    /// <summary>
 118    /// Searches for releases, retrying when the MusicBrainz server is too busy to answer.
 119    /// </summary>
 120    /// <param name="query">The MusicBrainz query client.</param>
 121    /// <param name="searchQuery">The search query.</param>
 122    /// <param name="logger">The logger.</param>
 123    /// <param name="cancellationToken">The cancellation token.</param>
 124    /// <returns>The search results.</returns>
 125    public static Task<ISearchResults<ISearchResult<IRelease>>> FindReleasesWithRetryAsync(this Query query, string sear
 0126        => RetryOnTransientErrorAsync(
 0127            token => query.FindReleasesAsync(searchQuery, null, null, false, token),
 0128            "release search",
 0129            logger,
 0130            cancellationToken);
 131
 132    /// <summary>
 133    /// Runs a request, retrying it when MusicBrainz reports a transient failure. MusicBrainz sheds load with
 134    /// HTTP 503 when its servers are busy, which is not specific to this client and succeeds when retried, so
 135    /// failing the whole refresh on the first one would leave items without metadata for no good reason.
 136    /// </summary>
 137    /// <typeparam name="T">The type of the request result.</typeparam>
 138    /// <param name="request">The request to run.</param>
 139    /// <param name="operation">The operation being performed, used for logging.</param>
 140    /// <param name="logger">The logger.</param>
 141    /// <param name="cancellationToken">The cancellation token.</param>
 142    /// <returns>The request result.</returns>
 143    internal static async Task<T> RetryOnTransientErrorAsync<T>(Func<CancellationToken, Task<T>> request, string operati
 144    {
 6145        for (var attempt = 1; ; attempt++)
 146        {
 147            try
 148            {
 6149                return await request(cancellationToken).ConfigureAwait(false);
 150            }
 5151            catch (HttpError ex) when (attempt <= MaxRetries && IsTransient(ex.Status))
 152            {
 3153                var delay = GetRetryDelay(ex, attempt);
 3154                logger.LogDebug(
 3155                    ex,
 3156                    "MusicBrainz {Operation} failed with {Status}, retrying in {Delay} (attempt {Attempt} of {Attempts})
 3157                    operation,
 3158                    ex.Status,
 3159                    delay,
 3160                    attempt,
 3161                    MaxRetries + 1);
 162
 3163                await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
 164            }
 165        }
 1166    }
 167
 168    /// <summary>
 169    /// Determines whether a response status is worth retrying. These are all cases of the server being unable to
 170    /// answer right now rather than of the request itself being wrong.
 171    /// </summary>
 172    /// <param name="status">The status returned by MusicBrainz.</param>
 173    /// <returns>Whether the request should be retried.</returns>
 174    private static bool IsTransient(HttpStatusCode status)
 4175        => status is HttpStatusCode.TooManyRequests
 4176            or HttpStatusCode.BadGateway
 4177            or HttpStatusCode.ServiceUnavailable
 4178            or HttpStatusCode.GatewayTimeout;
 179
 180    /// <summary>
 181    /// Works out how long to wait before retrying. MusicBrainz reports when its current rate limit window ends and
 182    /// retrying before then is documented to fail, so that hint wins over the exponential backoff when it is longer.
 183    /// </summary>
 184    /// <param name="error">The error returned by MusicBrainz.</param>
 185    /// <param name="attempt">The number of the attempt that just failed.</param>
 186    /// <returns>The time to wait before the next attempt.</returns>
 187    internal static TimeSpan GetRetryDelay(HttpError error, int attempt)
 188    {
 9189        var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
 9190        var hint = GetServerHint(error);
 9191        if (hint > delay)
 192        {
 3193            delay = hint.Value;
 194        }
 195
 9196        if (delay < _minimumRetryDelay)
 197        {
 0198            return _minimumRetryDelay;
 199        }
 200
 9201        return delay > _maximumRetryDelay ? _maximumRetryDelay : delay;
 202    }
 203
 204    private static TimeSpan? GetServerHint(HttpError error)
 205    {
 9206        var headers = error.ResponseHeaders;
 9207        if (headers is null)
 208        {
 0209            return null;
 210        }
 211
 9212        var retryAfter = headers.RetryAfter;
 9213        if (retryAfter?.Delta is { } delta)
 214        {
 6215            return delta;
 216        }
 217
 3218        if (retryAfter?.Date is { } date)
 219        {
 0220            return date - DateTimeOffset.UtcNow;
 221        }
 222
 3223        var rateLimit = new RateLimitInfo(headers);
 3224        if (rateLimit.ResetIn is { } resetIn)
 225        {
 0226            return TimeSpan.FromSeconds(resetIn);
 227        }
 228
 3229        return rateLimit.ResetAt - rateLimit.LastRequest;
 230    }
 231
 232    /// <summary>
 233    /// Runs a lookup, mapping a "not found" response to <see langword="null"/>. Identifiers stored on a library item
 234    /// can refer to entities that no longer exist in MusicBrainz, which is not an error worth failing a refresh over.
 235    /// </summary>
 236    /// <typeparam name="T">The type of entity being looked up.</typeparam>
 237    /// <param name="lookup">The lookup to run.</param>
 238    /// <param name="entityType">The type of entity being looked up, used for logging.</param>
 239    /// <param name="id">The identifier being looked up, used for logging.</param>
 240    /// <param name="logger">The logger.</param>
 241    /// <param name="cancellationToken">The cancellation token.</param>
 242    /// <returns>The entity, or <see langword="null"/> if MusicBrainz does not have it.</returns>
 243    private static async Task<T?> NotFoundAsNullAsync<T>(Func<CancellationToken, Task<T>> lookup, string entityType, Gui
 244        where T : class
 245    {
 246        try
 247        {
 0248            return await RetryOnTransientErrorAsync(lookup, entityType + " lookup", logger, cancellationToken).Configure
 249        }
 0250        catch (HttpError ex) when (ex.Status == HttpStatusCode.NotFound)
 251        {
 0252            logger.LogDebug("MusicBrainz has no {EntityType} with id {Id}", entityType, id);
 0253            return null;
 254        }
 0255    }
 256}