|   |  | 1 |  | using System; | 
|   |  | 2 |  | using System.Collections.Generic; | 
|   |  | 3 |  | using System.Globalization; | 
|   |  | 4 |  | using System.IO; | 
|   |  | 5 |  | using System.Linq; | 
|   |  | 6 |  | using System.Text; | 
|   |  | 7 |  | using System.Threading; | 
|   |  | 8 |  | using System.Threading.Tasks; | 
|   |  | 9 |  | using Jellyfin.Extensions; | 
|   |  | 10 |  | using MediaBrowser.Common.Extensions; | 
|   |  | 11 |  | using MediaBrowser.Controller.Entities; | 
|   |  | 12 |  | using MediaBrowser.Controller.Entities.Audio; | 
|   |  | 13 |  | using MediaBrowser.Controller.Library; | 
|   |  | 14 |  | using MediaBrowser.Controller.Lyrics; | 
|   |  | 15 |  | using MediaBrowser.Controller.Persistence; | 
|   |  | 16 |  | using MediaBrowser.Controller.Providers; | 
|   |  | 17 |  | using MediaBrowser.Model.Configuration; | 
|   |  | 18 |  | using MediaBrowser.Model.Entities; | 
|   |  | 19 |  | using MediaBrowser.Model.IO; | 
|   |  | 20 |  | using MediaBrowser.Model.Lyrics; | 
|   |  | 21 |  | using MediaBrowser.Model.Providers; | 
|   |  | 22 |  | using Microsoft.Extensions.Logging; | 
|   |  | 23 |  |  | 
|   |  | 24 |  | namespace MediaBrowser.Providers.Lyric; | 
|   |  | 25 |  |  | 
|   |  | 26 |  | /// <summary> | 
|   |  | 27 |  | /// Lyric Manager. | 
|   |  | 28 |  | /// </summary> | 
|   |  | 29 |  | public class LyricManager : ILyricManager | 
|   |  | 30 |  | { | 
|   |  | 31 |  |     private readonly ILogger<LyricManager> _logger; | 
|   |  | 32 |  |     private readonly IFileSystem _fileSystem; | 
|   |  | 33 |  |     private readonly ILibraryMonitor _libraryMonitor; | 
|   |  | 34 |  |     private readonly IMediaSourceManager _mediaSourceManager; | 
|   |  | 35 |  |  | 
|   |  | 36 |  |     private readonly ILyricProvider[] _lyricProviders; | 
|   |  | 37 |  |     private readonly ILyricParser[] _lyricParsers; | 
|   |  | 38 |  |  | 
|   |  | 39 |  |     /// <summary> | 
|   |  | 40 |  |     /// Initializes a new instance of the <see cref="LyricManager"/> class. | 
|   |  | 41 |  |     /// </summary> | 
|   |  | 42 |  |     /// <param name="logger">Instance of the <see cref="ILogger{LyricManager}"/> interface.</param> | 
|   |  | 43 |  |     /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> | 
|   |  | 44 |  |     /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param> | 
|   |  | 45 |  |     /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> | 
|   |  | 46 |  |     /// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param> | 
|   |  | 47 |  |     /// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param> | 
|   |  | 48 |  |     public LyricManager( | 
|   |  | 49 |  |         ILogger<LyricManager> logger, | 
|   |  | 50 |  |         IFileSystem fileSystem, | 
|   |  | 51 |  |         ILibraryMonitor libraryMonitor, | 
|   |  | 52 |  |         IMediaSourceManager mediaSourceManager, | 
|   |  | 53 |  |         IEnumerable<ILyricProvider> lyricProviders, | 
|   |  | 54 |  |         IEnumerable<ILyricParser> lyricParsers) | 
|   |  | 55 |  |     { | 
|   | 21 | 56 |  |         _logger = logger; | 
|   | 21 | 57 |  |         _fileSystem = fileSystem; | 
|   | 21 | 58 |  |         _libraryMonitor = libraryMonitor; | 
|   | 21 | 59 |  |         _mediaSourceManager = mediaSourceManager; | 
|   | 21 | 60 |  |         _lyricProviders = lyricProviders | 
|   | 21 | 61 |  |             .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) | 
|   | 21 | 62 |  |             .ToArray(); | 
|   | 21 | 63 |  |         _lyricParsers = lyricParsers | 
|   | 21 | 64 |  |             .OrderBy(l => l.Priority) | 
|   | 21 | 65 |  |             .ToArray(); | 
|   | 21 | 66 |  |     } | 
|   |  | 67 |  |  | 
|   |  | 68 |  |     /// <inheritdoc /> | 
|   |  | 69 |  |     public event EventHandler<LyricDownloadFailureEventArgs>? LyricDownloadFailure; | 
|   |  | 70 |  |  | 
|   |  | 71 |  |     /// <inheritdoc /> | 
|   |  | 72 |  |     public Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(Audio audio, bool isAutomated, CancellationToken ca | 
|   |  | 73 |  |     { | 
|   | 0 | 74 |  |         ArgumentNullException.ThrowIfNull(audio); | 
|   |  | 75 |  |  | 
|   | 0 | 76 |  |         var request = new LyricSearchRequest | 
|   | 0 | 77 |  |         { | 
|   | 0 | 78 |  |             MediaPath = audio.Path, | 
|   | 0 | 79 |  |             SongName = audio.Name, | 
|   | 0 | 80 |  |             AlbumName = audio.Album, | 
|   | 0 | 81 |  |             AlbumArtistsNames = audio.AlbumArtists, | 
|   | 0 | 82 |  |             ArtistNames = audio.Artists, | 
|   | 0 | 83 |  |             Duration = audio.RunTimeTicks, | 
|   | 0 | 84 |  |             IsAutomated = isAutomated | 
|   | 0 | 85 |  |         }; | 
|   |  | 86 |  |  | 
|   | 0 | 87 |  |         return SearchLyricsAsync(request, cancellationToken); | 
|   |  | 88 |  |     } | 
|   |  | 89 |  |  | 
|   |  | 90 |  |     /// <inheritdoc /> | 
|   |  | 91 |  |     public async Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(LyricSearchRequest request, CancellationToken | 
|   |  | 92 |  |     { | 
|   |  | 93 |  |         ArgumentNullException.ThrowIfNull(request); | 
|   |  | 94 |  |  | 
|   |  | 95 |  |         var providers = _lyricProviders | 
|   |  | 96 |  |             .Where(i => !request.DisabledLyricFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase)) | 
|   |  | 97 |  |             .OrderBy(i => | 
|   |  | 98 |  |             { | 
|   |  | 99 |  |                 var index = request.LyricFetcherOrder.IndexOf(i.Name); | 
|   |  | 100 |  |                 return index == -1 ? int.MaxValue : index; | 
|   |  | 101 |  |             }) | 
|   |  | 102 |  |             .ToArray(); | 
|   |  | 103 |  |  | 
|   |  | 104 |  |         // If not searching all, search one at a time until something is found | 
|   |  | 105 |  |         if (!request.SearchAllProviders) | 
|   |  | 106 |  |         { | 
|   |  | 107 |  |             foreach (var provider in providers) | 
|   |  | 108 |  |             { | 
|   |  | 109 |  |                 var providerResult = await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAw | 
|   |  | 110 |  |                 if (providerResult.Count > 0) | 
|   |  | 111 |  |                 { | 
|   |  | 112 |  |                     return providerResult; | 
|   |  | 113 |  |                 } | 
|   |  | 114 |  |             } | 
|   |  | 115 |  |  | 
|   |  | 116 |  |             return []; | 
|   |  | 117 |  |         } | 
|   |  | 118 |  |  | 
|   |  | 119 |  |         var tasks = providers.Select(async provider => await InternalSearchProviderAsync(provider, request, cancellation | 
|   |  | 120 |  |  | 
|   |  | 121 |  |         var results = await Task.WhenAll(tasks).ConfigureAwait(false); | 
|   |  | 122 |  |  | 
|   |  | 123 |  |         return results.SelectMany(i => i).ToArray(); | 
|   |  | 124 |  |     } | 
|   |  | 125 |  |  | 
|   |  | 126 |  |     /// <inheritdoc /> | 
|   |  | 127 |  |     public Task<LyricDto?> DownloadLyricsAsync(Audio audio, string lyricId, CancellationToken cancellationToken) | 
|   |  | 128 |  |     { | 
|   | 0 | 129 |  |         ArgumentNullException.ThrowIfNull(audio); | 
|   | 0 | 130 |  |         ArgumentException.ThrowIfNullOrWhiteSpace(lyricId); | 
|   |  | 131 |  |  | 
|   | 0 | 132 |  |         var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio); | 
|   |  | 133 |  |  | 
|   | 0 | 134 |  |         return DownloadLyricsAsync(audio, libraryOptions, lyricId, cancellationToken); | 
|   |  | 135 |  |     } | 
|   |  | 136 |  |  | 
|   |  | 137 |  |     /// <inheritdoc /> | 
|   |  | 138 |  |     public async Task<LyricDto?> DownloadLyricsAsync(Audio audio, LibraryOptions libraryOptions, string lyricId, Cancell | 
|   |  | 139 |  |     { | 
|   |  | 140 |  |         ArgumentNullException.ThrowIfNull(audio); | 
|   |  | 141 |  |         ArgumentNullException.ThrowIfNull(libraryOptions); | 
|   |  | 142 |  |         ArgumentException.ThrowIfNullOrWhiteSpace(lyricId); | 
|   |  | 143 |  |  | 
|   |  | 144 |  |         var provider = GetProvider(lyricId.AsSpan().LeftPart('_').ToString()); | 
|   |  | 145 |  |         if (provider is null) | 
|   |  | 146 |  |         { | 
|   |  | 147 |  |             return null; | 
|   |  | 148 |  |         } | 
|   |  | 149 |  |  | 
|   |  | 150 |  |         try | 
|   |  | 151 |  |         { | 
|   |  | 152 |  |             var response = await InternalGetRemoteLyricsAsync(lyricId, cancellationToken).ConfigureAwait(false); | 
|   |  | 153 |  |             if (response is null) | 
|   |  | 154 |  |             { | 
|   |  | 155 |  |                 _logger.LogDebug("Unable to download lyrics for {LyricId}", lyricId); | 
|   |  | 156 |  |                 return null; | 
|   |  | 157 |  |             } | 
|   |  | 158 |  |  | 
|   |  | 159 |  |             var parsedLyrics = await InternalParseRemoteLyricsAsync(response.Format, response.Stream, cancellationToken) | 
|   |  | 160 |  |             if (parsedLyrics is null) | 
|   |  | 161 |  |             { | 
|   |  | 162 |  |                 return null; | 
|   |  | 163 |  |             } | 
|   |  | 164 |  |  | 
|   |  | 165 |  |             await TrySaveLyric(audio, libraryOptions, response.Format, response.Stream).ConfigureAwait(false); | 
|   |  | 166 |  |             return parsedLyrics; | 
|   |  | 167 |  |         } | 
|   |  | 168 |  |         catch (RateLimitExceededException) | 
|   |  | 169 |  |         { | 
|   |  | 170 |  |             throw; | 
|   |  | 171 |  |         } | 
|   |  | 172 |  |         catch (Exception ex) | 
|   |  | 173 |  |         { | 
|   |  | 174 |  |             LyricDownloadFailure?.Invoke(this, new LyricDownloadFailureEventArgs | 
|   |  | 175 |  |             { | 
|   |  | 176 |  |                 Item = audio, | 
|   |  | 177 |  |                 Exception = ex, | 
|   |  | 178 |  |                 Provider = provider.Name | 
|   |  | 179 |  |             }); | 
|   |  | 180 |  |  | 
|   |  | 181 |  |             throw; | 
|   |  | 182 |  |         } | 
|   |  | 183 |  |     } | 
|   |  | 184 |  |  | 
|   |  | 185 |  |     /// <inheritdoc /> | 
|   |  | 186 |  |     public async Task<LyricDto?> SaveLyricAsync(Audio audio, string format, string lyrics) | 
|   |  | 187 |  |     { | 
|   |  | 188 |  |         ArgumentNullException.ThrowIfNull(audio); | 
|   |  | 189 |  |         ArgumentException.ThrowIfNullOrEmpty(format); | 
|   |  | 190 |  |         ArgumentException.ThrowIfNullOrEmpty(lyrics); | 
|   |  | 191 |  |  | 
|   |  | 192 |  |         var bytes = Encoding.UTF8.GetBytes(lyrics); | 
|   |  | 193 |  |         using var lyricStream = new MemoryStream(bytes, 0, bytes.Length, false, true); | 
|   |  | 194 |  |         return await SaveLyricAsync(audio, format, lyricStream).ConfigureAwait(false); | 
|   |  | 195 |  |     } | 
|   |  | 196 |  |  | 
|   |  | 197 |  |     /// <inheritdoc /> | 
|   |  | 198 |  |     public async Task<LyricDto?> SaveLyricAsync(Audio audio, string format, Stream lyrics) | 
|   |  | 199 |  |     { | 
|   |  | 200 |  |         ArgumentNullException.ThrowIfNull(audio); | 
|   |  | 201 |  |         ArgumentException.ThrowIfNullOrEmpty(format); | 
|   |  | 202 |  |         ArgumentNullException.ThrowIfNull(lyrics); | 
|   |  | 203 |  |  | 
|   |  | 204 |  |         var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio); | 
|   |  | 205 |  |  | 
|   |  | 206 |  |         var parsed = await InternalParseRemoteLyricsAsync(format, lyrics, CancellationToken.None).ConfigureAwait(false); | 
|   |  | 207 |  |         if (parsed is null) | 
|   |  | 208 |  |         { | 
|   |  | 209 |  |             return null; | 
|   |  | 210 |  |         } | 
|   |  | 211 |  |  | 
|   |  | 212 |  |         await TrySaveLyric(audio, libraryOptions, format, lyrics).ConfigureAwait(false); | 
|   |  | 213 |  |         return parsed; | 
|   |  | 214 |  |     } | 
|   |  | 215 |  |  | 
|   |  | 216 |  |     /// <inheritdoc /> | 
|   |  | 217 |  |     public async Task<LyricDto?> GetRemoteLyricsAsync(string id, CancellationToken cancellationToken) | 
|   |  | 218 |  |     { | 
|   |  | 219 |  |         ArgumentException.ThrowIfNullOrEmpty(id); | 
|   |  | 220 |  |  | 
|   |  | 221 |  |         var lyricResponse = await InternalGetRemoteLyricsAsync(id, cancellationToken).ConfigureAwait(false); | 
|   |  | 222 |  |         if (lyricResponse is null) | 
|   |  | 223 |  |         { | 
|   |  | 224 |  |             return null; | 
|   |  | 225 |  |         } | 
|   |  | 226 |  |  | 
|   |  | 227 |  |         return await InternalParseRemoteLyricsAsync(lyricResponse.Format, lyricResponse.Stream, cancellationToken).Confi | 
|   |  | 228 |  |     } | 
|   |  | 229 |  |  | 
|   |  | 230 |  |     /// <inheritdoc /> | 
|   |  | 231 |  |     public Task DeleteLyricsAsync(Audio audio) | 
|   |  | 232 |  |     { | 
|   | 0 | 233 |  |         ArgumentNullException.ThrowIfNull(audio); | 
|   | 0 | 234 |  |         var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery | 
|   | 0 | 235 |  |         { | 
|   | 0 | 236 |  |             ItemId = audio.Id, | 
|   | 0 | 237 |  |             Type = MediaStreamType.Lyric | 
|   | 0 | 238 |  |         }); | 
|   |  | 239 |  |  | 
|   | 0 | 240 |  |         foreach (var stream in streams) | 
|   |  | 241 |  |         { | 
|   | 0 | 242 |  |             var path = stream.Path; | 
|   | 0 | 243 |  |             _libraryMonitor.ReportFileSystemChangeBeginning(path); | 
|   |  | 244 |  |  | 
|   |  | 245 |  |             try | 
|   |  | 246 |  |             { | 
|   | 0 | 247 |  |                 _fileSystem.DeleteFile(path); | 
|   | 0 | 248 |  |             } | 
|   |  | 249 |  |             finally | 
|   |  | 250 |  |             { | 
|   | 0 | 251 |  |                 _libraryMonitor.ReportFileSystemChangeComplete(path, false); | 
|   | 0 | 252 |  |             } | 
|   |  | 253 |  |         } | 
|   |  | 254 |  |  | 
|   | 0 | 255 |  |         return audio.RefreshMetadata(CancellationToken.None); | 
|   |  | 256 |  |     } | 
|   |  | 257 |  |  | 
|   |  | 258 |  |     /// <inheritdoc /> | 
|   |  | 259 |  |     public IReadOnlyList<LyricProviderInfo> GetSupportedProviders(BaseItem item) | 
|   |  | 260 |  |     { | 
|   | 0 | 261 |  |         if (item is not Audio) | 
|   |  | 262 |  |         { | 
|   | 0 | 263 |  |             return []; | 
|   |  | 264 |  |         } | 
|   |  | 265 |  |  | 
|   | 0 | 266 |  |         return _lyricProviders.Select(p => new LyricProviderInfo { Name = p.Name, Id = GetProviderId(p.Name) }).ToList() | 
|   |  | 267 |  |     } | 
|   |  | 268 |  |  | 
|   |  | 269 |  |     /// <inheritdoc /> | 
|   |  | 270 |  |     public async Task<LyricDto?> GetLyricsAsync(Audio audio, CancellationToken cancellationToken) | 
|   |  | 271 |  |     { | 
|   |  | 272 |  |         ArgumentNullException.ThrowIfNull(audio); | 
|   |  | 273 |  |  | 
|   |  | 274 |  |         var lyricStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric); | 
|   |  | 275 |  |         foreach (var lyricStream in lyricStreams) | 
|   |  | 276 |  |         { | 
|   |  | 277 |  |             var lyricContents = await File.ReadAllTextAsync(lyricStream.Path, Encoding.UTF8, cancellationToken).Configur | 
|   |  | 278 |  |  | 
|   |  | 279 |  |             var lyricFile = new LyricFile(Path.GetFileName(lyricStream.Path), lyricContents); | 
|   |  | 280 |  |             foreach (var parser in _lyricParsers) | 
|   |  | 281 |  |             { | 
|   |  | 282 |  |                 var parsedLyrics = parser.ParseLyrics(lyricFile); | 
|   |  | 283 |  |                 if (parsedLyrics is not null) | 
|   |  | 284 |  |                 { | 
|   |  | 285 |  |                     return parsedLyrics; | 
|   |  | 286 |  |                 } | 
|   |  | 287 |  |             } | 
|   |  | 288 |  |         } | 
|   |  | 289 |  |  | 
|   |  | 290 |  |         return null; | 
|   |  | 291 |  |     } | 
|   |  | 292 |  |  | 
|   |  | 293 |  |     private ILyricProvider? GetProvider(string providerId) | 
|   |  | 294 |  |     { | 
|   | 0 | 295 |  |         var provider = _lyricProviders.FirstOrDefault(p => string.Equals(providerId, GetProviderId(p.Name), StringCompar | 
|   | 0 | 296 |  |         if (provider is null) | 
|   |  | 297 |  |         { | 
|   | 0 | 298 |  |             _logger.LogWarning("Unknown provider id: {ProviderId}", providerId.ReplaceLineEndings(string.Empty)); | 
|   |  | 299 |  |         } | 
|   |  | 300 |  |  | 
|   | 0 | 301 |  |         return provider; | 
|   |  | 302 |  |     } | 
|   |  | 303 |  |  | 
|   |  | 304 |  |     private string GetProviderId(string name) | 
|   | 0 | 305 |  |         => name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture); | 
|   |  | 306 |  |  | 
|   |  | 307 |  |     private async Task<LyricDto?> InternalParseRemoteLyricsAsync(string format, Stream lyricStream, CancellationToken ca | 
|   |  | 308 |  |     { | 
|   |  | 309 |  |         lyricStream.Seek(0, SeekOrigin.Begin); | 
|   |  | 310 |  |         using var streamReader = new StreamReader(lyricStream, leaveOpen: true); | 
|   |  | 311 |  |         var lyrics = await streamReader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); | 
|   |  | 312 |  |         var lyricFile = new LyricFile($"lyric.{format}", lyrics); | 
|   |  | 313 |  |         foreach (var parser in _lyricParsers) | 
|   |  | 314 |  |         { | 
|   |  | 315 |  |             var parsedLyrics = parser.ParseLyrics(lyricFile); | 
|   |  | 316 |  |             if (parsedLyrics is not null) | 
|   |  | 317 |  |             { | 
|   |  | 318 |  |                 return parsedLyrics; | 
|   |  | 319 |  |             } | 
|   |  | 320 |  |         } | 
|   |  | 321 |  |  | 
|   |  | 322 |  |         return null; | 
|   |  | 323 |  |     } | 
|   |  | 324 |  |  | 
|   |  | 325 |  |     private async Task<LyricResponse?> InternalGetRemoteLyricsAsync(string id, CancellationToken cancellationToken) | 
|   |  | 326 |  |     { | 
|   |  | 327 |  |         ArgumentException.ThrowIfNullOrWhiteSpace(id); | 
|   |  | 328 |  |         var parts = id.Split('_', 2); | 
|   |  | 329 |  |         var provider = GetProvider(parts[0]); | 
|   |  | 330 |  |         if (provider is null) | 
|   |  | 331 |  |         { | 
|   |  | 332 |  |             return null; | 
|   |  | 333 |  |         } | 
|   |  | 334 |  |  | 
|   |  | 335 |  |         id = parts[^1]; | 
|   |  | 336 |  |  | 
|   |  | 337 |  |         return await provider.GetLyricsAsync(id, cancellationToken).ConfigureAwait(false); | 
|   |  | 338 |  |     } | 
|   |  | 339 |  |  | 
|   |  | 340 |  |     private async Task<IReadOnlyList<RemoteLyricInfoDto>> InternalSearchProviderAsync( | 
|   |  | 341 |  |         ILyricProvider provider, | 
|   |  | 342 |  |         LyricSearchRequest request, | 
|   |  | 343 |  |         CancellationToken cancellationToken) | 
|   |  | 344 |  |     { | 
|   |  | 345 |  |         try | 
|   |  | 346 |  |         { | 
|   |  | 347 |  |             var providerId = GetProviderId(provider.Name); | 
|   |  | 348 |  |             var searchResults = await provider.SearchAsync(request, cancellationToken).ConfigureAwait(false); | 
|   |  | 349 |  |             var parsedResults = new List<RemoteLyricInfoDto>(); | 
|   |  | 350 |  |             foreach (var result in searchResults) | 
|   |  | 351 |  |             { | 
|   |  | 352 |  |                 var parsedLyrics = await InternalParseRemoteLyricsAsync(result.Lyrics.Format, result.Lyrics.Stream, canc | 
|   |  | 353 |  |                 if (parsedLyrics is null) | 
|   |  | 354 |  |                 { | 
|   |  | 355 |  |                     continue; | 
|   |  | 356 |  |                 } | 
|   |  | 357 |  |  | 
|   |  | 358 |  |                 parsedLyrics.Metadata = result.Metadata; | 
|   |  | 359 |  |                 parsedResults.Add(new RemoteLyricInfoDto | 
|   |  | 360 |  |                 { | 
|   |  | 361 |  |                     Id = $"{providerId}_{result.Id}", | 
|   |  | 362 |  |                     ProviderName = result.ProviderName, | 
|   |  | 363 |  |                     Lyrics = parsedLyrics | 
|   |  | 364 |  |                 }); | 
|   |  | 365 |  |             } | 
|   |  | 366 |  |  | 
|   |  | 367 |  |             return parsedResults; | 
|   |  | 368 |  |         } | 
|   |  | 369 |  |         catch (Exception ex) | 
|   |  | 370 |  |         { | 
|   |  | 371 |  |             _logger.LogError(ex, "Error downloading lyrics from {Provider}", provider.Name); | 
|   |  | 372 |  |             return []; | 
|   |  | 373 |  |         } | 
|   |  | 374 |  |     } | 
|   |  | 375 |  |  | 
|   |  | 376 |  |     private async Task TrySaveLyric( | 
|   |  | 377 |  |         Audio audio, | 
|   |  | 378 |  |         LibraryOptions libraryOptions, | 
|   |  | 379 |  |         string format, | 
|   |  | 380 |  |         Stream lyricStream) | 
|   |  | 381 |  |     { | 
|   |  | 382 |  |         var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia; | 
|   |  | 383 |  |  | 
|   |  | 384 |  |         var memoryStream = new MemoryStream(); | 
|   |  | 385 |  |         await using (memoryStream.ConfigureAwait(false)) | 
|   |  | 386 |  |         { | 
|   |  | 387 |  |             await using (lyricStream.ConfigureAwait(false)) | 
|   |  | 388 |  |             { | 
|   |  | 389 |  |                 lyricStream.Seek(0, SeekOrigin.Begin); | 
|   |  | 390 |  |                 await lyricStream.CopyToAsync(memoryStream).ConfigureAwait(false); | 
|   |  | 391 |  |                 memoryStream.Seek(0, SeekOrigin.Begin); | 
|   |  | 392 |  |             } | 
|   |  | 393 |  |  | 
|   |  | 394 |  |             var savePaths = new List<string>(); | 
|   |  | 395 |  |             var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + format.ReplaceLineEndings(string.Emp | 
|   |  | 396 |  |  | 
|   |  | 397 |  |             if (saveInMediaFolder) | 
|   |  | 398 |  |             { | 
|   |  | 399 |  |                 var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName)); | 
|   |  | 400 |  |                 // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path."); | 
|   |  | 401 |  |                 if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal)) | 
|   |  | 402 |  |                 { | 
|   |  | 403 |  |                     savePaths.Add(mediaFolderPath); | 
|   |  | 404 |  |                 } | 
|   |  | 405 |  |             } | 
|   |  | 406 |  |  | 
|   |  | 407 |  |             var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName)); | 
|   |  | 408 |  |  | 
|   |  | 409 |  |             // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path."); | 
|   |  | 410 |  |             if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal)) | 
|   |  | 411 |  |             { | 
|   |  | 412 |  |                 savePaths.Add(internalPath); | 
|   |  | 413 |  |             } | 
|   |  | 414 |  |  | 
|   |  | 415 |  |             if (savePaths.Count > 0) | 
|   |  | 416 |  |             { | 
|   |  | 417 |  |                 await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false); | 
|   |  | 418 |  |             } | 
|   |  | 419 |  |             else | 
|   |  | 420 |  |             { | 
|   |  | 421 |  |                 _logger.LogError("An uploaded lyric could not be saved because the resulting paths were invalid."); | 
|   |  | 422 |  |             } | 
|   |  | 423 |  |         } | 
|   |  | 424 |  |     } | 
|   |  | 425 |  |  | 
|   |  | 426 |  |     private async Task TrySaveToFiles(Stream stream, List<string> savePaths) | 
|   |  | 427 |  |     { | 
|   |  | 428 |  |         List<Exception>? exs = null; | 
|   |  | 429 |  |  | 
|   |  | 430 |  |         foreach (var savePath in savePaths) | 
|   |  | 431 |  |         { | 
|   |  | 432 |  |             _logger.LogInformation("Saving lyrics to {SavePath}", savePath.ReplaceLineEndings(string.Empty)); | 
|   |  | 433 |  |  | 
|   |  | 434 |  |             _libraryMonitor.ReportFileSystemChangeBeginning(savePath); | 
|   |  | 435 |  |  | 
|   |  | 436 |  |             try | 
|   |  | 437 |  |             { | 
|   |  | 438 |  |                 Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new InvalidOperationException("Path c | 
|   |  | 439 |  |  | 
|   |  | 440 |  |                 var fileOptions = AsyncFile.WriteOptions; | 
|   |  | 441 |  |                 fileOptions.Mode = FileMode.Create; | 
|   |  | 442 |  |                 fileOptions.PreallocationSize = stream.Length; | 
|   |  | 443 |  |                 var fs = new FileStream(savePath, fileOptions); | 
|   |  | 444 |  |                 await using (fs.ConfigureAwait(false)) | 
|   |  | 445 |  |                 { | 
|   |  | 446 |  |                     await stream.CopyToAsync(fs).ConfigureAwait(false); | 
|   |  | 447 |  |                 } | 
|   |  | 448 |  |  | 
|   |  | 449 |  |                 return; | 
|   |  | 450 |  |             } | 
|   |  | 451 |  |             catch (Exception ex) | 
|   |  | 452 |  |             { | 
|   |  | 453 |  |                 (exs ??= []).Add(ex); | 
|   |  | 454 |  |             } | 
|   |  | 455 |  |             finally | 
|   |  | 456 |  |             { | 
|   |  | 457 |  |                 _libraryMonitor.ReportFileSystemChangeComplete(savePath, false); | 
|   |  | 458 |  |             } | 
|   |  | 459 |  |  | 
|   |  | 460 |  |             stream.Position = 0; | 
|   |  | 461 |  |         } | 
|   |  | 462 |  |  | 
|   |  | 463 |  |         if (exs is not null) | 
|   |  | 464 |  |         { | 
|   |  | 465 |  |             throw new AggregateException(exs); | 
|   |  | 466 |  |         } | 
|   |  | 467 |  |     } | 
|   |  | 468 |  | } |