Browse Source

Store lyrics in the database as media streams (#9951)

Cody Robibero 1 year ago
parent
commit
0bc41c015f
49 changed files with 1478 additions and 259 deletions
  1. 12 0
      Emby.Naming/Common/NamingOptions.cs
  2. 2 1
      Emby.Naming/ExternalFiles/ExternalPathParser.cs
  3. 5 8
      Emby.Server.Implementations/Dto/DtoService.cs
  4. 13 0
      Emby.Server.Implementations/Library/LibraryManager.cs
  5. 19 6
      Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs
  6. 267 0
      Jellyfin.Api/Controllers/LyricsController.cs
  7. 22 16
      Jellyfin.Api/Controllers/SubtitleController.cs
  8. 1 44
      Jellyfin.Api/Controllers/UserLibraryController.cs
  9. 1 0
      Jellyfin.Data/Entities/User.cs
  10. 6 1
      Jellyfin.Data/Enums/PermissionKind.cs
  11. 101 0
      Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs
  12. 2 0
      Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs
  13. 1 0
      Jellyfin.Server.Implementations/Users/UserManager.cs
  14. 1 1
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  15. 5 0
      MediaBrowser.Common/Api/Policies.cs
  16. 12 0
      MediaBrowser.Controller/Entities/Audio/Audio.cs
  17. 9 0
      MediaBrowser.Controller/Library/ILibraryManager.cs
  18. 92 8
      MediaBrowser.Controller/Lyrics/ILyricManager.cs
  19. 2 2
      MediaBrowser.Controller/Lyrics/ILyricParser.cs
  20. 34 0
      MediaBrowser.Controller/Lyrics/ILyricProvider.cs
  21. 26 0
      MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs
  22. 5 0
      MediaBrowser.Model/Configuration/LibraryOptions.cs
  23. 2 1
      MediaBrowser.Model/Configuration/MetadataPluginType.cs
  24. 2 1
      MediaBrowser.Model/Dlna/DlnaProfileType.cs
  25. 6 1
      MediaBrowser.Model/Entities/MediaStreamType.cs
  26. 3 4
      MediaBrowser.Model/Lyrics/LyricDto.cs
  27. 1 1
      MediaBrowser.Model/Lyrics/LyricFile.cs
  28. 1 1
      MediaBrowser.Model/Lyrics/LyricLine.cs
  29. 6 1
      MediaBrowser.Model/Lyrics/LyricMetadata.cs
  30. 19 0
      MediaBrowser.Model/Lyrics/LyricResponse.cs
  31. 59 0
      MediaBrowser.Model/Lyrics/LyricSearchRequest.cs
  32. 22 0
      MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs
  33. 16 0
      MediaBrowser.Model/Lyrics/UploadLyricDto.cs
  34. 17 0
      MediaBrowser.Model/Providers/LyricProviderInfo.cs
  35. 29 0
      MediaBrowser.Model/Providers/RemoteLyricInfo.cs
  36. 6 0
      MediaBrowser.Model/Users/UserPolicy.cs
  37. 0 69
      MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs
  38. 0 36
      MediaBrowser.Providers/Lyric/ILyricProvider.cs
  39. 8 7
      MediaBrowser.Providers/Lyric/LrcLyricParser.cs
  40. 406 22
      MediaBrowser.Providers/Lyric/LyricManager.cs
  41. 6 5
      MediaBrowser.Providers/Lyric/TxtLyricParser.cs
  42. 15 3
      MediaBrowser.Providers/Manager/ProviderManager.cs
  43. 30 4
      MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
  44. 39 0
      MediaBrowser.Providers/MediaInfo/LyricResolver.cs
  45. 96 1
      MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
  46. 37 13
      MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
  47. 1 1
      MediaBrowser.Providers/Subtitles/SubtitleManager.cs
  48. 10 0
      src/Jellyfin.Extensions/StringExtensions.cs
  49. 3 1
      tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs

+ 12 - 0
Emby.Naming/Common/NamingOptions.cs

@@ -173,6 +173,13 @@ namespace Emby.Naming.Common
                 ".vtt",
             };
 
+            LyricFileExtensions = new[]
+            {
+                ".lrc",
+                ".elrc",
+                ".txt"
+            };
+
             AlbumStackingPrefixes = new[]
             {
                 "cd",
@@ -791,6 +798,11 @@ namespace Emby.Naming.Common
         /// </summary>
         public string[] SubtitleFileExtensions { get; set; }
 
+        /// <summary>
+        /// Gets the list of lyric file extensions.
+        /// </summary>
+        public string[] LyricFileExtensions { get; }
+
         /// <summary>
         /// Gets or sets list of episode regular expressions.
         /// </summary>

+ 2 - 1
Emby.Naming/ExternalFiles/ExternalPathParser.cs

@@ -45,7 +45,8 @@ namespace Emby.Naming.ExternalFiles
 
             var extension = Path.GetExtension(path.AsSpan());
             if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
-                && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
+                && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
+                && !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
             {
                 return null;
             }

+ 5 - 8
Emby.Server.Implementations/Dto/DtoService.cs

@@ -18,7 +18,6 @@ using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.LiveTv;
-using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Persistence;
 using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Controller.Providers;
@@ -53,7 +52,6 @@ namespace Emby.Server.Implementations.Dto
         private readonly IMediaSourceManager _mediaSourceManager;
         private readonly Lazy<ILiveTvManager> _livetvManagerFactory;
 
-        private readonly ILyricManager _lyricManager;
         private readonly ITrickplayManager _trickplayManager;
 
         public DtoService(
@@ -67,7 +65,6 @@ namespace Emby.Server.Implementations.Dto
             IApplicationHost appHost,
             IMediaSourceManager mediaSourceManager,
             Lazy<ILiveTvManager> livetvManagerFactory,
-            ILyricManager lyricManager,
             ITrickplayManager trickplayManager)
         {
             _logger = logger;
@@ -80,7 +77,6 @@ namespace Emby.Server.Implementations.Dto
             _appHost = appHost;
             _mediaSourceManager = mediaSourceManager;
             _livetvManagerFactory = livetvManagerFactory;
-            _lyricManager = lyricManager;
             _trickplayManager = trickplayManager;
         }
 
@@ -152,10 +148,6 @@ namespace Emby.Server.Implementations.Dto
             {
                 LivetvManager.AddInfoToProgramDto(new[] { (item, dto) }, options.Fields, user).GetAwaiter().GetResult();
             }
-            else if (item is Audio)
-            {
-                dto.HasLyrics = _lyricManager.HasLyricFile(item);
-            }
 
             if (item is IItemByName itemByName
                 && options.ContainsField(ItemFields.ItemCounts))
@@ -275,6 +267,11 @@ namespace Emby.Server.Implementations.Dto
                 LivetvManager.AddInfoToRecordingDto(item, dto, activeRecording, user);
             }
 
+            if (item is Audio audio)
+            {
+                dto.HasLyrics = audio.GetMediaStreams().Any(s => s.Type == MediaStreamType.Lyric);
+            }
+
             return dto;
         }
 

+ 13 - 0
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -1232,6 +1232,19 @@ namespace Emby.Server.Implementations.Library
             return item;
         }
 
+        /// <inheritdoc />
+        public T GetItemById<T>(Guid id)
+         where T : BaseItem
+        {
+            var item = GetItemById(id);
+            if (item is T typedItem)
+            {
+                return typedItem;
+            }
+
+            return null;
+        }
+
         public List<BaseItem> GetItemList(InternalItemsQuery query, bool allowExternalContent)
         {
             if (query.Recursive && !query.ParentId.IsEmpty())

+ 19 - 6
Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs

@@ -1,5 +1,6 @@
 using System.Threading.Tasks;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Extensions;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Library;
 using Microsoft.AspNetCore.Authorization;
@@ -25,15 +26,27 @@ namespace Jellyfin.Api.Auth.UserPermissionPolicy
         /// <inheritdoc />
         protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserPermissionRequirement requirement)
         {
-            var user = _userManager.GetUserById(context.User.GetUserId());
-            if (user is null)
+            // Api keys have global permissions, so just succeed the requirement.
+            if (context.User.GetIsApiKey())
             {
-                throw new ResourceNotFoundException();
+                context.Succeed(requirement);
             }
-
-            if (user.HasPermission(requirement.RequiredPermission))
+            else
             {
-                context.Succeed(requirement);
+                var userId = context.User.GetUserId();
+                if (!userId.IsEmpty())
+                {
+                    var user = _userManager.GetUserById(context.User.GetUserId());
+                    if (user is null)
+                    {
+                        throw new ResourceNotFoundException();
+                    }
+
+                    if (user.HasPermission(requirement.RequiredPermission))
+                    {
+                        context.Succeed(requirement);
+                    }
+                }
             }
 
             return Task.CompletedTask;

+ 267 - 0
Jellyfin.Api/Controllers/LyricsController.cs

@@ -0,0 +1,267 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.Net.Mime;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Attributes;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Extensions;
+using MediaBrowser.Common.Api;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Lyrics;
+using MediaBrowser.Model.Providers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Lyrics controller.
+/// </summary>
+[Route("")]
+public class LyricsController : BaseJellyfinApiController
+{
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILyricManager _lyricManager;
+    private readonly IProviderManager _providerManager;
+    private readonly IFileSystem _fileSystem;
+    private readonly IUserManager _userManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="LyricsController"/> class.
+    /// </summary>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
+    /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+    public LyricsController(
+        ILibraryManager libraryManager,
+        ILyricManager lyricManager,
+        IProviderManager providerManager,
+        IFileSystem fileSystem,
+        IUserManager userManager)
+    {
+        _libraryManager = libraryManager;
+        _lyricManager = lyricManager;
+        _providerManager = providerManager;
+        _fileSystem = fileSystem;
+        _userManager = userManager;
+    }
+
+    /// <summary>
+    /// Gets an item's lyrics.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Lyrics returned.</response>
+    /// <response code="404">Something went wrong. No Lyrics will be returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns>
+    [HttpGet("Audio/{itemId}/Lyrics")]
+    [Authorize]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public async Task<ActionResult<LyricDto>> GetLyrics([FromRoute, Required] Guid itemId)
+    {
+        var isApiKey = User.GetIsApiKey();
+        var userId = User.GetUserId();
+        if (!isApiKey && userId.IsEmpty())
+        {
+            return BadRequest();
+        }
+
+        var audio = _libraryManager.GetItemById<Audio>(itemId);
+        if (audio is null)
+        {
+            return NotFound();
+        }
+
+        if (!isApiKey)
+        {
+            var user = _userManager.GetUserById(userId);
+            if (user is null)
+            {
+                return NotFound();
+            }
+
+            // Check the item is visible for the user
+            if (!audio.IsVisible(user))
+            {
+                return Unauthorized($"{user.Username} is not permitted to access item {audio.Name}.");
+            }
+        }
+
+        var result = await _lyricManager.GetLyricsAsync(audio, CancellationToken.None).ConfigureAwait(false);
+        if (result is not null)
+        {
+            return Ok(result);
+        }
+
+        return NotFound();
+    }
+
+    /// <summary>
+    /// Upload an external lyric file.
+    /// </summary>
+    /// <param name="itemId">The item the lyric belongs to.</param>
+    /// <param name="fileName">Name of the file being uploaded.</param>
+    /// <response code="200">Lyrics uploaded.</response>
+    /// <response code="400">Error processing upload.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>The uploaded lyric.</returns>
+    [HttpPost("Audio/{itemId}/Lyrics")]
+    [Authorize(Policy = Policies.LyricManagement)]
+    [AcceptsFile(MediaTypeNames.Text.Plain)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<LyricDto>> UploadLyrics(
+        [FromRoute, Required] Guid itemId,
+        [FromQuery, Required] string fileName)
+    {
+        var audio = _libraryManager.GetItemById<Audio>(itemId);
+        if (audio is null)
+        {
+            return NotFound();
+        }
+
+        if (Request.ContentLength.GetValueOrDefault(0) == 0)
+        {
+            return BadRequest("No lyrics uploaded");
+        }
+
+        // Utilize Path.GetExtension as it provides extra path validation.
+        var format = Path.GetExtension(fileName.AsSpan()).RightPart('.').ToString();
+        if (string.IsNullOrEmpty(format))
+        {
+            return BadRequest("Extension is required on filename");
+        }
+
+        var stream = new MemoryStream();
+        await using (stream.ConfigureAwait(false))
+        {
+            await Request.Body.CopyToAsync(stream).ConfigureAwait(false);
+            var uploadedLyric = await _lyricManager.UploadLyricAsync(
+                audio,
+                new LyricResponse
+                {
+                    Format = format,
+                    Stream = stream
+                }).ConfigureAwait(false);
+
+            if (uploadedLyric is null)
+            {
+                return BadRequest();
+            }
+
+            _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+            return Ok(uploadedLyric);
+        }
+    }
+
+    /// <summary>
+    /// Deletes an external lyric file.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <response code="204">Lyric deleted.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Audio/{itemId}/Lyrics")]
+    [Authorize(Policy = Policies.LyricManagement)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult> DeleteLyrics(
+        [FromRoute, Required] Guid itemId)
+    {
+        var audio = _libraryManager.GetItemById<Audio>(itemId);
+        if (audio is null)
+        {
+            return NotFound();
+        }
+
+        await _lyricManager.DeleteLyricsAsync(audio).ConfigureAwait(false);
+        return NoContent();
+    }
+
+    /// <summary>
+    /// Search remote lyrics.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <response code="200">Lyrics retrieved.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>An array of <see cref="RemoteLyricInfo"/>.</returns>
+    [HttpGet("Audio/{itemId}/RemoteSearch/Lyrics")]
+    [Authorize(Policy = Policies.LyricManagement)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<IReadOnlyList<RemoteLyricInfoDto>>> SearchRemoteLyrics([FromRoute, Required] Guid itemId)
+    {
+        var audio = _libraryManager.GetItemById<Audio>(itemId);
+        if (audio is null)
+        {
+            return NotFound();
+        }
+
+        var results = await _lyricManager.SearchLyricsAsync(audio, false, CancellationToken.None).ConfigureAwait(false);
+        return Ok(results);
+    }
+
+    /// <summary>
+    /// Downloads a remote lyric.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="lyricId">The lyric id.</param>
+    /// <response code="200">Lyric downloaded.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Audio/{itemId}/RemoteSearch/Lyrics/{lyricId}")]
+    [Authorize(Policy = Policies.LyricManagement)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<LyricDto>> DownloadRemoteLyrics(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] string lyricId)
+    {
+        var audio = _libraryManager.GetItemById<Audio>(itemId);
+        if (audio is null)
+        {
+            return NotFound();
+        }
+
+        var downloadedLyrics = await _lyricManager.DownloadLyricsAsync(audio, lyricId, CancellationToken.None).ConfigureAwait(false);
+        if (downloadedLyrics is null)
+        {
+            return NotFound();
+        }
+
+        _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+        return Ok(downloadedLyrics);
+    }
+
+    /// <summary>
+    /// Gets the remote lyrics.
+    /// </summary>
+    /// <param name="lyricId">The remote provider item id.</param>
+    /// <response code="200">File returned.</response>
+    /// <response code="404">Lyric not found.</response>
+    /// <returns>A <see cref="FileStreamResult"/> with the lyric file.</returns>
+    [HttpGet("Providers/Lyrics/{lyricId}")]
+    [Authorize(Policy = Policies.LyricManagement)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public async Task<ActionResult<LyricDto>> GetRemoteLyrics([FromRoute, Required] string lyricId)
+    {
+        var result = await _lyricManager.GetRemoteLyricsAsync(lyricId, CancellationToken.None).ConfigureAwait(false);
+        if (result is null)
+        {
+            return NotFound();
+        }
+
+        return Ok(result);
+    }
+}

+ 22 - 16
Jellyfin.Api/Controllers/SubtitleController.cs

@@ -11,7 +11,6 @@ using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Models.SubtitleDtos;
 using MediaBrowser.Common.Api;
@@ -407,22 +406,29 @@ public class SubtitleController : BaseJellyfinApiController
         [FromBody, Required] UploadSubtitleDto body)
     {
         var video = (Video)_libraryManager.GetItemById(itemId);
-        var stream = new CryptoStream(Request.Body, new FromBase64Transform(), CryptoStreamMode.Read);
-        await using (stream.ConfigureAwait(false))
-        {
-            await _subtitleManager.UploadSubtitle(
-                video,
-                new SubtitleResponse
-                {
-                    Format = body.Format,
-                    Language = body.Language,
-                    IsForced = body.IsForced,
-                    IsHearingImpaired = body.IsHearingImpaired,
-                    Stream = stream
-                }).ConfigureAwait(false);
-            _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
 
-            return NoContent();
+        var bytes = Encoding.UTF8.GetBytes(body.Data);
+        var memoryStream = new MemoryStream(bytes, 0, bytes.Length, false, true);
+        await using (memoryStream.ConfigureAwait(false))
+        {
+            using var transform = new FromBase64Transform();
+            var stream = new CryptoStream(memoryStream, transform, CryptoStreamMode.Read);
+            await using (stream.ConfigureAwait(false))
+            {
+                await _subtitleManager.UploadSubtitle(
+                    video,
+                    new SubtitleResponse
+                    {
+                        Format = body.Format,
+                        Language = body.Language,
+                        IsForced = body.IsForced,
+                        IsHearingImpaired = body.IsHearingImpaired,
+                        Stream = stream
+                    }).ConfigureAwait(false);
+                _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+
+                return NoContent();
+            }
         }
     }
 

+ 1 - 44
Jellyfin.Api/Controllers/UserLibraryController.cs

@@ -18,6 +18,7 @@ using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Lyrics;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -539,48 +540,4 @@ public class UserLibraryController : BaseJellyfinApiController
 
         return _userDataRepository.GetUserDataDto(item, user);
     }
-
-    /// <summary>
-    /// Gets an item's lyrics.
-    /// </summary>
-    /// <param name="userId">User id.</param>
-    /// <param name="itemId">Item id.</param>
-    /// <response code="200">Lyrics returned.</response>
-    /// <response code="404">Something went wrong. No Lyrics will be returned.</response>
-    /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns>
-    [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")]
-    [ProducesResponseType(StatusCodes.Status200OK)]
-    public async Task<ActionResult<LyricResponse>> GetLyrics([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
-    {
-        var user = _userManager.GetUserById(userId);
-
-        if (user is null)
-        {
-            return NotFound();
-        }
-
-        var item = itemId.IsEmpty()
-            ? _libraryManager.GetUserRootFolder()
-            : _libraryManager.GetItemById(itemId);
-
-        if (item is null)
-        {
-            return NotFound();
-        }
-
-        if (item is not UserRootFolder
-            // Check the item is visible for the user
-            && !item.IsVisible(user))
-        {
-            return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
-        }
-
-        var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false);
-        if (result is not null)
-        {
-            return Ok(result);
-        }
-
-        return NotFound();
-    }
 }

+ 1 - 0
Jellyfin.Data/Entities/User.cs

@@ -506,6 +506,7 @@ namespace Jellyfin.Data.Entities
             Permissions.Add(new Permission(PermissionKind.EnableRemoteControlOfOtherUsers, false));
             Permissions.Add(new Permission(PermissionKind.EnableCollectionManagement, false));
             Permissions.Add(new Permission(PermissionKind.EnableSubtitleManagement, false));
+            Permissions.Add(new Permission(PermissionKind.EnableLyricManagement, false));
         }
 
         /// <summary>

+ 6 - 1
Jellyfin.Data/Enums/PermissionKind.cs

@@ -118,6 +118,11 @@ namespace Jellyfin.Data.Enums
         /// <summary>
         /// Whether the user can edit subtitles.
         /// </summary>
-        EnableSubtitleManagement = 22
+        EnableSubtitleManagement = 22,
+
+        /// <summary>
+        /// Whether the user can edit lyrics.
+        /// </summary>
+        EnableLyricManagement = 23,
     }
 }

+ 101 - 0
Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs

@@ -0,0 +1,101 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Lyrics;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+using Episode = MediaBrowser.Controller.Entities.TV.Episode;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Library;
+
+/// <summary>
+/// Creates an entry in the activity log whenever a lyric download fails.
+/// </summary>
+public class LyricDownloadFailureLogger : IEventConsumer<LyricDownloadFailureEventArgs>
+{
+    private readonly ILocalizationManager _localizationManager;
+    private readonly IActivityManager _activityManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="LyricDownloadFailureLogger"/> class.
+    /// </summary>
+    /// <param name="localizationManager">The localization manager.</param>
+    /// <param name="activityManager">The activity manager.</param>
+    public LyricDownloadFailureLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+    {
+        _localizationManager = localizationManager;
+        _activityManager = activityManager;
+    }
+
+    /// <inheritdoc />
+    public async Task OnEvent(LyricDownloadFailureEventArgs eventArgs)
+    {
+        await _activityManager.CreateAsync(new ActivityLog(
+            string.Format(
+                CultureInfo.InvariantCulture,
+                _localizationManager.GetLocalizedString("LyricDownloadFailureFromForItem"),
+                eventArgs.Provider,
+                GetItemName(eventArgs.Item)),
+            "LyricDownloadFailure",
+            Guid.Empty)
+        {
+            ItemId = eventArgs.Item.Id.ToString("N", CultureInfo.InvariantCulture),
+            ShortOverview = eventArgs.Exception.Message
+        }).ConfigureAwait(false);
+    }
+
+    private static string GetItemName(BaseItem item)
+    {
+        var name = item.Name;
+        if (item is Episode episode)
+        {
+            if (episode.IndexNumber.HasValue)
+            {
+                name = string.Format(
+                    CultureInfo.InvariantCulture,
+                    "Ep{0} - {1}",
+                    episode.IndexNumber.Value,
+                    name);
+            }
+
+            if (episode.ParentIndexNumber.HasValue)
+            {
+                name = string.Format(
+                    CultureInfo.InvariantCulture,
+                    "S{0}, {1}",
+                    episode.ParentIndexNumber.Value,
+                    name);
+            }
+        }
+
+        if (item is IHasSeries hasSeries)
+        {
+            name = hasSeries.SeriesName + " - " + name;
+        }
+
+        if (item is IHasAlbumArtist hasAlbumArtist)
+        {
+            var artists = hasAlbumArtist.AlbumArtists;
+
+            if (artists.Count > 0)
+            {
+                name = artists[0] + " - " + name;
+            }
+        }
+        else if (item is IHasArtist hasArtist)
+        {
+            var artists = hasArtist.Artists;
+
+            if (artists.Count > 0)
+            {
+                name = artists[0] + " - " + name;
+            }
+        }
+
+        return name;
+    }
+}

+ 2 - 0
Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs

@@ -12,6 +12,7 @@ using MediaBrowser.Controller.Events.Authentication;
 using MediaBrowser.Controller.Events.Session;
 using MediaBrowser.Controller.Events.Updates;
 using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Subtitles;
 using MediaBrowser.Model.Tasks;
 using Microsoft.Extensions.DependencyInjection;
@@ -30,6 +31,7 @@ namespace Jellyfin.Server.Implementations.Events
         public static void AddEventServices(this IServiceCollection collection)
         {
             // Library consumers
+            collection.AddScoped<IEventConsumer<LyricDownloadFailureEventArgs>, LyricDownloadFailureLogger>();
             collection.AddScoped<IEventConsumer<SubtitleDownloadFailureEventArgs>, SubtitleDownloadFailureLogger>();
 
             // Security consumers

+ 1 - 0
Jellyfin.Server.Implementations/Users/UserManager.cs

@@ -688,6 +688,7 @@ namespace Jellyfin.Server.Implementations.Users
                 user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
                 user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
                 user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement);
+                user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement);
                 user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
                 user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
 

+ 1 - 1
Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs

@@ -37,7 +37,6 @@ using Microsoft.OpenApi.Interfaces;
 using Microsoft.OpenApi.Models;
 using Swashbuckle.AspNetCore.SwaggerGen;
 using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
-using IPNetwork = System.Net.IPNetwork;
 
 namespace Jellyfin.Server.Extensions
 {
@@ -83,6 +82,7 @@ namespace Jellyfin.Server.Extensions
                 options.AddPolicy(Policies.SyncPlayJoinGroup, new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.JoinGroup));
                 options.AddPolicy(Policies.SyncPlayIsInGroup, new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.IsInGroup));
                 options.AddPolicy(Policies.SubtitleManagement, new UserPermissionRequirement(PermissionKind.EnableSubtitleManagement));
+                options.AddPolicy(Policies.LyricManagement, new UserPermissionRequirement(PermissionKind.EnableLyricManagement));
                 options.AddPolicy(
                     Policies.RequiresElevation,
                     policy => policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication)

+ 5 - 0
MediaBrowser.Common/Api/Policies.cs

@@ -89,4 +89,9 @@ public static class Policies
     /// Policy name for accessing subtitles management.
     /// </summary>
     public const string SubtitleManagement = "SubtitleManagement";
+
+    /// <summary>
+    /// Policy name for accessing lyric management.
+    /// </summary>
+    public const string LyricManagement = "LyricManagement";
 }

+ 12 - 0
MediaBrowser.Controller/Entities/Audio/Audio.cs

@@ -4,6 +4,7 @@
 
 using System;
 using System.Collections.Generic;
+using System.ComponentModel;
 using System.Globalization;
 using System.Linq;
 using System.Text.Json.Serialization;
@@ -27,6 +28,7 @@ namespace MediaBrowser.Controller.Entities.Audio
         {
             Artists = Array.Empty<string>();
             AlbumArtists = Array.Empty<string>();
+            LyricFiles = Array.Empty<string>();
         }
 
         /// <inheritdoc />
@@ -65,6 +67,16 @@ namespace MediaBrowser.Controller.Entities.Audio
         [JsonIgnore]
         public override MediaType MediaType => MediaType.Audio;
 
+        /// <summary>
+        /// Gets or sets a value indicating whether this audio has lyrics.
+        /// </summary>
+        public bool? HasLyrics { get; set; }
+
+        /// <summary>
+        /// Gets or sets the list of lyric paths.
+        /// </summary>
+        public IReadOnlyList<string> LyricFiles { get; set; }
+
         public override double GetDefaultPrimaryImageAspectRatio()
         {
             return 1;

+ 9 - 0
MediaBrowser.Controller/Library/ILibraryManager.cs

@@ -168,6 +168,15 @@ namespace MediaBrowser.Controller.Library
         /// <returns>BaseItem.</returns>
         BaseItem GetItemById(Guid id);
 
+        /// <summary>
+        /// Gets the item by id, as T.
+        /// </summary>
+        /// <param name="id">The item id.</param>
+        /// <typeparam name="T">The type of item.</typeparam>
+        /// <returns>The item.</returns>
+        T GetItemById<T>(Guid id)
+            where T : BaseItem;
+
         /// <summary>
         /// Gets the intros.
         /// </summary>

+ 92 - 8
MediaBrowser.Controller/Lyrics/ILyricManager.cs

@@ -1,5 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
 using System.Threading.Tasks;
 using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Lyrics;
+using MediaBrowser.Model.Providers;
 
 namespace MediaBrowser.Controller.Lyrics;
 
@@ -9,16 +16,93 @@ namespace MediaBrowser.Controller.Lyrics;
 public interface ILyricManager
 {
     /// <summary>
-    /// Gets the lyrics.
+    /// Occurs when a lyric download fails.
     /// </summary>
-    /// <param name="item">The media item.</param>
-    /// <returns>A task representing found lyrics the passed item.</returns>
-    Task<LyricResponse?> GetLyrics(BaseItem item);
+    event EventHandler<LyricDownloadFailureEventArgs> LyricDownloadFailure;
 
     /// <summary>
-    /// Checks if requested item has a matching local lyric file.
+    /// Search for lyrics for the specified song.
     /// </summary>
-    /// <param name="item">The media item.</param>
-    /// <returns>True if item has a matching lyric file; otherwise false.</returns>
-    bool HasLyricFile(BaseItem item);
+    /// <param name="audio">The song.</param>
+    /// <param name="isAutomated">Whether the request is automated.</param>
+    /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+    /// <returns>The list of lyrics.</returns>
+    Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(
+        Audio audio,
+        bool isAutomated,
+        CancellationToken cancellationToken);
+
+    /// <summary>
+    /// Search for lyrics.
+    /// </summary>
+    /// <param name="request">The search request.</param>
+    /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+    /// <returns>The list of lyrics.</returns>
+    Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(
+        LyricSearchRequest request,
+        CancellationToken cancellationToken);
+
+    /// <summary>
+    /// Download the lyrics.
+    /// </summary>
+    /// <param name="audio">The audio.</param>
+    /// <param name="lyricId">The remote lyric id.</param>
+    /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+    /// <returns>The downloaded lyrics.</returns>
+    Task<LyricDto?> DownloadLyricsAsync(
+        Audio audio,
+        string lyricId,
+        CancellationToken cancellationToken);
+
+    /// <summary>
+    /// Download the lyrics.
+    /// </summary>
+    /// <param name="audio">The audio.</param>
+    /// <param name="libraryOptions">The library options to use.</param>
+    /// <param name="lyricId">The remote lyric id.</param>
+    /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+    /// <returns>The downloaded lyrics.</returns>
+    Task<LyricDto?> DownloadLyricsAsync(
+        Audio audio,
+        LibraryOptions libraryOptions,
+        string lyricId,
+        CancellationToken cancellationToken);
+
+    /// <summary>
+    /// Upload new lyrics.
+    /// </summary>
+    /// <param name="audio">The audio file the lyrics belong to.</param>
+    /// <param name="lyricResponse">The lyric response.</param>
+    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+    Task<LyricDto?> UploadLyricAsync(Audio audio, LyricResponse lyricResponse);
+
+    /// <summary>
+    /// Get the remote lyrics.
+    /// </summary>
+    /// <param name="id">The remote lyrics id.</param>
+    /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+    /// <returns>The lyric response.</returns>
+    Task<LyricDto?> GetRemoteLyricsAsync(string id, CancellationToken cancellationToken);
+
+    /// <summary>
+    /// Deletes the lyrics.
+    /// </summary>
+    /// <param name="audio">The audio file to remove lyrics from.</param>
+    /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+    Task DeleteLyricsAsync(Audio audio);
+
+    /// <summary>
+    /// Get the list of lyric providers.
+    /// </summary>
+    /// <param name="item">The item.</param>
+    /// <returns>Lyric providers.</returns>
+    IReadOnlyList<LyricProviderInfo> GetSupportedProviders(BaseItem item);
+
+    /// <summary>
+    /// Get the existing lyric for the audio.
+    /// </summary>
+    /// <param name="audio">The audio item.</param>
+    /// <param name="cancellationToken">The cancellation token.</param>
+    /// <returns>The parsed lyric model.</returns>
+    Task<LyricDto?> GetLyricsAsync(Audio audio, CancellationToken cancellationToken);
 }

+ 2 - 2
MediaBrowser.Controller/Lyrics/ILyricParser.cs

@@ -1,5 +1,5 @@
 using MediaBrowser.Controller.Resolvers;
-using MediaBrowser.Providers.Lyric;
+using MediaBrowser.Model.Lyrics;
 
 namespace MediaBrowser.Controller.Lyrics;
 
@@ -24,5 +24,5 @@ public interface ILyricParser
     /// </summary>
     /// <param name="lyrics">The raw lyrics content.</param>
     /// <returns>The parsed lyrics or null if invalid.</returns>
-    LyricResponse? ParseLyrics(LyricFile lyrics);
+    LyricDto? ParseLyrics(LyricFile lyrics);
 }

+ 34 - 0
MediaBrowser.Controller/Lyrics/ILyricProvider.cs

@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Lyrics;
+using MediaBrowser.Model.Providers;
+
+namespace MediaBrowser.Controller.Lyrics;
+
+/// <summary>
+/// Interface ILyricsProvider.
+/// </summary>
+public interface ILyricProvider
+{
+    /// <summary>
+    /// Gets the provider name.
+    /// </summary>
+    string Name { get; }
+
+    /// <summary>
+    /// Search for lyrics.
+    /// </summary>
+    /// <param name="request">The search request.</param>
+    /// <param name="cancellationToken">The cancellation token.</param>
+    /// <returns>The list of remote lyrics.</returns>
+    Task<IEnumerable<RemoteLyricInfo>> SearchAsync(LyricSearchRequest request, CancellationToken cancellationToken);
+
+    /// <summary>
+    /// Get the lyrics.
+    /// </summary>
+    /// <param name="id">The remote lyric id.</param>
+    /// <param name="cancellationToken">The cancellation token.</param>
+    /// <returns>The lyric response.</returns>
+    Task<LyricResponse?> GetLyricsAsync(string id, CancellationToken cancellationToken);
+}

+ 26 - 0
MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs

@@ -0,0 +1,26 @@
+using System;
+using MediaBrowser.Controller.Entities;
+
+namespace MediaBrowser.Controller.Lyrics
+{
+    /// <summary>
+    /// An event that occurs when subtitle downloading fails.
+    /// </summary>
+    public class LyricDownloadFailureEventArgs : EventArgs
+    {
+        /// <summary>
+        /// Gets or sets the item.
+        /// </summary>
+        public required BaseItem Item { get; set; }
+
+        /// <summary>
+        /// Gets or sets the provider.
+        /// </summary>
+        public required string Provider { get; set; }
+
+        /// <summary>
+        /// Gets or sets the exception.
+        /// </summary>
+        public required Exception Exception { get; set; }
+    }
+}

+ 5 - 0
MediaBrowser.Model/Configuration/LibraryOptions.cs

@@ -1,6 +1,7 @@
 #pragma warning disable CS1591
 
 using System;
+using System.ComponentModel;
 
 namespace MediaBrowser.Model.Configuration
 {
@@ -20,6 +21,7 @@ namespace MediaBrowser.Model.Configuration
             AutomaticallyAddToCollection = false;
             EnablePhotos = true;
             SaveSubtitlesWithMedia = true;
+            SaveLyricsWithMedia = true;
             PathInfos = Array.Empty<MediaPathInfo>();
             EnableAutomaticSeriesGrouping = true;
             SeasonZeroDisplayName = "Specials";
@@ -92,6 +94,9 @@ namespace MediaBrowser.Model.Configuration
 
         public bool SaveSubtitlesWithMedia { get; set; }
 
+        [DefaultValue(true)]
+        public bool SaveLyricsWithMedia { get; set; }
+
         public bool AutomaticallyAddToCollection { get; set; }
 
         public EmbeddedSubtitleOptions AllowEmbeddedSubtitles { get; set; }

+ 2 - 1
MediaBrowser.Model/Configuration/MetadataPluginType.cs

@@ -13,6 +13,7 @@ namespace MediaBrowser.Model.Configuration
         LocalMetadataProvider,
         MetadataFetcher,
         MetadataSaver,
-        SubtitleFetcher
+        SubtitleFetcher,
+        LyricFetcher
     }
 }

+ 2 - 1
MediaBrowser.Model/Dlna/DlnaProfileType.cs

@@ -7,6 +7,7 @@ namespace MediaBrowser.Model.Dlna
         Audio = 0,
         Video = 1,
         Photo = 2,
-        Subtitle = 3
+        Subtitle = 3,
+        Lyric = 4
     }
 }

+ 6 - 1
MediaBrowser.Model/Entities/MediaStreamType.cs

@@ -28,6 +28,11 @@ namespace MediaBrowser.Model.Entities
         /// <summary>
         /// The data.
         /// </summary>
-        Data
+        Data,
+
+        /// <summary>
+        /// The lyric.
+        /// </summary>
+        Lyric
     }
 }

+ 3 - 4
MediaBrowser.Controller/Lyrics/LyricResponse.cs → MediaBrowser.Model/Lyrics/LyricDto.cs

@@ -1,12 +1,11 @@
-using System;
 using System.Collections.Generic;
 
-namespace MediaBrowser.Controller.Lyrics;
+namespace MediaBrowser.Model.Lyrics;
 
 /// <summary>
 /// LyricResponse model.
 /// </summary>
-public class LyricResponse
+public class LyricDto
 {
     /// <summary>
     /// Gets or sets Metadata for the lyrics.
@@ -16,5 +15,5 @@ public class LyricResponse
     /// <summary>
     /// Gets or sets a collection of individual lyric lines.
     /// </summary>
-    public IReadOnlyList<LyricLine> Lyrics { get; set; } = Array.Empty<LyricLine>();
+    public IReadOnlyList<LyricLine> Lyrics { get; set; } = [];
 }

+ 1 - 1
MediaBrowser.Controller/Lyrics/LyricFile.cs → MediaBrowser.Model/Lyrics/LyricFile.cs

@@ -1,4 +1,4 @@
-namespace MediaBrowser.Providers.Lyric;
+namespace MediaBrowser.Model.Lyrics;
 
 /// <summary>
 /// The information for a raw lyrics file before parsing.

+ 1 - 1
MediaBrowser.Controller/Lyrics/LyricLine.cs → MediaBrowser.Model/Lyrics/LyricLine.cs

@@ -1,4 +1,4 @@
-namespace MediaBrowser.Controller.Lyrics;
+namespace MediaBrowser.Model.Lyrics;
 
 /// <summary>
 /// Lyric model.

+ 6 - 1
MediaBrowser.Controller/Lyrics/LyricMetadata.cs → MediaBrowser.Model/Lyrics/LyricMetadata.cs

@@ -1,4 +1,4 @@
-namespace MediaBrowser.Controller.Lyrics;
+namespace MediaBrowser.Model.Lyrics;
 
 /// <summary>
 /// LyricMetadata model.
@@ -49,4 +49,9 @@ public class LyricMetadata
     /// Gets or sets the version of the creator used.
     /// </summary>
     public string? Version { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether this lyric is synced.
+    /// </summary>
+    public bool? IsSynced { get; set; }
 }

+ 19 - 0
MediaBrowser.Model/Lyrics/LyricResponse.cs

@@ -0,0 +1,19 @@
+using System.IO;
+
+namespace MediaBrowser.Model.Lyrics;
+
+/// <summary>
+/// LyricResponse model.
+/// </summary>
+public class LyricResponse
+{
+    /// <summary>
+    /// Gets or sets the lyric stream.
+    /// </summary>
+    public required Stream Stream { get; set; }
+
+    /// <summary>
+    /// Gets or sets the lyric format.
+    /// </summary>
+    public required string Format { get; set; }
+}

+ 59 - 0
MediaBrowser.Model/Lyrics/LyricSearchRequest.cs

@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Model.Entities;
+
+namespace MediaBrowser.Model.Lyrics;
+
+/// <summary>
+/// Lyric search request.
+/// </summary>
+public class LyricSearchRequest : IHasProviderIds
+{
+    /// <summary>
+    /// Gets or sets the media path.
+    /// </summary>
+    public string? MediaPath { get; set; }
+
+    /// <summary>
+    /// Gets or sets the artist name.
+    /// </summary>
+    public IReadOnlyList<string>? ArtistNames { get; set; }
+
+    /// <summary>
+    /// Gets or sets the album name.
+    /// </summary>
+    public string? AlbumName { get; set; }
+
+    /// <summary>
+    /// Gets or sets the song name.
+    /// </summary>
+    public string? SongName { get; set; }
+
+    /// <summary>
+    /// Gets or sets the track duration in ticks.
+    /// </summary>
+    public long? Duration { get; set; }
+
+    /// <inheritdoc />
+    public Dictionary<string, string> ProviderIds { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+
+    /// <summary>
+    /// Gets or sets a value indicating whether to search all providers.
+    /// </summary>
+    public bool SearchAllProviders { get; set; } = true;
+
+    /// <summary>
+    /// Gets or sets the list of disabled lyric fetcher names.
+    /// </summary>
+    public IReadOnlyList<string> DisabledLyricFetchers { get; set; } = [];
+
+    /// <summary>
+    /// Gets or sets the order of lyric fetchers.
+    /// </summary>
+    public IReadOnlyList<string> LyricFetcherOrder { get; set; } = [];
+
+    /// <summary>
+    /// Gets or sets a value indicating whether this request is automated.
+    /// </summary>
+    public bool IsAutomated { get; set; }
+}

+ 22 - 0
MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs

@@ -0,0 +1,22 @@
+namespace MediaBrowser.Model.Lyrics;
+
+/// <summary>
+/// The remote lyric info dto.
+/// </summary>
+public class RemoteLyricInfoDto
+{
+    /// <summary>
+    /// Gets or sets the id for the lyric.
+    /// </summary>
+    public required string Id { get; set; }
+
+    /// <summary>
+    /// Gets the provider name.
+    /// </summary>
+    public required string ProviderName { get; init; }
+
+    /// <summary>
+    /// Gets the lyrics.
+    /// </summary>
+    public required LyricDto Lyrics { get; init; }
+}

+ 16 - 0
MediaBrowser.Model/Lyrics/UploadLyricDto.cs

@@ -0,0 +1,16 @@
+using System.ComponentModel.DataAnnotations;
+using Microsoft.AspNetCore.Http;
+
+namespace MediaBrowser.Model.Lyrics;
+
+/// <summary>
+/// Upload lyric dto.
+/// </summary>
+public class UploadLyricDto
+{
+    /// <summary>
+    /// Gets or sets the lyrics file.
+    /// </summary>
+    [Required]
+    public IFormFile Lyrics { get; set; } = null!;
+}

+ 17 - 0
MediaBrowser.Model/Providers/LyricProviderInfo.cs

@@ -0,0 +1,17 @@
+namespace MediaBrowser.Model.Providers;
+
+/// <summary>
+/// Lyric provider info.
+/// </summary>
+public class LyricProviderInfo
+{
+    /// <summary>
+    /// Gets the provider name.
+    /// </summary>
+    public required string Name { get; init; }
+
+    /// <summary>
+    /// Gets the provider id.
+    /// </summary>
+    public required string Id { get; init; }
+}

+ 29 - 0
MediaBrowser.Model/Providers/RemoteLyricInfo.cs

@@ -0,0 +1,29 @@
+using MediaBrowser.Model.Lyrics;
+
+namespace MediaBrowser.Model.Providers;
+
+/// <summary>
+/// The remote lyric info.
+/// </summary>
+public class RemoteLyricInfo
+{
+    /// <summary>
+    /// Gets or sets the id for the lyric.
+    /// </summary>
+    public required string Id { get; set; }
+
+    /// <summary>
+    /// Gets the provider name.
+    /// </summary>
+    public required string ProviderName { get; init; }
+
+    /// <summary>
+    /// Gets the lyric metadata.
+    /// </summary>
+    public required LyricMetadata Metadata { get; init; }
+
+    /// <summary>
+    /// Gets the lyrics.
+    /// </summary>
+    public required LyricResponse Lyrics { get; init; }
+}

+ 6 - 0
MediaBrowser.Model/Users/UserPolicy.cs

@@ -92,6 +92,12 @@ namespace MediaBrowser.Model.Users
         [DefaultValue(false)]
         public bool EnableSubtitleManagement { get; set; }
 
+        /// <summary>
+        /// Gets or sets a value indicating whether this user can manage lyrics.
+        /// </summary>
+        [DefaultValue(false)]
+        public bool EnableLyricManagement { get; set; }
+
         /// <summary>
         /// Gets or sets a value indicating whether this instance is disabled.
         /// </summary>

+ 0 - 69
MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs

@@ -1,69 +0,0 @@
-using System;
-using System.IO;
-using System.Threading.Tasks;
-using Jellyfin.Extensions;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Resolvers;
-
-namespace MediaBrowser.Providers.Lyric;
-
-/// <inheritdoc />
-public class DefaultLyricProvider : ILyricProvider
-{
-    private static readonly string[] _lyricExtensions = { ".lrc", ".elrc", ".txt" };
-
-    /// <inheritdoc />
-    public string Name => "DefaultLyricProvider";
-
-    /// <inheritdoc />
-    public ResolverPriority Priority => ResolverPriority.First;
-
-    /// <inheritdoc />
-    public bool HasLyrics(BaseItem item)
-    {
-        var path = GetLyricsPath(item);
-        return path is not null;
-    }
-
-    /// <inheritdoc />
-    public async Task<LyricFile?> GetLyrics(BaseItem item)
-    {
-        var path = GetLyricsPath(item);
-        if (path is not null)
-        {
-            var content = await File.ReadAllTextAsync(path).ConfigureAwait(false);
-            if (!string.IsNullOrEmpty(content))
-            {
-                return new LyricFile(path, content);
-            }
-        }
-
-        return null;
-    }
-
-    private string? GetLyricsPath(BaseItem item)
-    {
-        // Ensure the path to the item is not null
-        string? itemDirectoryPath = Path.GetDirectoryName(item.Path);
-        if (itemDirectoryPath is null)
-        {
-            return null;
-        }
-
-        // Ensure the directory path exists
-        if (!Directory.Exists(itemDirectoryPath))
-        {
-            return null;
-        }
-
-        foreach (var lyricFilePath in Directory.GetFiles(itemDirectoryPath, $"{Path.GetFileNameWithoutExtension(item.Path)}.*"))
-        {
-            if (_lyricExtensions.Contains(Path.GetExtension(lyricFilePath.AsSpan()), StringComparison.OrdinalIgnoreCase))
-            {
-                return lyricFilePath;
-            }
-        }
-
-        return null;
-    }
-}

+ 0 - 36
MediaBrowser.Providers/Lyric/ILyricProvider.cs

@@ -1,36 +0,0 @@
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Resolvers;
-
-namespace MediaBrowser.Providers.Lyric;
-
-/// <summary>
-/// Interface ILyricsProvider.
-/// </summary>
-public interface ILyricProvider
-{
-    /// <summary>
-    /// Gets a value indicating the provider name.
-    /// </summary>
-    string Name { get; }
-
-    /// <summary>
-    /// Gets the priority.
-    /// </summary>
-    /// <value>The priority.</value>
-    ResolverPriority Priority { get; }
-
-    /// <summary>
-    /// Checks if an item has lyrics available.
-    /// </summary>
-    /// <param name="item">The media item.</param>
-    /// <returns>Whether lyrics where found or not.</returns>
-    bool HasLyrics(BaseItem item);
-
-    /// <summary>
-    /// Gets the lyrics.
-    /// </summary>
-    /// <param name="item">The media item.</param>
-    /// <returns>A task representing found lyrics.</returns>
-    Task<LyricFile?> GetLyrics(BaseItem item);
-}

+ 8 - 7
MediaBrowser.Providers/Lyric/LrcLyricParser.cs

@@ -8,6 +8,7 @@ using LrcParser.Model;
 using LrcParser.Parser;
 using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Lyrics;
 
 namespace MediaBrowser.Providers.Lyric;
 
@@ -18,8 +19,8 @@ public class LrcLyricParser : ILyricParser
 {
     private readonly LyricParser _lrcLyricParser;
 
-    private static readonly string[] _supportedMediaTypes = { ".lrc", ".elrc" };
-    private static readonly string[] _acceptedTimeFormats = { "HH:mm:ss", "H:mm:ss", "mm:ss", "m:ss" };
+    private static readonly string[] _supportedMediaTypes = [".lrc", ".elrc"];
+    private static readonly string[] _acceptedTimeFormats = ["HH:mm:ss", "H:mm:ss", "mm:ss", "m:ss"];
 
     /// <summary>
     /// Initializes a new instance of the <see cref="LrcLyricParser"/> class.
@@ -39,7 +40,7 @@ public class LrcLyricParser : ILyricParser
     public ResolverPriority Priority => ResolverPriority.Fourth;
 
     /// <inheritdoc />
-    public LyricResponse? ParseLyrics(LyricFile lyrics)
+    public LyricDto? ParseLyrics(LyricFile lyrics)
     {
         if (!_supportedMediaTypes.Contains(Path.GetExtension(lyrics.Name.AsSpan()), StringComparison.OrdinalIgnoreCase))
         {
@@ -95,7 +96,7 @@ public class LrcLyricParser : ILyricParser
             return null;
         }
 
-        List<LyricLine> lyricList = new();
+        List<LyricLine> lyricList = [];
 
         for (int i = 0; i < sortedLyricData.Count; i++)
         {
@@ -106,7 +107,7 @@ public class LrcLyricParser : ILyricParser
             }
 
             long ticks = TimeSpan.FromMilliseconds(timeData.Value).Ticks;
-            lyricList.Add(new LyricLine(sortedLyricData[i].Text, ticks));
+            lyricList.Add(new LyricLine(sortedLyricData[i].Text.Trim(), ticks));
         }
 
         if (fileMetaData.Count != 0)
@@ -114,10 +115,10 @@ public class LrcLyricParser : ILyricParser
             // Map metaData values from LRC file to LyricMetadata properties
             LyricMetadata lyricMetadata = MapMetadataValues(fileMetaData);
 
-            return new LyricResponse { Metadata = lyricMetadata, Lyrics = lyricList };
+            return new LyricDto { Metadata = lyricMetadata, Lyrics = lyricList };
         }
 
-        return new LyricResponse { Lyrics = lyricList };
+        return new LyricDto { Lyrics = lyricList };
     }
 
     /// <summary>

+ 406 - 22
MediaBrowser.Providers/Lyric/LyricManager.cs

@@ -1,8 +1,25 @@
+using System;
 using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
 using System.Linq;
+using System.Text;
+using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Extensions;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Lyrics;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Lyrics;
+using MediaBrowser.Model.Providers;
+using Microsoft.Extensions.Logging;
 
 namespace MediaBrowser.Providers.Lyric;
 
@@ -11,37 +28,246 @@ namespace MediaBrowser.Providers.Lyric;
 /// </summary>
 public class LyricManager : ILyricManager
 {
+    private readonly ILogger<LyricManager> _logger;
+    private readonly IFileSystem _fileSystem;
+    private readonly ILibraryMonitor _libraryMonitor;
+    private readonly IMediaSourceManager _mediaSourceManager;
+
     private readonly ILyricProvider[] _lyricProviders;
     private readonly ILyricParser[] _lyricParsers;
 
     /// <summary>
     /// Initializes a new instance of the <see cref="LyricManager"/> class.
     /// </summary>
-    /// <param name="lyricProviders">All found lyricProviders.</param>
-    /// <param name="lyricParsers">All found lyricParsers.</param>
-    public LyricManager(IEnumerable<ILyricProvider> lyricProviders, IEnumerable<ILyricParser> lyricParsers)
+    /// <param name="logger">Instance of the <see cref="ILogger{LyricManager}"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
+    /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+    /// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param>
+    /// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param>
+    public LyricManager(
+        ILogger<LyricManager> logger,
+        IFileSystem fileSystem,
+        ILibraryMonitor libraryMonitor,
+        IMediaSourceManager mediaSourceManager,
+        IEnumerable<ILyricProvider> lyricProviders,
+        IEnumerable<ILyricParser> lyricParsers)
+    {
+        _logger = logger;
+        _fileSystem = fileSystem;
+        _libraryMonitor = libraryMonitor;
+        _mediaSourceManager = mediaSourceManager;
+        _lyricProviders = lyricProviders
+            .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
+            .ToArray();
+        _lyricParsers = lyricParsers
+            .OrderBy(l => l.Priority)
+            .ToArray();
+    }
+
+    /// <inheritdoc />
+    public event EventHandler<LyricDownloadFailureEventArgs>? LyricDownloadFailure;
+
+    /// <inheritdoc />
+    public Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(Audio audio, bool isAutomated, CancellationToken cancellationToken)
+    {
+        ArgumentNullException.ThrowIfNull(audio);
+
+        var request = new LyricSearchRequest
+        {
+            MediaPath = audio.Path,
+            SongName = audio.Name,
+            AlbumName = audio.Album,
+            ArtistNames = audio.GetAllArtists().ToList(),
+            Duration = audio.RunTimeTicks,
+            IsAutomated = isAutomated
+        };
+
+        return SearchLyricsAsync(request, cancellationToken);
+    }
+
+    /// <inheritdoc />
+    public async Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(LyricSearchRequest request, CancellationToken cancellationToken)
+    {
+        ArgumentNullException.ThrowIfNull(request);
+
+        var providers = _lyricProviders
+            .Where(i => !request.DisabledLyricFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase))
+            .OrderBy(i =>
+            {
+                var index = request.LyricFetcherOrder.IndexOf(i.Name);
+                return index == -1 ? int.MaxValue : index;
+            })
+            .ToArray();
+
+        // If not searching all, search one at a time until something is found
+        if (!request.SearchAllProviders)
+        {
+            foreach (var provider in providers)
+            {
+                var providerResult = await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false);
+                if (providerResult.Count > 0)
+                {
+                    return providerResult;
+                }
+            }
+
+            return [];
+        }
+
+        var tasks = providers.Select(async provider => await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false));
+
+        var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+
+        return results.SelectMany(i => i).ToArray();
+    }
+
+    /// <inheritdoc />
+    public Task<LyricDto?> DownloadLyricsAsync(Audio audio, string lyricId, CancellationToken cancellationToken)
+    {
+        ArgumentNullException.ThrowIfNull(audio);
+        ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
+
+        var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
+
+        return DownloadLyricsAsync(audio, libraryOptions, lyricId, cancellationToken);
+    }
+
+    /// <inheritdoc />
+    public async Task<LyricDto?> DownloadLyricsAsync(Audio audio, LibraryOptions libraryOptions, string lyricId, CancellationToken cancellationToken)
+    {
+        ArgumentNullException.ThrowIfNull(audio);
+        ArgumentNullException.ThrowIfNull(libraryOptions);
+        ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
+
+        var provider = GetProvider(lyricId.AsSpan().LeftPart('_').ToString());
+        if (provider is null)
+        {
+            return null;
+        }
+
+        try
+        {
+            var response = await InternalGetRemoteLyricsAsync(lyricId, cancellationToken).ConfigureAwait(false);
+            if (response is null)
+            {
+                _logger.LogDebug("Unable to download lyrics for {LyricId}", lyricId);
+                return null;
+            }
+
+            var parsedLyrics = await InternalParseRemoteLyricsAsync(response, cancellationToken).ConfigureAwait(false);
+            if (parsedLyrics is null)
+            {
+                return null;
+            }
+
+            await TrySaveLyric(audio, libraryOptions, response).ConfigureAwait(false);
+            return parsedLyrics;
+        }
+        catch (RateLimitExceededException)
+        {
+            throw;
+        }
+        catch (Exception ex)
+        {
+            LyricDownloadFailure?.Invoke(this, new LyricDownloadFailureEventArgs
+            {
+                Item = audio,
+                Exception = ex,
+                Provider = provider.Name
+            });
+
+            throw;
+        }
+    }
+
+    /// <inheritdoc />
+    public async Task<LyricDto?> UploadLyricAsync(Audio audio, LyricResponse lyricResponse)
     {
-        _lyricProviders = lyricProviders.OrderBy(i => i.Priority).ToArray();
-        _lyricParsers = lyricParsers.OrderBy(i => i.Priority).ToArray();
+        ArgumentNullException.ThrowIfNull(audio);
+        ArgumentNullException.ThrowIfNull(lyricResponse);
+        var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
+
+        var parsed = await InternalParseRemoteLyricsAsync(lyricResponse, CancellationToken.None).ConfigureAwait(false);
+        if (parsed is null)
+        {
+            return null;
+        }
+
+        await TrySaveLyric(audio, libraryOptions, lyricResponse).ConfigureAwait(false);
+        return parsed;
+    }
+
+    /// <inheritdoc />
+    public async Task<LyricDto?> GetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
+    {
+        ArgumentException.ThrowIfNullOrEmpty(id);
+
+        var lyricResponse = await InternalGetRemoteLyricsAsync(id, cancellationToken).ConfigureAwait(false);
+        if (lyricResponse is null)
+        {
+            return null;
+        }
+
+        return await InternalParseRemoteLyricsAsync(lyricResponse, cancellationToken).ConfigureAwait(false);
     }
 
     /// <inheritdoc />
-    public async Task<LyricResponse?> GetLyrics(BaseItem item)
+    public Task DeleteLyricsAsync(Audio audio)
     {
-        foreach (ILyricProvider provider in _lyricProviders)
+        ArgumentNullException.ThrowIfNull(audio);
+        var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
+        {
+            ItemId = audio.Id,
+            Type = MediaStreamType.Lyric
+        });
+
+        foreach (var stream in streams)
         {
-            var lyrics = await provider.GetLyrics(item).ConfigureAwait(false);
-            if (lyrics is null)
+            var path = stream.Path;
+            _libraryMonitor.ReportFileSystemChangeBeginning(path);
+
+            try
             {
-                continue;
+                _fileSystem.DeleteFile(path);
             }
+            finally
+            {
+                _libraryMonitor.ReportFileSystemChangeComplete(path, false);
+            }
+        }
+
+        return audio.RefreshMetadata(CancellationToken.None);
+    }
+
+    /// <inheritdoc />
+    public IReadOnlyList<LyricProviderInfo> GetSupportedProviders(BaseItem item)
+    {
+        if (item is not Audio)
+        {
+            return [];
+        }
+
+        return _lyricProviders.Select(p => new LyricProviderInfo { Name = p.Name, Id = GetProviderId(p.Name) }).ToList();
+    }
 
-            foreach (ILyricParser parser in _lyricParsers)
+    /// <inheritdoc />
+    public async Task<LyricDto?> GetLyricsAsync(Audio audio, CancellationToken cancellationToken)
+    {
+        ArgumentNullException.ThrowIfNull(audio);
+
+        var lyricStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric);
+        foreach (var lyricStream in lyricStreams)
+        {
+            var lyricContents = await File.ReadAllTextAsync(lyricStream.Path, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
+
+            var lyricFile = new LyricFile(Path.GetFileName(lyricStream.Path), lyricContents);
+            foreach (var parser in _lyricParsers)
             {
-                var result = parser.ParseLyrics(lyrics);
-                if (result is not null)
+                var parsedLyrics = parser.ParseLyrics(lyricFile);
+                if (parsedLyrics is not null)
                 {
-                    return result;
+                    return parsedLyrics;
                 }
             }
         }
@@ -49,22 +275,180 @@ public class LyricManager : ILyricManager
         return null;
     }
 
-    /// <inheritdoc />
-    public bool HasLyricFile(BaseItem item)
+    private ILyricProvider? GetProvider(string providerId)
+    {
+        var provider = _lyricProviders.FirstOrDefault(p => string.Equals(providerId, GetProviderId(p.Name), StringComparison.Ordinal));
+        if (provider is null)
+        {
+            _logger.LogWarning("Unknown provider id: {ProviderId}", providerId.ReplaceLineEndings(string.Empty));
+        }
+
+        return provider;
+    }
+
+    private string GetProviderId(string name)
+        => name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
+
+    private async Task<LyricDto?> InternalParseRemoteLyricsAsync(LyricResponse lyricResponse, CancellationToken cancellationToken)
+    {
+        lyricResponse.Stream.Seek(0, SeekOrigin.Begin);
+        using var streamReader = new StreamReader(lyricResponse.Stream, leaveOpen: true);
+        var lyrics = await streamReader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+        var lyricFile = new LyricFile($"lyric.{lyricResponse.Format}", lyrics);
+        foreach (var parser in _lyricParsers)
+        {
+            var parsedLyrics = parser.ParseLyrics(lyricFile);
+            if (parsedLyrics is not null)
+            {
+                return parsedLyrics;
+            }
+        }
+
+        return null;
+    }
+
+    private async Task<LyricResponse?> InternalGetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
+    {
+        ArgumentException.ThrowIfNullOrWhiteSpace(id);
+        var parts = id.Split('_', 2);
+        var provider = GetProvider(parts[0]);
+        if (provider is null)
+        {
+            return null;
+        }
+
+        id = parts[^1];
+
+        return await provider.GetLyricsAsync(id, cancellationToken).ConfigureAwait(false);
+    }
+
+    private async Task<IReadOnlyList<RemoteLyricInfoDto>> InternalSearchProviderAsync(
+        ILyricProvider provider,
+        LyricSearchRequest request,
+        CancellationToken cancellationToken)
+    {
+        try
+        {
+            var providerId = GetProviderId(provider.Name);
+            var searchResults = await provider.SearchAsync(request, cancellationToken).ConfigureAwait(false);
+            var parsedResults = new List<RemoteLyricInfoDto>();
+            foreach (var result in searchResults)
+            {
+                var parsedLyrics = await InternalParseRemoteLyricsAsync(result.Lyrics, cancellationToken).ConfigureAwait(false);
+                if (parsedLyrics is null)
+                {
+                    continue;
+                }
+
+                parsedLyrics.Metadata = result.Metadata;
+                parsedResults.Add(new RemoteLyricInfoDto
+                {
+                    Id = $"{providerId}_{result.Id}",
+                    ProviderName = result.ProviderName,
+                    Lyrics = parsedLyrics
+                });
+            }
+
+            return parsedResults;
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error downloading lyrics from {Provider}", provider.Name);
+            return [];
+        }
+    }
+
+    private async Task TrySaveLyric(
+        Audio audio,
+        LibraryOptions libraryOptions,
+        LyricResponse lyricResponse)
     {
-        foreach (ILyricProvider provider in _lyricProviders)
+        var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia;
+
+        var memoryStream = new MemoryStream();
+        await using (memoryStream.ConfigureAwait(false))
         {
-            if (item is null)
+            var stream = lyricResponse.Stream;
+
+            await using (stream.ConfigureAwait(false))
             {
-                continue;
+                stream.Seek(0, SeekOrigin.Begin);
+                await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
+                memoryStream.Seek(0, SeekOrigin.Begin);
             }
 
-            if (provider.HasLyrics(item))
+            var savePaths = new List<string>();
+            var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + lyricResponse.Format.ReplaceLineEndings(string.Empty).ToLowerInvariant();
+
+            if (saveInMediaFolder)
             {
-                return true;
+                var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
+                // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
+                if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
+                {
+                    savePaths.Add(mediaFolderPath);
+                }
+            }
+
+            var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
+
+            // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
+            if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
+            {
+                savePaths.Add(internalPath);
+            }
+
+            if (savePaths.Count > 0)
+            {
+                await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false);
+            }
+            else
+            {
+                _logger.LogError("An uploaded lyric could not be saved because the resulting paths were invalid.");
             }
         }
+    }
 
-        return false;
+    private async Task TrySaveToFiles(Stream stream, List<string> savePaths)
+    {
+        List<Exception>? exs = null;
+
+        foreach (var savePath in savePaths)
+        {
+            _logger.LogInformation("Saving lyrics to {SavePath}", savePath.ReplaceLineEndings(string.Empty));
+
+            _libraryMonitor.ReportFileSystemChangeBeginning(savePath);
+
+            try
+            {
+                Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new InvalidOperationException("Path can't be a root directory."));
+
+                var fileOptions = AsyncFile.WriteOptions;
+                fileOptions.Mode = FileMode.Create;
+                fileOptions.PreallocationSize = stream.Length;
+                var fs = new FileStream(savePath, fileOptions);
+                await using (fs.ConfigureAwait(false))
+                {
+                    await stream.CopyToAsync(fs).ConfigureAwait(false);
+                }
+
+                return;
+            }
+            catch (Exception ex)
+            {
+                (exs ??= []).Add(ex);
+            }
+            finally
+            {
+                _libraryMonitor.ReportFileSystemChangeComplete(savePath, false);
+            }
+
+            stream.Position = 0;
+        }
+
+        if (exs is not null)
+        {
+            throw new AggregateException(exs);
+        }
     }
 }

+ 6 - 5
MediaBrowser.Providers/Lyric/TxtLyricParser.cs

@@ -3,6 +3,7 @@ using System.IO;
 using Jellyfin.Extensions;
 using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Lyrics;
 
 namespace MediaBrowser.Providers.Lyric;
 
@@ -11,8 +12,8 @@ namespace MediaBrowser.Providers.Lyric;
 /// </summary>
 public class TxtLyricParser : ILyricParser
 {
-    private static readonly string[] _supportedMediaTypes = { ".lrc", ".elrc", ".txt" };
-    private static readonly string[] _lineBreakCharacters = { "\r\n", "\r", "\n" };
+    private static readonly string[] _supportedMediaTypes = [".lrc", ".elrc", ".txt"];
+    private static readonly string[] _lineBreakCharacters = ["\r\n", "\r", "\n"];
 
     /// <inheritdoc />
     public string Name => "TxtLyricProvider";
@@ -24,7 +25,7 @@ public class TxtLyricParser : ILyricParser
     public ResolverPriority Priority => ResolverPriority.Fifth;
 
     /// <inheritdoc />
-    public LyricResponse? ParseLyrics(LyricFile lyrics)
+    public LyricDto? ParseLyrics(LyricFile lyrics)
     {
         if (!_supportedMediaTypes.Contains(Path.GetExtension(lyrics.Name.AsSpan()), StringComparison.OrdinalIgnoreCase))
         {
@@ -36,9 +37,9 @@ public class TxtLyricParser : ILyricParser
 
         for (int lyricLineIndex = 0; lyricLineIndex < lyricTextLines.Length; lyricLineIndex++)
         {
-            lyricList[lyricLineIndex] = new LyricLine(lyricTextLines[lyricLineIndex]);
+            lyricList[lyricLineIndex] = new LyricLine(lyricTextLines[lyricLineIndex].Trim());
         }
 
-        return new LyricResponse { Lyrics = lyricList };
+        return new LyricDto { Lyrics = lyricList };
     }
 }

+ 15 - 3
MediaBrowser.Providers/Manager/ProviderManager.cs

@@ -21,6 +21,7 @@ using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Entities.Movies;
 using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Subtitles;
 using MediaBrowser.Model.Configuration;
@@ -52,6 +53,7 @@ namespace MediaBrowser.Providers.Manager
         private readonly IServerApplicationPaths _appPaths;
         private readonly ILibraryManager _libraryManager;
         private readonly ISubtitleManager _subtitleManager;
+        private readonly ILyricManager _lyricManager;
         private readonly IServerConfigurationManager _configurationManager;
         private readonly IBaseItemManager _baseItemManager;
         private readonly ConcurrentDictionary<Guid, double> _activeRefreshes = new();
@@ -78,6 +80,7 @@ namespace MediaBrowser.Providers.Manager
         /// <param name="appPaths">The server application paths.</param>
         /// <param name="libraryManager">The library manager.</param>
         /// <param name="baseItemManager">The BaseItem manager.</param>
+        /// <param name="lyricManager">The lyric manager.</param>
         public ProviderManager(
             IHttpClientFactory httpClientFactory,
             ISubtitleManager subtitleManager,
@@ -87,7 +90,8 @@ namespace MediaBrowser.Providers.Manager
             IFileSystem fileSystem,
             IServerApplicationPaths appPaths,
             ILibraryManager libraryManager,
-            IBaseItemManager baseItemManager)
+            IBaseItemManager baseItemManager,
+            ILyricManager lyricManager)
         {
             _logger = logger;
             _httpClientFactory = httpClientFactory;
@@ -98,6 +102,7 @@ namespace MediaBrowser.Providers.Manager
             _libraryManager = libraryManager;
             _subtitleManager = subtitleManager;
             _baseItemManager = baseItemManager;
+            _lyricManager = lyricManager;
         }
 
         /// <inheritdoc/>
@@ -503,15 +508,22 @@ namespace MediaBrowser.Providers.Manager
             AddMetadataPlugins(pluginList, dummy, libraryOptions, options);
             AddImagePlugins(pluginList, imageProviders);
 
-            var subtitleProviders = _subtitleManager.GetSupportedProviders(dummy);
-
             // Subtitle fetchers
+            var subtitleProviders = _subtitleManager.GetSupportedProviders(dummy);
             pluginList.AddRange(subtitleProviders.Select(i => new MetadataPlugin
             {
                 Name = i.Name,
                 Type = MetadataPluginType.SubtitleFetcher
             }));
 
+            // Lyric fetchers
+            var lyricProviders = _lyricManager.GetSupportedProviders(dummy);
+            pluginList.AddRange(lyricProviders.Select(i => new MetadataPlugin
+            {
+                Name = i.Name,
+                Type = MetadataPluginType.LyricFetcher
+            }));
+
             summary.Plugins = pluginList.ToArray();
 
             var supportedImageTypes = imageProviders.OfType<IRemoteImageProvider>()

+ 30 - 4
MediaBrowser.Providers/MediaInfo/AudioFileProber.cs

@@ -35,6 +35,7 @@ namespace MediaBrowser.Providers.MediaInfo
         private readonly IItemRepository _itemRepo;
         private readonly ILibraryManager _libraryManager;
         private readonly IMediaSourceManager _mediaSourceManager;
+        private readonly LyricResolver _lyricResolver;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="AudioFileProber"/> class.
@@ -44,18 +45,21 @@ namespace MediaBrowser.Providers.MediaInfo
         /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
         /// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param>
         /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="lyricResolver">Instance of the <see cref="LyricResolver"/> interface.</param>
         public AudioFileProber(
             ILogger<AudioFileProber> logger,
             IMediaSourceManager mediaSourceManager,
             IMediaEncoder mediaEncoder,
             IItemRepository itemRepo,
-            ILibraryManager libraryManager)
+            ILibraryManager libraryManager,
+            LyricResolver lyricResolver)
         {
             _logger = logger;
             _mediaEncoder = mediaEncoder;
             _itemRepo = itemRepo;
             _libraryManager = libraryManager;
             _mediaSourceManager = mediaSourceManager;
+            _lyricResolver = lyricResolver;
         }
 
         [GeneratedRegex(@"I:\s+(.*?)\s+LUFS")]
@@ -103,7 +107,7 @@ namespace MediaBrowser.Providers.MediaInfo
 
                 cancellationToken.ThrowIfCancellationRequested();
 
-                Fetch(item, result, cancellationToken);
+                Fetch(item, result, options, cancellationToken);
             }
 
             var libraryOptions = _libraryManager.GetLibraryOptions(item);
@@ -205,8 +209,13 @@ namespace MediaBrowser.Providers.MediaInfo
         /// </summary>
         /// <param name="audio">The <see cref="Audio"/>.</param>
         /// <param name="mediaInfo">The <see cref="Model.MediaInfo.MediaInfo"/>.</param>
+        /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
         /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
-        protected void Fetch(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, CancellationToken cancellationToken)
+        protected void Fetch(
+            Audio audio,
+            Model.MediaInfo.MediaInfo mediaInfo,
+            MetadataRefreshOptions options,
+            CancellationToken cancellationToken)
         {
             audio.Container = mediaInfo.Container;
             audio.TotalBitrate = mediaInfo.Bitrate;
@@ -219,7 +228,12 @@ namespace MediaBrowser.Providers.MediaInfo
                 FetchDataFromTags(audio);
             }
 
-            _itemRepo.SaveMediaStreams(audio.Id, mediaInfo.MediaStreams, cancellationToken);
+            var mediaStreams = new List<MediaStream>(mediaInfo.MediaStreams);
+            AddExternalLyrics(audio, mediaStreams, options);
+
+            audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric);
+
+            _itemRepo.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken);
         }
 
         /// <summary>
@@ -333,5 +347,17 @@ namespace MediaBrowser.Providers.MediaInfo
                 audio.SetProviderId(MetadataProvider.MusicBrainzTrack, tags.MusicBrainzTrackId);
             }
         }
+
+        private void AddExternalLyrics(
+            Audio audio,
+            List<MediaStream> currentStreams,
+            MetadataRefreshOptions options)
+        {
+            var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
+            var externalLyricFiles = _lyricResolver.GetExternalStreams(audio, startIndex, options.DirectoryService, false);
+
+            audio.LyricFiles = externalLyricFiles.Select(i => i.Path).Distinct().ToArray();
+            currentStreams.AddRange(externalLyricFiles);
+        }
     }
 }

+ 39 - 0
MediaBrowser.Providers/MediaInfo/LyricResolver.cs

@@ -0,0 +1,39 @@
+using Emby.Naming.Common;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.MediaInfo;
+
+/// <summary>
+/// Resolves external lyric files for <see cref="Audio"/>.
+/// </summary>
+public class LyricResolver : MediaInfoResolver
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="LyricResolver"/> class for external subtitle file processing.
+    /// </summary>
+    /// <param name="logger">The logger.</param>
+    /// <param name="localizationManager">The localization manager.</param>
+    /// <param name="mediaEncoder">The media encoder.</param>
+    /// <param name="fileSystem">The file system.</param>
+    /// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.</param>
+    public LyricResolver(
+        ILogger<LyricResolver> logger,
+        ILocalizationManager localizationManager,
+        IMediaEncoder mediaEncoder,
+        IFileSystem fileSystem,
+        NamingOptions namingOptions)
+        : base(
+            logger,
+            localizationManager,
+            mediaEncoder,
+            fileSystem,
+            namingOptions,
+            DlnaProfileType.Lyric)
+    {
+    }
+}

+ 96 - 1
MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
 using Emby.Naming.Common;
 using Emby.Naming.ExternalFiles;
 using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Dlna;
@@ -148,7 +149,49 @@ namespace MediaBrowser.Providers.MediaInfo
                 }
             }
 
-            return mediaStreams.AsReadOnly();
+            return mediaStreams;
+        }
+
+        /// <summary>
+        /// Retrieves the external streams for the provided audio.
+        /// </summary>
+        /// <param name="audio">The <see cref="Audio"/> object to search external streams for.</param>
+        /// <param name="startIndex">The stream index to start adding external streams at.</param>
+        /// <param name="directoryService">The directory service to search for files.</param>
+        /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
+        /// <returns>The external streams located.</returns>
+        public IReadOnlyList<MediaStream> GetExternalStreams(
+            Audio audio,
+            int startIndex,
+            IDirectoryService directoryService,
+            bool clearCache)
+        {
+            if (!audio.IsFileProtocol)
+            {
+                return Array.Empty<MediaStream>();
+            }
+
+            var pathInfos = GetExternalFiles(audio, directoryService, clearCache);
+
+            if (pathInfos.Count == 0)
+            {
+                return Array.Empty<MediaStream>();
+            }
+
+            var mediaStreams = new MediaStream[pathInfos.Count];
+
+            for (var i = 0; i < pathInfos.Count; i++)
+            {
+                mediaStreams[i] = new MediaStream
+                {
+                    Type = MediaStreamType.Lyric,
+                    Path = pathInfos[i].Path,
+                    Language = pathInfos[i].Language,
+                    Index = startIndex++
+                };
+            }
+
+            return mediaStreams;
         }
 
         /// <summary>
@@ -209,6 +252,58 @@ namespace MediaBrowser.Providers.MediaInfo
             return externalPathInfos;
         }
 
+        /// <summary>
+        /// Returns the external file infos for the given audio.
+        /// </summary>
+        /// <param name="audio">The <see cref="Audio"/> object to search external files for.</param>
+        /// <param name="directoryService">The directory service to search for files.</param>
+        /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
+        /// <returns>The external file paths located.</returns>
+        public IReadOnlyList<ExternalPathParserResult> GetExternalFiles(
+            Audio audio,
+            IDirectoryService directoryService,
+            bool clearCache)
+        {
+            if (!audio.IsFileProtocol)
+            {
+                return Array.Empty<ExternalPathParserResult>();
+            }
+
+            string folder = audio.ContainingFolderPath;
+            var files = directoryService.GetFilePaths(folder, clearCache, true).ToList();
+            files.Remove(audio.Path);
+            var internalMetadataPath = audio.GetInternalMetadataPath();
+            if (_fileSystem.DirectoryExists(internalMetadataPath))
+            {
+                files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true));
+            }
+
+            if (files.Count == 0)
+            {
+                return Array.Empty<ExternalPathParserResult>();
+            }
+
+            var externalPathInfos = new List<ExternalPathParserResult>();
+            ReadOnlySpan<char> prefix = audio.FileNameWithoutExtension;
+            foreach (var file in files)
+            {
+                var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan());
+                if (fileNameWithoutExtension.Length >= prefix.Length
+                    && prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase)
+                    && (fileNameWithoutExtension.Length == prefix.Length || _namingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[prefix.Length])))
+                {
+                    var externalPathInfo = _externalPathParser.ParseFile(file, fileNameWithoutExtension[prefix.Length..].ToString());
+
+                    if (externalPathInfo is not null)
+                    {
+                        externalPathInfos.Add(externalPathInfo);
+                    }
+                }
+            }
+
+            return externalPathInfos;
+        }
+
         /// <summary>
         /// Returns the media info of the given file.
         /// </summary>

+ 37 - 13
MediaBrowser.Providers/MediaInfo/ProbeProvider.cs

@@ -43,6 +43,7 @@ namespace MediaBrowser.Providers.MediaInfo
         private readonly ILogger<ProbeProvider> _logger;
         private readonly AudioResolver _audioResolver;
         private readonly SubtitleResolver _subtitleResolver;
+        private readonly LyricResolver _lyricResolver;
         private readonly FFProbeVideoInfo _videoProber;
         private readonly AudioFileProber _audioProber;
         private readonly Task<ItemUpdateType> _cachedTask = Task.FromResult(ItemUpdateType.None);
@@ -79,9 +80,10 @@ namespace MediaBrowser.Providers.MediaInfo
             NamingOptions namingOptions)
         {
             _logger = loggerFactory.CreateLogger<ProbeProvider>();
-            _audioProber = new AudioFileProber(loggerFactory.CreateLogger<AudioFileProber>(), mediaSourceManager, mediaEncoder, itemRepo, libraryManager);
             _audioResolver = new AudioResolver(loggerFactory.CreateLogger<AudioResolver>(), localization, mediaEncoder, fileSystem, namingOptions);
             _subtitleResolver = new SubtitleResolver(loggerFactory.CreateLogger<SubtitleResolver>(), localization, mediaEncoder, fileSystem, namingOptions);
+            _lyricResolver = new LyricResolver(loggerFactory.CreateLogger<LyricResolver>(), localization, mediaEncoder, fileSystem, namingOptions);
+
             _videoProber = new FFProbeVideoInfo(
                 loggerFactory.CreateLogger<FFProbeVideoInfo>(),
                 mediaSourceManager,
@@ -96,6 +98,14 @@ namespace MediaBrowser.Providers.MediaInfo
                 libraryManager,
                 _audioResolver,
                 _subtitleResolver);
+
+            _audioProber = new AudioFileProber(
+                loggerFactory.CreateLogger<AudioFileProber>(),
+                mediaSourceManager,
+                mediaEncoder,
+                itemRepo,
+                libraryManager,
+                _lyricResolver);
         }
 
         /// <inheritdoc />
@@ -123,23 +133,37 @@ namespace MediaBrowser.Providers.MediaInfo
                 }
             }
 
-            if (item.SupportsLocalMetadata && video is not null && !video.IsPlaceHolder
-                && !video.SubtitleFiles.SequenceEqual(
-                    _subtitleResolver.GetExternalFiles(video, directoryService, false)
-                    .Select(info => info.Path).ToList(),
-                    StringComparer.Ordinal))
+            if (video is not null
+                && item.SupportsLocalMetadata
+                && !video.IsPlaceHolder)
             {
-                _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path);
-                return true;
+                if (!video.SubtitleFiles.SequenceEqual(
+                        _subtitleResolver.GetExternalFiles(video, directoryService, false)
+                            .Select(info => info.Path).ToList(),
+                        StringComparer.Ordinal))
+                {
+                    _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path);
+                    return true;
+                }
+
+                if (!video.AudioFiles.SequenceEqual(
+                        _audioResolver.GetExternalFiles(video, directoryService, false)
+                            .Select(info => info.Path).ToList(),
+                        StringComparer.Ordinal))
+                {
+                    _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path);
+                    return true;
+                }
             }
 
-            if (item.SupportsLocalMetadata && video is not null && !video.IsPlaceHolder
-                && !video.AudioFiles.SequenceEqual(
-                    _audioResolver.GetExternalFiles(video, directoryService, false)
-                    .Select(info => info.Path).ToList(),
+            if (item is Audio audio
+                && item.SupportsLocalMetadata
+                && !audio.LyricFiles.SequenceEqual(
+                    _lyricResolver.GetExternalFiles(audio, directoryService, false)
+                        .Select(info => info.Path).ToList(),
                     StringComparer.Ordinal))
             {
-                _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path);
+                _logger.LogDebug("Refreshing {ItemPath} due to external lyrics change.", item.Path);
                 return true;
             }
 

+ 1 - 1
MediaBrowser.Providers/Subtitles/SubtitleManager.cs

@@ -74,7 +74,7 @@ namespace MediaBrowser.Providers.Subtitles
                 .Where(i => i.SupportedMediaTypes.Contains(contentType) && !request.DisabledSubtitleFetchers.Contains(i.Name, StringComparison.OrdinalIgnoreCase))
                 .OrderBy(i =>
                 {
-                    var index = request.SubtitleFetcherOrder.ToList().IndexOf(i.Name);
+                    var index = request.SubtitleFetcherOrder.IndexOf(i.Name);
                     return index == -1 ? int.MaxValue : index;
                 })
                 .ToArray();

+ 10 - 0
src/Jellyfin.Extensions/StringExtensions.cs

@@ -61,6 +61,11 @@ namespace Jellyfin.Extensions
         /// <returns>The part left of the <paramref name="needle" />.</returns>
         public static ReadOnlySpan<char> LeftPart(this ReadOnlySpan<char> haystack, char needle)
         {
+            if (haystack.IsEmpty)
+            {
+                return ReadOnlySpan<char>.Empty;
+            }
+
             var pos = haystack.IndexOf(needle);
             return pos == -1 ? haystack : haystack[..pos];
         }
@@ -73,6 +78,11 @@ namespace Jellyfin.Extensions
         /// <returns>The part right of the <paramref name="needle" />.</returns>
         public static ReadOnlySpan<char> RightPart(this ReadOnlySpan<char> haystack, char needle)
         {
+            if (haystack.IsEmpty)
+            {
+                return ReadOnlySpan<char>.Empty;
+            }
+
             var pos = haystack.LastIndexOf(needle);
             if (pos == -1)
             {

+ 3 - 1
tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs

@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Movies;
 using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Subtitles;
 using MediaBrowser.Model.Configuration;
@@ -570,7 +571,8 @@ namespace Jellyfin.Providers.Tests.Manager
                 Mock.Of<IFileSystem>(),
                 Mock.Of<IServerApplicationPaths>(),
                 libraryManager.Object,
-                baseItemManager!);
+                baseItemManager!,
+                Mock.Of<ILyricManager>());
 
             return providerManager;
         }