< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.Lyric.LyricManager
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/Lyric/LyricManager.cs
Line coverage
5%
Covered lines: 12
Uncovered lines: 198
Coverable lines: 210
Total lines: 476
Line coverage: 5.7%
Branch coverage
0%
Covered branches: 0
Total branches: 56
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/11/2026 - 12:15:59 AM Line coverage: 5.3% (11/207) Branch coverage: 0% (0/56) Total lines: 46810/29/2026 - 12:17:11 AM Line coverage: 5.7% (12/210) Branch coverage: 0% (0/56) Total lines: 476 5/11/2026 - 12:15:59 AM Line coverage: 5.3% (11/207) Branch coverage: 0% (0/56) Total lines: 46810/29/2026 - 12:17:11 AM Line coverage: 5.7% (12/210) Branch coverage: 0% (0/56) Total lines: 476

Coverage delta

Coverage delta 1 -1

Metrics

File(s)

/srv/git/jellyfin/MediaBrowser.Providers/Lyric/LyricManager.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.IO;
 5using System.Linq;
 6using System.Text;
 7using System.Threading;
 8using System.Threading.Tasks;
 9using Jellyfin.Extensions;
 10using MediaBrowser.Common.Extensions;
 11using MediaBrowser.Controller.Entities;
 12using MediaBrowser.Controller.Entities.Audio;
 13using MediaBrowser.Controller.Library;
 14using MediaBrowser.Controller.Lyrics;
 15using MediaBrowser.Controller.Persistence;
 16using MediaBrowser.Controller.Providers;
 17using MediaBrowser.Model.Configuration;
 18using MediaBrowser.Model.Entities;
 19using MediaBrowser.Model.IO;
 20using MediaBrowser.Model.Lyrics;
 21using MediaBrowser.Model.Providers;
 22using Microsoft.Extensions.Logging;
 23
 24namespace MediaBrowser.Providers.Lyric;
 25
 26/// <summary>
 27/// Lyric Manager.
 28/// </summary>
 29public 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    private readonly IDirectoryService _directoryService;
 36
 37    private readonly ILyricProvider[] _lyricProviders;
 38    private readonly ILyricParser[] _lyricParsers;
 39
 40    /// <summary>
 41    /// Initializes a new instance of the <see cref="LyricManager"/> class.
 42    /// </summary>
 43    /// <param name="logger">Instance of the <see cref="ILogger{LyricManager}"/> interface.</param>
 44    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
 45    /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
 46    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
 47    /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param>
 48    /// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param>
 49    /// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param>
 50    public LyricManager(
 51        ILogger<LyricManager> logger,
 52        IFileSystem fileSystem,
 53        ILibraryMonitor libraryMonitor,
 54        IMediaSourceManager mediaSourceManager,
 55        IDirectoryService directoryService,
 56        IEnumerable<ILyricProvider> lyricProviders,
 57        IEnumerable<ILyricParser> lyricParsers)
 58    {
 2759        _logger = logger;
 2760        _fileSystem = fileSystem;
 2761        _libraryMonitor = libraryMonitor;
 2762        _mediaSourceManager = mediaSourceManager;
 2763        _directoryService = directoryService;
 2764        _lyricProviders = lyricProviders
 2765            .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
 2766            .ToArray();
 2767        _lyricParsers = lyricParsers
 2768            .OrderBy(l => l.Priority)
 2769            .ToArray();
 2770    }
 71
 72    /// <inheritdoc />
 73    public event EventHandler<LyricDownloadFailureEventArgs>? LyricDownloadFailure;
 74
 75    /// <inheritdoc />
 76    public Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(Audio audio, bool isAutomated, CancellationToken ca
 77    {
 078        ArgumentNullException.ThrowIfNull(audio);
 79
 080        var request = new LyricSearchRequest
 081        {
 082            MediaPath = audio.Path,
 083            SongName = audio.Name,
 084            AlbumName = audio.Album,
 085            AlbumArtistsNames = audio.AlbumArtists,
 086            ArtistNames = audio.Artists,
 087            Duration = audio.RunTimeTicks,
 088            IsAutomated = isAutomated
 089        };
 90
 091        return SearchLyricsAsync(request, cancellationToken);
 92    }
 93
 94    /// <inheritdoc />
 95    public async Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(LyricSearchRequest request, CancellationToken
 96    {
 097        ArgumentNullException.ThrowIfNull(request);
 98
 099        var providers = _lyricProviders
 0100            .Where(i => !request.DisabledLyricFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase))
 0101            .OrderBy(i =>
 0102            {
 0103                var index = request.LyricFetcherOrder.IndexOf(i.Name);
 0104                return index == -1 ? int.MaxValue : index;
 0105            })
 0106            .ToArray();
 107
 108        // If not searching all, search one at a time until something is found
 0109        if (!request.SearchAllProviders)
 110        {
 0111            foreach (var provider in providers)
 112            {
 0113                var providerResult = await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAw
 0114                if (providerResult.Count > 0)
 115                {
 0116                    return providerResult;
 117                }
 118            }
 119
 0120            return [];
 121        }
 122
 0123        var tasks = providers.Select(async provider => await InternalSearchProviderAsync(provider, request, cancellation
 124
 0125        var results = await Task.WhenAll(tasks).ConfigureAwait(false);
 126
 0127        return results.SelectMany(i => i).ToArray();
 0128    }
 129
 130    /// <inheritdoc />
 131    public Task<LyricDto?> DownloadLyricsAsync(Audio audio, string lyricId, CancellationToken cancellationToken)
 132    {
 0133        ArgumentNullException.ThrowIfNull(audio);
 0134        ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
 135
 0136        var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
 137
 0138        return DownloadLyricsAsync(audio, libraryOptions, lyricId, cancellationToken);
 139    }
 140
 141    /// <inheritdoc />
 142    public async Task<LyricDto?> DownloadLyricsAsync(Audio audio, LibraryOptions libraryOptions, string lyricId, Cancell
 143    {
 0144        ArgumentNullException.ThrowIfNull(audio);
 0145        ArgumentNullException.ThrowIfNull(libraryOptions);
 0146        ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
 147
 0148        var provider = GetProvider(lyricId.AsSpan().LeftPart('_').ToString());
 0149        if (provider is null)
 150        {
 0151            return null;
 152        }
 153
 154        try
 155        {
 0156            var response = await InternalGetRemoteLyricsAsync(lyricId, cancellationToken).ConfigureAwait(false);
 0157            if (response is null)
 158            {
 0159                _logger.LogDebug("Unable to download lyrics for {LyricId}", lyricId);
 0160                return null;
 161            }
 162
 0163            var parsedLyrics = await InternalParseRemoteLyricsAsync(response.Format, response.Stream, cancellationToken)
 0164            if (parsedLyrics is null)
 165            {
 0166                return null;
 167            }
 168
 0169            await TrySaveLyric(audio, libraryOptions, response.Format, response.Stream).ConfigureAwait(false);
 0170            return parsedLyrics;
 171        }
 0172        catch (RateLimitExceededException)
 173        {
 0174            throw;
 175        }
 0176        catch (Exception ex)
 177        {
 0178            LyricDownloadFailure?.Invoke(this, new LyricDownloadFailureEventArgs
 0179            {
 0180                Item = audio,
 0181                Exception = ex,
 0182                Provider = provider.Name
 0183            });
 184
 0185            throw;
 186        }
 0187    }
 188
 189    /// <inheritdoc />
 190    public async Task<LyricDto?> SaveLyricAsync(Audio audio, string format, string lyrics)
 191    {
 0192        ArgumentNullException.ThrowIfNull(audio);
 0193        ArgumentException.ThrowIfNullOrEmpty(format);
 0194        ArgumentException.ThrowIfNullOrEmpty(lyrics);
 195
 0196        var bytes = Encoding.UTF8.GetBytes(lyrics);
 0197        using var lyricStream = new MemoryStream(bytes, 0, bytes.Length, false, true);
 0198        return await SaveLyricAsync(audio, format, lyricStream).ConfigureAwait(false);
 0199    }
 200
 201    /// <inheritdoc />
 202    public async Task<LyricDto?> SaveLyricAsync(Audio audio, string format, Stream lyrics)
 203    {
 0204        ArgumentNullException.ThrowIfNull(audio);
 0205        ArgumentException.ThrowIfNullOrEmpty(format);
 0206        ArgumentNullException.ThrowIfNull(lyrics);
 207
 0208        var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
 209
 0210        var parsed = await InternalParseRemoteLyricsAsync(format, lyrics, CancellationToken.None).ConfigureAwait(false);
 0211        if (parsed is null)
 212        {
 0213            return null;
 214        }
 215
 0216        await TrySaveLyric(audio, libraryOptions, format, lyrics).ConfigureAwait(false);
 0217        return parsed;
 0218    }
 219
 220    /// <inheritdoc />
 221    public async Task<LyricDto?> GetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
 222    {
 0223        ArgumentException.ThrowIfNullOrEmpty(id);
 224
 0225        var lyricResponse = await InternalGetRemoteLyricsAsync(id, cancellationToken).ConfigureAwait(false);
 0226        if (lyricResponse is null)
 227        {
 0228            return null;
 229        }
 230
 0231        return await InternalParseRemoteLyricsAsync(lyricResponse.Format, lyricResponse.Stream, cancellationToken).Confi
 0232    }
 233
 234    /// <inheritdoc />
 235    public Task DeleteLyricsAsync(Audio audio)
 236    {
 0237        ArgumentNullException.ThrowIfNull(audio);
 0238        var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
 0239        {
 0240            ItemId = audio.Id,
 0241            Type = MediaStreamType.Lyric
 0242        });
 243
 0244        foreach (var stream in streams)
 245        {
 0246            var path = stream.Path;
 0247            _libraryMonitor.ReportFileSystemChangeBeginning(path);
 248
 249            try
 250            {
 0251                _fileSystem.DeleteFile(path);
 0252            }
 253            finally
 254            {
 0255                _libraryMonitor.ReportFileSystemChangeComplete(path, false);
 0256            }
 257
 0258            _directoryService.Invalidate(path);
 259        }
 260
 0261        return audio.RefreshMetadata(CancellationToken.None);
 262    }
 263
 264    /// <inheritdoc />
 265    public IReadOnlyList<LyricProviderInfo> GetSupportedProviders(BaseItem item)
 266    {
 0267        if (item is not Audio)
 268        {
 0269            return [];
 270        }
 271
 0272        return _lyricProviders.Select(p => new LyricProviderInfo { Name = p.Name, Id = GetProviderId(p.Name) }).ToList()
 273    }
 274
 275    /// <inheritdoc />
 276    public async Task<LyricDto?> GetLyricsAsync(Audio audio, CancellationToken cancellationToken)
 277    {
 0278        ArgumentNullException.ThrowIfNull(audio);
 279
 0280        var lyricStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric);
 0281        foreach (var lyricStream in lyricStreams)
 282        {
 0283            var lyricContents = await File.ReadAllTextAsync(lyricStream.Path, Encoding.UTF8, cancellationToken).Configur
 284
 0285            var lyricFile = new LyricFile(Path.GetFileName(lyricStream.Path), lyricContents);
 0286            foreach (var parser in _lyricParsers)
 287            {
 0288                var parsedLyrics = parser.ParseLyrics(lyricFile);
 0289                if (parsedLyrics is not null)
 290                {
 0291                    return parsedLyrics;
 292                }
 293            }
 0294        }
 295
 0296        return null;
 0297    }
 298
 299    private ILyricProvider? GetProvider(string providerId)
 300    {
 0301        var provider = _lyricProviders.FirstOrDefault(p => string.Equals(providerId, GetProviderId(p.Name), StringCompar
 0302        if (provider is null)
 303        {
 0304            _logger.LogWarning("Unknown provider id: {ProviderId}", providerId.ReplaceLineEndings(string.Empty));
 305        }
 306
 0307        return provider;
 308    }
 309
 310    private string GetProviderId(string name)
 0311        => name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
 312
 313    private async Task<LyricDto?> InternalParseRemoteLyricsAsync(string format, Stream lyricStream, CancellationToken ca
 314    {
 0315        lyricStream.Seek(0, SeekOrigin.Begin);
 0316        using var streamReader = new StreamReader(lyricStream, leaveOpen: true);
 0317        var lyrics = await streamReader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 0318        var lyricFile = new LyricFile($"lyric.{format}", lyrics);
 0319        foreach (var parser in _lyricParsers)
 320        {
 0321            var parsedLyrics = parser.ParseLyrics(lyricFile);
 0322            if (parsedLyrics is not null)
 323            {
 0324                return parsedLyrics;
 325            }
 326        }
 327
 0328        return null;
 0329    }
 330
 331    private async Task<LyricResponse?> InternalGetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
 332    {
 0333        ArgumentException.ThrowIfNullOrWhiteSpace(id);
 0334        var parts = id.Split('_', 2);
 0335        var provider = GetProvider(parts[0]);
 0336        if (provider is null)
 337        {
 0338            return null;
 339        }
 340
 0341        id = parts[^1];
 342
 0343        return await provider.GetLyricsAsync(id, cancellationToken).ConfigureAwait(false);
 0344    }
 345
 346    private async Task<IReadOnlyList<RemoteLyricInfoDto>> InternalSearchProviderAsync(
 347        ILyricProvider provider,
 348        LyricSearchRequest request,
 349        CancellationToken cancellationToken)
 350    {
 351        try
 352        {
 0353            var providerId = GetProviderId(provider.Name);
 0354            var searchResults = await provider.SearchAsync(request, cancellationToken).ConfigureAwait(false);
 0355            var parsedResults = new List<RemoteLyricInfoDto>();
 0356            foreach (var result in searchResults)
 357            {
 0358                var parsedLyrics = await InternalParseRemoteLyricsAsync(result.Lyrics.Format, result.Lyrics.Stream, canc
 0359                if (parsedLyrics is null)
 360                {
 361                    continue;
 362                }
 363
 0364                parsedLyrics.Metadata = result.Metadata;
 0365                parsedResults.Add(new RemoteLyricInfoDto
 0366                {
 0367                    Id = $"{providerId}_{result.Id}",
 0368                    ProviderName = result.ProviderName,
 0369                    Lyrics = parsedLyrics
 0370                });
 0371            }
 372
 0373            return parsedResults;
 374        }
 0375        catch (Exception ex)
 376        {
 0377            _logger.LogError(ex, "Error downloading lyrics from {Provider}", provider.Name);
 0378            return [];
 379        }
 0380    }
 381
 382    private async Task TrySaveLyric(
 383        Audio audio,
 384        LibraryOptions libraryOptions,
 385        string format,
 386        Stream lyricStream)
 387    {
 0388        var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia;
 389
 0390        var memoryStream = new MemoryStream();
 0391        await using (memoryStream.ConfigureAwait(false))
 392        {
 0393            await using (lyricStream.ConfigureAwait(false))
 394            {
 0395                lyricStream.Seek(0, SeekOrigin.Begin);
 0396                await lyricStream.CopyToAsync(memoryStream).ConfigureAwait(false);
 0397                memoryStream.Seek(0, SeekOrigin.Begin);
 398            }
 399
 0400            var savePaths = new List<string>();
 0401            var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + format.ReplaceLineEndings(string.Emp
 402
 0403            if (saveInMediaFolder)
 404            {
 0405                var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
 406                // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
 0407                if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath))
 408                {
 0409                    savePaths.Add(mediaFolderPath);
 410                }
 411            }
 412
 0413            var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
 414
 415            // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
 0416            if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath))
 417            {
 0418                savePaths.Add(internalPath);
 419            }
 420
 0421            if (savePaths.Count > 0)
 422            {
 0423                await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false);
 424            }
 425            else
 426            {
 0427                _logger.LogError("An uploaded lyric could not be saved because the resulting paths were invalid.");
 428            }
 429        }
 0430    }
 431
 432    private async Task TrySaveToFiles(Stream stream, List<string> savePaths)
 433    {
 0434        List<Exception>? exs = null;
 435
 0436        foreach (var savePath in savePaths)
 437        {
 0438            _logger.LogInformation("Saving lyrics to {SavePath}", savePath.ReplaceLineEndings(string.Empty));
 439
 0440            _libraryMonitor.ReportFileSystemChangeBeginning(savePath);
 441
 442            try
 443            {
 0444                Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new InvalidOperationException("Path c
 445
 0446                var fileOptions = AsyncFile.WriteOptions;
 0447                fileOptions.Mode = FileMode.Create;
 0448                fileOptions.PreallocationSize = stream.Length;
 0449                var fs = new FileStream(savePath, fileOptions);
 0450                await using (fs.ConfigureAwait(false))
 451                {
 0452                    await stream.CopyToAsync(fs).ConfigureAwait(false);
 453                }
 454
 0455                _directoryService.Invalidate(savePath);
 456
 0457                return;
 458            }
 0459            catch (Exception ex)
 460            {
 0461                (exs ??= []).Add(ex);
 0462            }
 463            finally
 464            {
 0465                _libraryMonitor.ReportFileSystemChangeComplete(savePath, false);
 466            }
 467
 0468            stream.Position = 0;
 0469        }
 470
 0471        if (exs is not null)
 472        {
 0473            throw new AggregateException(exs);
 474        }
 0475    }
 476}