Store lyrics in the database as media streams (#9951)
This commit is contained in:
parent
59f50ae8b2
commit
0bc41c015f
|
@ -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;
|
||||
|
@ -24,6 +25,16 @@ namespace Jellyfin.Api.Auth.UserPermissionPolicy
|
|||
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserPermissionRequirement requirement)
|
||||
{
|
||||
// Api keys have global permissions, so just succeed the requirement.
|
||||
if (context.User.GetIsApiKey())
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
else
|
||||
{
|
||||
var userId = context.User.GetUserId();
|
||||
if (!userId.IsEmpty())
|
||||
{
|
||||
var user = _userManager.GetUserById(context.User.GetUserId());
|
||||
if (user is null)
|
||||
|
@ -35,6 +46,8 @@ namespace Jellyfin.Api.Auth.UserPermissionPolicy
|
|||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
|
267
Jellyfin.Api/Controllers/LyricsController.cs
Normal file
267
Jellyfin.Api/Controllers/LyricsController.cs
Normal file
|
@ -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,7 +406,13 @@ public class SubtitleController : BaseJellyfinApiController
|
|||
[FromBody, Required] UploadSubtitleDto body)
|
||||
{
|
||||
var video = (Video)_libraryManager.GetItemById(itemId);
|
||||
var stream = new CryptoStream(Request.Body, new FromBase64Transform(), CryptoStreamMode.Read);
|
||||
|
||||
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(
|
||||
|
@ -425,6 +430,7 @@ public class SubtitleController : BaseJellyfinApiController
|
|||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a subtitle in the specified format.
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
34
MediaBrowser.Controller/Lyrics/ILyricProvider.cs
Normal file
34
MediaBrowser.Controller/Lyrics/ILyricProvider.cs
Normal file
|
@ -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; }
|
||||
}
|
19
MediaBrowser.Model/Lyrics/LyricResponse.cs
Normal file
19
MediaBrowser.Model/Lyrics/LyricResponse.cs
Normal file
|
@ -0,0 +1,19 @@
|
|||
using System.IO;
|
||||
|
||||
namespace MediaBrowser.Model.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// LyricResponse model.
|
||||
/// </summary>
|
||||
public class LyricResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the lyric stream.
|
||||
/// </summary>
|
||||
public required Stream Stream { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lyric format.
|
||||
/// </summary>
|
||||
public required string Format { get; set; }
|
||||
}
|
59
MediaBrowser.Model/Lyrics/LyricSearchRequest.cs
Normal file
59
MediaBrowser.Model/Lyrics/LyricSearchRequest.cs
Normal file
|
@ -0,0 +1,59 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Model.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// Lyric search request.
|
||||
/// </summary>
|
||||
public class LyricSearchRequest : IHasProviderIds
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the media path.
|
||||
/// </summary>
|
||||
public string? MediaPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the artist name.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? ArtistNames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the album name.
|
||||
/// </summary>
|
||||
public string? AlbumName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the song name.
|
||||
/// </summary>
|
||||
public string? SongName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the track duration in ticks.
|
||||
/// </summary>
|
||||
public long? Duration { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Dictionary<string, string> ProviderIds { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to search all providers.
|
||||
/// </summary>
|
||||
public bool SearchAllProviders { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of disabled lyric fetcher names.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> DisabledLyricFetchers { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the order of lyric fetchers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> LyricFetcherOrder { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this request is automated.
|
||||
/// </summary>
|
||||
public bool IsAutomated { get; set; }
|
||||
}
|
22
MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs
Normal file
22
MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
namespace MediaBrowser.Model.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// The remote lyric info dto.
|
||||
/// </summary>
|
||||
public class RemoteLyricInfoDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the id for the lyric.
|
||||
/// </summary>
|
||||
public required string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the provider name.
|
||||
/// </summary>
|
||||
public required string ProviderName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the lyrics.
|
||||
/// </summary>
|
||||
public required LyricDto Lyrics { get; init; }
|
||||
}
|
16
MediaBrowser.Model/Lyrics/UploadLyricDto.cs
Normal file
16
MediaBrowser.Model/Lyrics/UploadLyricDto.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace MediaBrowser.Model.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// Upload lyric dto.
|
||||
/// </summary>
|
||||
public class UploadLyricDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the lyrics file.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public IFormFile Lyrics { get; set; } = null!;
|
||||
}
|
17
MediaBrowser.Model/Providers/LyricProviderInfo.cs
Normal file
17
MediaBrowser.Model/Providers/LyricProviderInfo.cs
Normal file
|
@ -0,0 +1,17 @@
|
|||
namespace MediaBrowser.Model.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Lyric provider info.
|
||||
/// </summary>
|
||||
public class LyricProviderInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the provider id.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
}
|
29
MediaBrowser.Model/Providers/RemoteLyricInfo.cs
Normal file
29
MediaBrowser.Model/Providers/RemoteLyricInfo.cs
Normal file
|
@ -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)
|
||||
{
|
||||
_lyricProviders = lyricProviders.OrderBy(i => i.Priority).ToArray();
|
||||
_lyricParsers = lyricParsers.OrderBy(i => i.Priority).ToArray();
|
||||
_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 async Task<LyricResponse?> GetLyrics(BaseItem item)
|
||||
public event EventHandler<LyricDownloadFailureEventArgs>? LyricDownloadFailure;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(Audio audio, bool isAutomated, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (ILyricProvider provider in _lyricProviders)
|
||||
ArgumentNullException.ThrowIfNull(audio);
|
||||
|
||||
var request = new LyricSearchRequest
|
||||
{
|
||||
var lyrics = await provider.GetLyrics(item).ConfigureAwait(false);
|
||||
if (lyrics is null)
|
||||
{
|
||||
continue;
|
||||
MediaPath = audio.Path,
|
||||
SongName = audio.Name,
|
||||
AlbumName = audio.Album,
|
||||
ArtistNames = audio.GetAllArtists().ToList(),
|
||||
Duration = audio.RunTimeTicks,
|
||||
IsAutomated = isAutomated
|
||||
};
|
||||
|
||||
return SearchLyricsAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
foreach (ILyricParser parser in _lyricParsers)
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(LyricSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = parser.ParseLyrics(lyrics);
|
||||
if (result is not null)
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var providers = _lyricProviders
|
||||
.Where(i => !request.DisabledLyricFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase))
|
||||
.OrderBy(i =>
|
||||
{
|
||||
return result;
|
||||
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)
|
||||
{
|
||||
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 Task DeleteLyricsAsync(Audio audio)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(audio);
|
||||
var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
|
||||
{
|
||||
ItemId = audio.Id,
|
||||
Type = MediaStreamType.Lyric
|
||||
});
|
||||
|
||||
foreach (var stream in streams)
|
||||
{
|
||||
var path = stream.Path;
|
||||
_libraryMonitor.ReportFileSystemChangeBeginning(path);
|
||||
|
||||
try
|
||||
{
|
||||
_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();
|
||||
}
|
||||
|
||||
/// <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 parsedLyrics = parser.ParseLyrics(lyricFile);
|
||||
if (parsedLyrics is not null)
|
||||
{
|
||||
return parsedLyrics;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -49,22 +275,180 @@ public class LyricManager : ILyricManager
|
|||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasLyricFile(BaseItem item)
|
||||
private ILyricProvider? GetProvider(string providerId)
|
||||
{
|
||||
foreach (ILyricProvider provider in _lyricProviders)
|
||||
var provider = _lyricProviders.FirstOrDefault(p => string.Equals(providerId, GetProviderId(p.Name), StringComparison.Ordinal));
|
||||
if (provider is null)
|
||||
{
|
||||
if (item 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;
|
||||
}
|
||||
|
||||
if (provider.HasLyrics(item))
|
||||
parsedLyrics.Metadata = result.Metadata;
|
||||
parsedResults.Add(new RemoteLyricInfoDto
|
||||
{
|
||||
return true;
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
private async Task TrySaveLyric(
|
||||
Audio audio,
|
||||
LibraryOptions libraryOptions,
|
||||
LyricResponse lyricResponse)
|
||||
{
|
||||
var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia;
|
||||
|
||||
var memoryStream = new MemoryStream();
|
||||
await using (memoryStream.ConfigureAwait(false))
|
||||
{
|
||||
var stream = lyricResponse.Stream;
|
||||
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
|
||||
memoryStream.Seek(0, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
var savePaths = new List<string>();
|
||||
var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + lyricResponse.Format.ReplaceLineEndings(string.Empty).ToLowerInvariant();
|
||||
|
||||
if (saveInMediaFolder)
|
||||
{
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
39
MediaBrowser.Providers/MediaInfo/LyricResolver.cs
Normal file
39
MediaBrowser.Providers/MediaInfo/LyricResolver.cs
Normal file
|
@ -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,8 +133,11 @@ namespace MediaBrowser.Providers.MediaInfo
|
|||
}
|
||||
}
|
||||
|
||||
if (item.SupportsLocalMetadata && video is not null && !video.IsPlaceHolder
|
||||
&& !video.SubtitleFiles.SequenceEqual(
|
||||
if (video is not null
|
||||
&& item.SupportsLocalMetadata
|
||||
&& !video.IsPlaceHolder)
|
||||
{
|
||||
if (!video.SubtitleFiles.SequenceEqual(
|
||||
_subtitleResolver.GetExternalFiles(video, directoryService, false)
|
||||
.Select(info => info.Path).ToList(),
|
||||
StringComparer.Ordinal))
|
||||
|
@ -133,8 +146,7 @@ namespace MediaBrowser.Providers.MediaInfo
|
|||
return true;
|
||||
}
|
||||
|
||||
if (item.SupportsLocalMetadata && video is not null && !video.IsPlaceHolder
|
||||
&& !video.AudioFiles.SequenceEqual(
|
||||
if (!video.AudioFiles.SequenceEqual(
|
||||
_audioResolver.GetExternalFiles(video, directoryService, false)
|
||||
.Select(info => info.Path).ToList(),
|
||||
StringComparer.Ordinal))
|
||||
|
@ -142,6 +154,18 @@ namespace MediaBrowser.Providers.MediaInfo
|
|||
_logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
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 lyrics change.", item.Path);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -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…
Reference in New Issue
Block a user