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

pull/11068/head
Cody Robibero 2 months ago committed by GitHub
parent 59f50ae8b2
commit 0bc41c015f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -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;
}

@ -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;
}

@ -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())

@ -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;

@ -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);
}
}

@ -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();
}
}
}

@ -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();
}
}

@ -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>

@ -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,
}
}

@ -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;
}
}

@ -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

@ -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);

@ -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)

@ -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";
}

@ -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;

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

@ -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);
}

@ -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);
}

@ -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);
}

@ -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; }
}
}

@ -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; }

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

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

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

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

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

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

@ -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; }
}

@ -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; }
}

@ -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; }
}

@ -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; }
}

@ -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!;
}

@ -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; }
}

@ -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; }
}

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

@ -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;
}
}

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

@ -8,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>

@ -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);
}
}
}

@ -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 };
}
}

@ -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>()

@ -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);
}
}
}

@ -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)
{
}
}

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

@ -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;
}

@ -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();

@ -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)
{

@ -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;
}

Loading…
Cancel
Save