| | | 1 | | using System; |
| | | 2 | | using System.Net; |
| | | 3 | | using System.Threading; |
| | | 4 | | using System.Threading.Tasks; |
| | | 5 | | using MetaBrainz.Common; |
| | | 6 | | using MetaBrainz.MusicBrainz; |
| | | 7 | | using MetaBrainz.MusicBrainz.Interfaces.Entities; |
| | | 8 | | using MetaBrainz.MusicBrainz.Interfaces.Searches; |
| | | 9 | | using Microsoft.Extensions.Logging; |
| | | 10 | | |
| | | 11 | | namespace MediaBrowser.Providers.Plugins.MusicBrainz; |
| | | 12 | | |
| | | 13 | | /// <summary> |
| | | 14 | | /// Helpers for talking to MusicBrainz with identifiers that are not guaranteed to be valid. |
| | | 15 | | /// </summary> |
| | | 16 | | internal 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 | | |
| | 1 | 23 | | private static readonly TimeSpan _minimumRetryDelay = TimeSpan.FromSeconds(1); |
| | | 24 | | |
| | 1 | 25 | | 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 | | { |
| | 0 | 37 | | if (string.IsNullOrWhiteSpace(id)) |
| | | 38 | | { |
| | 0 | 39 | | return null; |
| | | 40 | | } |
| | | 41 | | |
| | 0 | 42 | | if (!Guid.TryParse(id, out var parsedId)) |
| | | 43 | | { |
| | 0 | 44 | | logger.LogDebug("Ignoring malformed MusicBrainz {EntityType} id {Id}", entityType, id); |
| | 0 | 45 | | return null; |
| | | 46 | | } |
| | | 47 | | |
| | 0 | 48 | | 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 |
| | 0 | 61 | | => NotFoundAsNullAsync( |
| | 0 | 62 | | token => query.LookupReleaseAsync(releaseId, include, token), |
| | 0 | 63 | | "release", |
| | 0 | 64 | | releaseId, |
| | 0 | 65 | | logger, |
| | 0 | 66 | | 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 |
| | 0 | 78 | | => NotFoundAsNullAsync( |
| | 0 | 79 | | token => query.LookupReleaseGroupAsync(releaseGroupId, include, null, token), |
| | 0 | 80 | | "release group", |
| | 0 | 81 | | releaseGroupId, |
| | 0 | 82 | | logger, |
| | 0 | 83 | | 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 |
| | 0 | 95 | | => NotFoundAsNullAsync( |
| | 0 | 96 | | token => query.LookupArtistAsync(artistId, include, null, null, token), |
| | 0 | 97 | | "artist", |
| | 0 | 98 | | artistId, |
| | 0 | 99 | | logger, |
| | 0 | 100 | | 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 |
| | 0 | 111 | | => RetryOnTransientErrorAsync( |
| | 0 | 112 | | token => query.FindArtistsAsync(searchQuery, null, null, false, token), |
| | 0 | 113 | | "artist search", |
| | 0 | 114 | | logger, |
| | 0 | 115 | | 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 |
| | 0 | 126 | | => RetryOnTransientErrorAsync( |
| | 0 | 127 | | token => query.FindReleasesAsync(searchQuery, null, null, false, token), |
| | 0 | 128 | | "release search", |
| | 0 | 129 | | logger, |
| | 0 | 130 | | 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 | | { |
| | 6 | 145 | | for (var attempt = 1; ; attempt++) |
| | | 146 | | { |
| | | 147 | | try |
| | | 148 | | { |
| | 6 | 149 | | return await request(cancellationToken).ConfigureAwait(false); |
| | | 150 | | } |
| | 5 | 151 | | catch (HttpError ex) when (attempt <= MaxRetries && IsTransient(ex.Status)) |
| | | 152 | | { |
| | 3 | 153 | | var delay = GetRetryDelay(ex, attempt); |
| | 3 | 154 | | logger.LogDebug( |
| | 3 | 155 | | ex, |
| | 3 | 156 | | "MusicBrainz {Operation} failed with {Status}, retrying in {Delay} (attempt {Attempt} of {Attempts}) |
| | 3 | 157 | | operation, |
| | 3 | 158 | | ex.Status, |
| | 3 | 159 | | delay, |
| | 3 | 160 | | attempt, |
| | 3 | 161 | | MaxRetries + 1); |
| | | 162 | | |
| | 3 | 163 | | await Task.Delay(delay, cancellationToken).ConfigureAwait(false); |
| | | 164 | | } |
| | | 165 | | } |
| | 1 | 166 | | } |
| | | 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) |
| | 4 | 175 | | => status is HttpStatusCode.TooManyRequests |
| | 4 | 176 | | or HttpStatusCode.BadGateway |
| | 4 | 177 | | or HttpStatusCode.ServiceUnavailable |
| | 4 | 178 | | 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 | | { |
| | 9 | 189 | | var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)); |
| | 9 | 190 | | var hint = GetServerHint(error); |
| | 9 | 191 | | if (hint > delay) |
| | | 192 | | { |
| | 3 | 193 | | delay = hint.Value; |
| | | 194 | | } |
| | | 195 | | |
| | 9 | 196 | | if (delay < _minimumRetryDelay) |
| | | 197 | | { |
| | 0 | 198 | | return _minimumRetryDelay; |
| | | 199 | | } |
| | | 200 | | |
| | 9 | 201 | | return delay > _maximumRetryDelay ? _maximumRetryDelay : delay; |
| | | 202 | | } |
| | | 203 | | |
| | | 204 | | private static TimeSpan? GetServerHint(HttpError error) |
| | | 205 | | { |
| | 9 | 206 | | var headers = error.ResponseHeaders; |
| | 9 | 207 | | if (headers is null) |
| | | 208 | | { |
| | 0 | 209 | | return null; |
| | | 210 | | } |
| | | 211 | | |
| | 9 | 212 | | var retryAfter = headers.RetryAfter; |
| | 9 | 213 | | if (retryAfter?.Delta is { } delta) |
| | | 214 | | { |
| | 6 | 215 | | return delta; |
| | | 216 | | } |
| | | 217 | | |
| | 3 | 218 | | if (retryAfter?.Date is { } date) |
| | | 219 | | { |
| | 0 | 220 | | return date - DateTimeOffset.UtcNow; |
| | | 221 | | } |
| | | 222 | | |
| | 3 | 223 | | var rateLimit = new RateLimitInfo(headers); |
| | 3 | 224 | | if (rateLimit.ResetIn is { } resetIn) |
| | | 225 | | { |
| | 0 | 226 | | return TimeSpan.FromSeconds(resetIn); |
| | | 227 | | } |
| | | 228 | | |
| | 3 | 229 | | 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 | | { |
| | 0 | 248 | | return await RetryOnTransientErrorAsync(lookup, entityType + " lookup", logger, cancellationToken).Configure |
| | | 249 | | } |
| | 0 | 250 | | catch (HttpError ex) when (ex.Status == HttpStatusCode.NotFound) |
| | | 251 | | { |
| | 0 | 252 | | logger.LogDebug("MusicBrainz has no {EntityType} with id {Id}", entityType, id); |
| | 0 | 253 | | return null; |
| | | 254 | | } |
| | 0 | 255 | | } |
| | | 256 | | } |