aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Naming/Common/NamingOptions.cs12
-rw-r--r--Emby.Naming/ExternalFiles/ExternalPathParser.cs3
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs13
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs13
-rw-r--r--Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs25
-rw-r--r--Jellyfin.Api/Controllers/LyricsController.cs267
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs38
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs45
-rw-r--r--Jellyfin.Data/Entities/User.cs1
-rw-r--r--Jellyfin.Data/Enums/PermissionKind.cs7
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs101
-rw-r--r--Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs2
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs1
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs2
-rw-r--r--MediaBrowser.Common/Api/Policies.cs5
-rw-r--r--MediaBrowser.Controller/Entities/Audio/Audio.cs12
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs9
-rw-r--r--MediaBrowser.Controller/Lyrics/ILyricManager.cs100
-rw-r--r--MediaBrowser.Controller/Lyrics/ILyricParser.cs4
-rw-r--r--MediaBrowser.Controller/Lyrics/ILyricProvider.cs34
-rw-r--r--MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs26
-rw-r--r--MediaBrowser.Model/Configuration/LibraryOptions.cs5
-rw-r--r--MediaBrowser.Model/Configuration/MetadataPluginType.cs3
-rw-r--r--MediaBrowser.Model/Dlna/DlnaProfileType.cs3
-rw-r--r--MediaBrowser.Model/Entities/MediaStreamType.cs7
-rw-r--r--MediaBrowser.Model/Lyrics/LyricDto.cs (renamed from MediaBrowser.Controller/Lyrics/LyricResponse.cs)7
-rw-r--r--MediaBrowser.Model/Lyrics/LyricFile.cs (renamed from MediaBrowser.Controller/Lyrics/LyricFile.cs)2
-rw-r--r--MediaBrowser.Model/Lyrics/LyricLine.cs (renamed from MediaBrowser.Controller/Lyrics/LyricLine.cs)2
-rw-r--r--MediaBrowser.Model/Lyrics/LyricMetadata.cs (renamed from MediaBrowser.Controller/Lyrics/LyricMetadata.cs)7
-rw-r--r--MediaBrowser.Model/Lyrics/LyricResponse.cs19
-rw-r--r--MediaBrowser.Model/Lyrics/LyricSearchRequest.cs59
-rw-r--r--MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs22
-rw-r--r--MediaBrowser.Model/Lyrics/UploadLyricDto.cs16
-rw-r--r--MediaBrowser.Model/Providers/LyricProviderInfo.cs17
-rw-r--r--MediaBrowser.Model/Providers/RemoteLyricInfo.cs29
-rw-r--r--MediaBrowser.Model/Users/UserPolicy.cs6
-rw-r--r--MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs69
-rw-r--r--MediaBrowser.Providers/Lyric/ILyricProvider.cs36
-rw-r--r--MediaBrowser.Providers/Lyric/LrcLyricParser.cs15
-rw-r--r--MediaBrowser.Providers/Lyric/LyricManager.cs428
-rw-r--r--MediaBrowser.Providers/Lyric/TxtLyricParser.cs11
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs18
-rw-r--r--MediaBrowser.Providers/MediaInfo/AudioFileProber.cs34
-rw-r--r--MediaBrowser.Providers/MediaInfo/LyricResolver.cs39
-rw-r--r--MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs97
-rw-r--r--MediaBrowser.Providers/MediaInfo/ProbeProvider.cs50
-rw-r--r--MediaBrowser.Providers/Subtitles/SubtitleManager.cs2
-rw-r--r--src/Jellyfin.Extensions/StringExtensions.cs10
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs4
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;
}