diff options
49 files changed, 1478 insertions, 259 deletions
diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index b63c8f10e..4bd226d95 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -173,6 +173,13 @@ namespace Emby.Naming.Common ".vtt", }; + LyricFileExtensions = new[] + { + ".lrc", + ".elrc", + ".txt" + }; + AlbumStackingPrefixes = new[] { "cd", @@ -792,6 +799,11 @@ namespace Emby.Naming.Common 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> public EpisodeExpression[] EpisodeExpressions { get; set; } diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs index 4080ba10d..9d54533c2 100644 --- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs +++ b/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; } diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index d372277e0..7812687ea 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/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; } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 7998ce34a..13a381060 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/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()) diff --git a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs index e72bec46f..764c0a435 100644 --- a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs +++ b/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; diff --git a/Jellyfin.Api/Controllers/LyricsController.cs b/Jellyfin.Api/Controllers/LyricsController.cs new file mode 100644 index 000000000..4fccf2cb4 --- /dev/null +++ b/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); + } +} diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index 49ca058bd..d6ec40a7e 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/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(); + } } } diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index 264e0a3db..e3bfd4ea9 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/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(); - } } diff --git a/Jellyfin.Data/Entities/User.cs b/Jellyfin.Data/Entities/User.cs index ea0de3016..2c9cc8d78 100644 --- a/Jellyfin.Data/Entities/User.cs +++ b/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> diff --git a/Jellyfin.Data/Enums/PermissionKind.cs b/Jellyfin.Data/Enums/PermissionKind.cs index 6644f0151..c3d6705c2 100644 --- a/Jellyfin.Data/Enums/PermissionKind.cs +++ b/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, } } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs new file mode 100644 index 000000000..bd717b0af --- /dev/null +++ b/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; + } +} diff --git a/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs index 9626817e9..d1db6d3b4 100644 --- a/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs +++ b/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 diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index c4a2bfdb8..41f1ac351 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/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); diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 46df173bf..597643ed1 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/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) diff --git a/MediaBrowser.Common/Api/Policies.cs b/MediaBrowser.Common/Api/Policies.cs index e5427b8ef..435f4798f 100644 --- a/MediaBrowser.Common/Api/Policies.cs +++ b/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"; } diff --git a/MediaBrowser.Controller/Entities/Audio/Audio.cs b/MediaBrowser.Controller/Entities/Audio/Audio.cs index 243d2f04f..709d4b70c 100644 --- a/MediaBrowser.Controller/Entities/Audio/Audio.cs +++ b/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; diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 9ec22324f..e44c09783 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -169,6 +169,15 @@ namespace MediaBrowser.Controller.Library 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> /// <param name="item">The item.</param> diff --git a/MediaBrowser.Controller/Lyrics/ILyricManager.cs b/MediaBrowser.Controller/Lyrics/ILyricManager.cs index bb93e1e4c..f4376a1ee 100644 --- a/MediaBrowser.Controller/Lyrics/ILyricManager.cs +++ b/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); } diff --git a/MediaBrowser.Controller/Lyrics/ILyricParser.cs b/MediaBrowser.Controller/Lyrics/ILyricParser.cs index 65a9471a3..819950d09 100644 --- a/MediaBrowser.Controller/Lyrics/ILyricParser.cs +++ b/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); } diff --git a/MediaBrowser.Controller/Lyrics/ILyricProvider.cs b/MediaBrowser.Controller/Lyrics/ILyricProvider.cs new file mode 100644 index 000000000..0831a4c4e --- /dev/null +++ b/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); +} diff --git a/MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs b/MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs new file mode 100644 index 000000000..1b1f36020 --- /dev/null +++ b/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; } + } +} diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index 1c071067d..42148a276 100644 --- a/MediaBrowser.Model/Configuration/LibraryOptions.cs +++ b/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; } diff --git a/MediaBrowser.Model/Configuration/MetadataPluginType.cs b/MediaBrowser.Model/Configuration/MetadataPluginType.cs index 4c5e95266..ef303726d 100644 --- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs +++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs @@ -13,6 +13,7 @@ namespace MediaBrowser.Model.Configuration LocalMetadataProvider, MetadataFetcher, MetadataSaver, - SubtitleFetcher + SubtitleFetcher, + LyricFetcher } } diff --git a/MediaBrowser.Model/Dlna/DlnaProfileType.cs b/MediaBrowser.Model/Dlna/DlnaProfileType.cs index c1a663bf1..1bb885c44 100644 --- a/MediaBrowser.Model/Dlna/DlnaProfileType.cs +++ b/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 } } diff --git a/MediaBrowser.Model/Entities/MediaStreamType.cs b/MediaBrowser.Model/Entities/MediaStreamType.cs index 83751a6a7..0964bb769 100644 --- a/MediaBrowser.Model/Entities/MediaStreamType.cs +++ b/MediaBrowser.Model/Entities/MediaStreamType.cs @@ -28,6 +28,11 @@ namespace MediaBrowser.Model.Entities /// <summary> /// The data. /// </summary> - Data + Data, + + /// <summary> + /// The lyric. + /// </summary> + Lyric } } diff --git a/MediaBrowser.Controller/Lyrics/LyricResponse.cs b/MediaBrowser.Model/Lyrics/LyricDto.cs index 0d52b5ec5..7a9bffc99 100644 --- a/MediaBrowser.Controller/Lyrics/LyricResponse.cs +++ b/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; } = []; } diff --git a/MediaBrowser.Controller/Lyrics/LyricFile.cs b/MediaBrowser.Model/Lyrics/LyricFile.cs index ede89403c..3912b037e 100644 --- a/MediaBrowser.Controller/Lyrics/LyricFile.cs +++ b/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. diff --git a/MediaBrowser.Controller/Lyrics/LyricLine.cs b/MediaBrowser.Model/Lyrics/LyricLine.cs index c406f92fc..64d1f64c2 100644 --- a/MediaBrowser.Controller/Lyrics/LyricLine.cs +++ b/MediaBrowser.Model/Lyrics/LyricLine.cs @@ -1,4 +1,4 @@ -namespace MediaBrowser.Controller.Lyrics; +namespace MediaBrowser.Model.Lyrics; /// <summary> /// Lyric model. diff --git a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs b/MediaBrowser.Model/Lyrics/LyricMetadata.cs index c4f033489..4f819d6c9 100644 --- a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs +++ b/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; } } diff --git a/MediaBrowser.Model/Lyrics/LyricResponse.cs b/MediaBrowser.Model/Lyrics/LyricResponse.cs new file mode 100644 index 000000000..b04adeb7b --- /dev/null +++ b/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; } +} diff --git a/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs b/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs new file mode 100644 index 000000000..48c442a55 --- /dev/null +++ b/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; } +} diff --git a/MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs b/MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs new file mode 100644 index 000000000..dda56d198 --- /dev/null +++ b/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; } +} diff --git a/MediaBrowser.Model/Lyrics/UploadLyricDto.cs b/MediaBrowser.Model/Lyrics/UploadLyricDto.cs new file mode 100644 index 000000000..0ea8a4c63 --- /dev/null +++ b/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!; +} diff --git a/MediaBrowser.Model/Providers/LyricProviderInfo.cs b/MediaBrowser.Model/Providers/LyricProviderInfo.cs new file mode 100644 index 000000000..ea9c94185 --- /dev/null +++ b/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; } +} diff --git a/MediaBrowser.Model/Providers/RemoteLyricInfo.cs b/MediaBrowser.Model/Providers/RemoteLyricInfo.cs new file mode 100644 index 000000000..9fb340a58 --- /dev/null +++ b/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; } +} diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs index 219ed5d5f..951e05763 100644 --- a/MediaBrowser.Model/Users/UserPolicy.cs +++ b/MediaBrowser.Model/Users/UserPolicy.cs @@ -93,6 +93,12 @@ namespace MediaBrowser.Model.Users 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> /// <value><c>true</c> if this instance is disabled; otherwise, <c>false</c>.</value> diff --git a/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs b/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs deleted file mode 100644 index ab09f278a..000000000 --- a/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs +++ /dev/null @@ -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; - } -} diff --git a/MediaBrowser.Providers/Lyric/ILyricProvider.cs b/MediaBrowser.Providers/Lyric/ILyricProvider.cs deleted file mode 100644 index 27ceba72b..000000000 --- a/MediaBrowser.Providers/Lyric/ILyricProvider.cs +++ /dev/null @@ -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); -} diff --git a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs index a10ff198b..67b26e457 100644 --- a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs +++ b/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> diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs index 6da811927..60734b89a 100644 --- a/MediaBrowser.Providers/Lyric/LyricManager.cs +++ b/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); + } } } diff --git a/MediaBrowser.Providers/Lyric/TxtLyricParser.cs b/MediaBrowser.Providers/Lyric/TxtLyricParser.cs index 706f13dbc..a8188da28 100644 --- a/MediaBrowser.Providers/Lyric/TxtLyricParser.cs +++ b/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 }; } } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 2e9547bf3..81a299015 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/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>() diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index f718325df..fb86e254f 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/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); + } } } diff --git a/MediaBrowser.Providers/MediaInfo/LyricResolver.cs b/MediaBrowser.Providers/MediaInfo/LyricResolver.cs new file mode 100644 index 000000000..52af5ea08 --- /dev/null +++ b/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) + { + } +} diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs index f846aa5de..fbec4e963 100644 --- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs +++ b/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> @@ -210,6 +253,58 @@ namespace MediaBrowser.Providers.MediaInfo } /// <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> /// <param name="path">The path to the file.</param> diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 114a92975..8bb874f0d 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/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; } diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index 87fd2a3cd..f68b3cee6 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/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(); diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index fd8f7e59a..9d8afc23c 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/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) { diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs index 1e0851993..478db6941 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/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; } |
