diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 9a9de1059e..8b7e216e44 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -67,6 +67,7 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Notifications; @@ -94,6 +95,7 @@ using MediaBrowser.Model.Serialization; using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; using MediaBrowser.Providers.Chapters; +using MediaBrowser.Providers.Lyric; using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Plugins.Tmdb; using MediaBrowser.Providers.Subtitles; @@ -598,6 +600,7 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 3d2b8f7f63..f6d37421a8 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using Jellyfin.Api.Helpers; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -18,6 +19,7 @@ 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; @@ -50,6 +52,8 @@ namespace Emby.Server.Implementations.Dto private readonly IMediaSourceManager _mediaSourceManager; private readonly Lazy _livetvManagerFactory; + private readonly ILyricManager _lyricManager; + public DtoService( ILogger logger, ILibraryManager libraryManager, @@ -59,7 +63,8 @@ namespace Emby.Server.Implementations.Dto IProviderManager providerManager, IApplicationHost appHost, IMediaSourceManager mediaSourceManager, - Lazy livetvManagerFactory) + Lazy livetvManagerFactory, + ILyricManager lyricManager) { _logger = logger; _libraryManager = libraryManager; @@ -70,6 +75,7 @@ namespace Emby.Server.Implementations.Dto _appHost = appHost; _mediaSourceManager = mediaSourceManager; _livetvManagerFactory = livetvManagerFactory; + _lyricManager = lyricManager; } private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; @@ -139,6 +145,10 @@ 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)) diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index ee8a17b62d..8a2d5a27d9 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -7,11 +7,13 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; +using Jellyfin.Api.Models.UserDtos; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -36,6 +38,7 @@ namespace Jellyfin.Api.Controllers private readonly IDtoService _dtoService; private readonly IUserViewManager _userViewManager; private readonly IFileSystem _fileSystem; + private readonly ILyricManager _lyricManager; /// /// Initializes a new instance of the class. @@ -46,13 +49,15 @@ namespace Jellyfin.Api.Controllers /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public UserLibraryController( IUserManager userManager, IUserDataManager userDataRepository, ILibraryManager libraryManager, IDtoService dtoService, IUserViewManager userViewManager, - IFileSystem fileSystem) + IFileSystem fileSystem, + ILyricManager lyricManager) { _userManager = userManager; _userDataRepository = userDataRepository; @@ -60,6 +65,7 @@ namespace Jellyfin.Api.Controllers _dtoService = dtoService; _userViewManager = userViewManager; _fileSystem = fileSystem; + _lyricManager = lyricManager; } /// @@ -381,5 +387,42 @@ namespace Jellyfin.Api.Controllers return _userDataRepository.GetUserDataDto(item, user); } + + /// + /// Gets an item's lyrics. + /// + /// User id. + /// Item id. + /// Lyrics returned. + /// Something went wrong. No Lyrics will be returned. + /// An containing the item's lyrics. + [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetLyrics([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + + if (user == null) + { + return NotFound(); + } + + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); + + if (item == null) + { + return NotFound(); + } + + var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false); + if (result is not null) + { + return Ok(result); + } + + return NotFound(); + } } } diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 67e50b92d9..984711dc2d 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -19,6 +19,7 @@ using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; using MediaBrowser.Model.Activity; @@ -95,6 +96,11 @@ namespace Jellyfin.Server serviceCollection.AddScoped(); + foreach (var type in GetExportTypes()) + { + serviceCollection.AddSingleton(typeof(ILyricProvider), type); + } + base.RegisterServices(serviceCollection); } diff --git a/MediaBrowser.Controller/Lyrics/ILyricManager.cs b/MediaBrowser.Controller/Lyrics/ILyricManager.cs new file mode 100644 index 0000000000..bb93e1e4c6 --- /dev/null +++ b/MediaBrowser.Controller/Lyrics/ILyricManager.cs @@ -0,0 +1,24 @@ +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Lyrics; + +/// +/// Interface ILyricManager. +/// +public interface ILyricManager +{ + /// + /// Gets the lyrics. + /// + /// The media item. + /// A task representing found lyrics the passed item. + Task GetLyrics(BaseItem item); + + /// + /// Checks if requested item has a matching local lyric file. + /// + /// The media item. + /// True if item has a matching lyric file; otherwise false. + bool HasLyricFile(BaseItem item); +} diff --git a/MediaBrowser.Controller/Lyrics/ILyricProvider.cs b/MediaBrowser.Controller/Lyrics/ILyricProvider.cs new file mode 100644 index 0000000000..2a04c61520 --- /dev/null +++ b/MediaBrowser.Controller/Lyrics/ILyricProvider.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Resolvers; + +namespace MediaBrowser.Controller.Lyrics; + +/// +/// Interface ILyricsProvider. +/// +public interface ILyricProvider +{ + /// + /// Gets a value indicating the provider name. + /// + string Name { get; } + + /// + /// Gets the priority. + /// + /// The priority. + ResolverPriority Priority { get; } + + /// + /// Gets the supported media types for this provider. + /// + /// The supported media types. + IReadOnlyCollection SupportedMediaTypes { get; } + + /// + /// Gets the lyrics. + /// + /// The media item. + /// A task representing found lyrics. + Task GetLyrics(BaseItem item); +} diff --git a/MediaBrowser.Controller/Lyrics/LyricInfo.cs b/MediaBrowser.Controller/Lyrics/LyricInfo.cs new file mode 100644 index 0000000000..6ec6df5825 --- /dev/null +++ b/MediaBrowser.Controller/Lyrics/LyricInfo.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using Jellyfin.Extensions; + +namespace MediaBrowser.Controller.Lyrics; + +/// +/// Lyric helper methods. +/// +public static class LyricInfo +{ + /// + /// Gets matching lyric file for a requested item. + /// + /// The lyricProvider interface to use. + /// Path of requested item. + /// Lyric file path if passed lyric provider's supported media type is found; otherwise, null. + public static string? GetLyricFilePath(this ILyricProvider lyricProvider, string itemPath) + { + // Ensure we have a provider + if (lyricProvider is null) + { + return null; + } + + // Ensure the path to the item is not null + string? itemDirectoryPath = Path.GetDirectoryName(itemPath); + 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(itemPath)}.*")) + { + if (lyricProvider.SupportedMediaTypes.Contains(Path.GetExtension(lyricFilePath.AsSpan())[1..], StringComparison.OrdinalIgnoreCase)) + { + return lyricFilePath; + } + } + + return null; + } +} diff --git a/MediaBrowser.Controller/Lyrics/LyricLine.cs b/MediaBrowser.Controller/Lyrics/LyricLine.cs new file mode 100644 index 0000000000..c406f92fcc --- /dev/null +++ b/MediaBrowser.Controller/Lyrics/LyricLine.cs @@ -0,0 +1,28 @@ +namespace MediaBrowser.Controller.Lyrics; + +/// +/// Lyric model. +/// +public class LyricLine +{ + /// + /// Initializes a new instance of the class. + /// + /// The lyric text. + /// The lyric start time in ticks. + public LyricLine(string text, long? start = null) + { + Text = text; + Start = start; + } + + /// + /// Gets the text of this lyric line. + /// + public string Text { get; } + + /// + /// Gets the start time in ticks. + /// + public long? Start { get; } +} diff --git a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs b/MediaBrowser.Controller/Lyrics/LyricMetadata.cs new file mode 100644 index 0000000000..6091ede52a --- /dev/null +++ b/MediaBrowser.Controller/Lyrics/LyricMetadata.cs @@ -0,0 +1,54 @@ +using System; + +namespace MediaBrowser.Controller.Lyrics; + +/// +/// LyricMetadata model. +/// +public class LyricMetadata +{ + /// + /// Gets or sets the song artist. + /// + public string? Artist { get; set; } + + /// + /// Gets or sets the album this song is on. + /// + public string? Album { get; set; } + + /// + /// Gets or sets the title of the song. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the author of the lyric data. + /// + public string? Author { get; set; } + + /// + /// Gets or sets the length of the song in ticks. + /// + public long? Length { get; set; } + + /// + /// Gets or sets who the LRC file was created by. + /// + public string? By { get; set; } + + /// + /// Gets or sets the lyric offset compared to audio in ticks. + /// + public long? Offset { get; set; } + + /// + /// Gets or sets the software used to create the LRC file. + /// + public string? Creator { get; set; } + + /// + /// Gets or sets the version of the creator used. + /// + public string? Version { get; set; } +} diff --git a/MediaBrowser.Controller/Lyrics/LyricResponse.cs b/MediaBrowser.Controller/Lyrics/LyricResponse.cs new file mode 100644 index 0000000000..0d52b5ec50 --- /dev/null +++ b/MediaBrowser.Controller/Lyrics/LyricResponse.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace MediaBrowser.Controller.Lyrics; + +/// +/// LyricResponse model. +/// +public class LyricResponse +{ + /// + /// Gets or sets Metadata for the lyrics. + /// + public LyricMetadata Metadata { get; set; } = new(); + + /// + /// Gets or sets a collection of individual lyric lines. + /// + public IReadOnlyList Lyrics { get; set; } = Array.Empty(); +} diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index fdb84fa320..2a86fded22 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -76,6 +76,8 @@ namespace MediaBrowser.Model.Dto public bool? CanDownload { get; set; } + public bool? HasLyrics { get; set; } + public bool? HasSubtitles { get; set; } public string PreferredMetadataLanguage { get; set; } diff --git a/MediaBrowser.Providers/Lyric/LrcLyricProvider.cs b/MediaBrowser.Providers/Lyric/LrcLyricProvider.cs new file mode 100644 index 0000000000..7b108921b3 --- /dev/null +++ b/MediaBrowser.Providers/Lyric/LrcLyricProvider.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using LrcParser.Model; +using LrcParser.Parser; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Lyrics; +using MediaBrowser.Controller.Resolvers; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Lyric; + +/// +/// LRC Lyric Provider. +/// +public class LrcLyricProvider : ILyricProvider +{ + private readonly ILogger _logger; + + private readonly LyricParser _lrcLyricParser; + + private static readonly string[] _acceptedTimeFormats = { "HH:mm:ss", "H:mm:ss", "mm:ss", "m:ss" }; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public LrcLyricProvider(ILogger logger) + { + _logger = logger; + _lrcLyricParser = new LrcParser.Parser.Lrc.LrcParser(); + } + + /// + public string Name => "LrcLyricProvider"; + + /// + /// Gets the priority. + /// + /// The priority. + public ResolverPriority Priority => ResolverPriority.First; + + /// + public IReadOnlyCollection SupportedMediaTypes { get; } = new[] { "lrc", "elrc" }; + + /// + /// Opens lyric file for the requested item, and processes it for API return. + /// + /// The item to to process. + /// If provider can determine lyrics, returns a with or without metadata; otherwise, null. + public async Task GetLyrics(BaseItem item) + { + string? lyricFilePath = this.GetLyricFilePath(item.Path); + + if (string.IsNullOrEmpty(lyricFilePath)) + { + return null; + } + + var fileMetaData = new Dictionary(StringComparer.OrdinalIgnoreCase); + string lrcFileContent = await File.ReadAllTextAsync(lyricFilePath).ConfigureAwait(false); + + Song lyricData; + + try + { + lyricData = _lrcLyricParser.Decode(lrcFileContent); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error parsing lyric file {LyricFilePath} from {Provider}", lyricFilePath, Name); + return null; + } + + List sortedLyricData = lyricData.Lyrics.Where(x => x.TimeTags.Count > 0).OrderBy(x => x.TimeTags.First().Value).ToList(); + + // Parse metadata rows + var metaDataRows = lyricData.Lyrics + .Where(x => x.TimeTags.Count == 0) + .Where(x => x.Text.StartsWith('[') && x.Text.EndsWith(']')) + .Select(x => x.Text) + .ToList(); + + foreach (string metaDataRow in metaDataRows) + { + var index = metaDataRow.IndexOf(':', StringComparison.OrdinalIgnoreCase); + if (index == -1) + { + continue; + } + + // Remove square bracket before field name, and after field value + // Example 1: [au: 1hitsong] + // Example 2: [ar: Calabrese] + var metaDataFieldName = GetMetadataFieldName(metaDataRow, index); + var metaDataFieldValue = GetMetadataValue(metaDataRow, index); + + if (string.IsNullOrEmpty(metaDataFieldName) || string.IsNullOrEmpty(metaDataFieldValue)) + { + continue; + } + + fileMetaData[metaDataFieldName] = metaDataFieldValue; + } + + if (sortedLyricData.Count == 0) + { + return null; + } + + List lyricList = new(); + + for (int i = 0; i < sortedLyricData.Count; i++) + { + var timeData = sortedLyricData[i].TimeTags.First().Value; + if (timeData is null) + { + continue; + } + + long ticks = TimeSpan.FromMilliseconds(timeData.Value).Ticks; + lyricList.Add(new LyricLine(sortedLyricData[i].Text, ticks)); + } + + if (fileMetaData.Count != 0) + { + // Map metaData values from LRC file to LyricMetadata properties + LyricMetadata lyricMetadata = MapMetadataValues(fileMetaData); + + return new LyricResponse + { + Metadata = lyricMetadata, + Lyrics = lyricList + }; + } + + return new LyricResponse + { + Lyrics = lyricList + }; + } + + /// + /// Converts metadata from an LRC file to LyricMetadata properties. + /// + /// The metadata from the LRC file. + /// A lyricMetadata object with mapped property data. + private static LyricMetadata MapMetadataValues(IDictionary metaData) + { + LyricMetadata lyricMetadata = new(); + + if (metaData.TryGetValue("ar", out var artist) && !string.IsNullOrEmpty(artist)) + { + lyricMetadata.Artist = artist; + } + + if (metaData.TryGetValue("al", out var album) && !string.IsNullOrEmpty(album)) + { + lyricMetadata.Album = album; + } + + if (metaData.TryGetValue("ti", out var title) && !string.IsNullOrEmpty(title)) + { + lyricMetadata.Title = title; + } + + if (metaData.TryGetValue("au", out var author) && !string.IsNullOrEmpty(author)) + { + lyricMetadata.Author = author; + } + + if (metaData.TryGetValue("length", out var length) && !string.IsNullOrEmpty(length)) + { + if (DateTime.TryParseExact(length, _acceptedTimeFormats, null, DateTimeStyles.None, out var value)) + { + lyricMetadata.Length = value.TimeOfDay.Ticks; + } + } + + if (metaData.TryGetValue("by", out var by) && !string.IsNullOrEmpty(by)) + { + lyricMetadata.By = by; + } + + if (metaData.TryGetValue("offset", out var offset) && !string.IsNullOrEmpty(offset)) + { + if (int.TryParse(offset, out var value)) + { + lyricMetadata.Offset = TimeSpan.FromMilliseconds(value).Ticks; + } + } + + if (metaData.TryGetValue("re", out var creator) && !string.IsNullOrEmpty(creator)) + { + lyricMetadata.Creator = creator; + } + + if (metaData.TryGetValue("ve", out var version) && !string.IsNullOrEmpty(version)) + { + lyricMetadata.Version = version; + } + + return lyricMetadata; + } + + private static string GetMetadataFieldName(string metaDataRow, int index) + { + var metadataFieldName = metaDataRow.AsSpan(1, index - 1).Trim(); + return metadataFieldName.IsEmpty ? string.Empty : metadataFieldName.ToString(); + } + + private static string GetMetadataValue(string metaDataRow, int index) + { + var metadataValue = metaDataRow.AsSpan(index + 1, metaDataRow.Length - index - 2).Trim(); + return metadataValue.IsEmpty ? string.Empty : metadataValue.ToString(); + } +} diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs new file mode 100644 index 0000000000..f9547e0f05 --- /dev/null +++ b/MediaBrowser.Providers/Lyric/LyricManager.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Lyrics; + +namespace MediaBrowser.Providers.Lyric; + +/// +/// Lyric Manager. +/// +public class LyricManager : ILyricManager +{ + private readonly ILyricProvider[] _lyricProviders; + + /// + /// Initializes a new instance of the class. + /// + /// All found lyricProviders. + public LyricManager(IEnumerable lyricProviders) + { + _lyricProviders = lyricProviders.OrderBy(i => i.Priority).ToArray(); + } + + /// + public async Task GetLyrics(BaseItem item) + { + foreach (ILyricProvider provider in _lyricProviders) + { + var results = await provider.GetLyrics(item).ConfigureAwait(false); + if (results is not null) + { + return results; + } + } + + return null; + } + + /// + public bool HasLyricFile(BaseItem item) + { + foreach (ILyricProvider provider in _lyricProviders) + { + if (item is null) + { + continue; + } + + if (provider.GetLyricFilePath(item.Path) is not null) + { + return true; + } + } + + return false; + } +} diff --git a/MediaBrowser.Providers/Lyric/TxtLyricProvider.cs b/MediaBrowser.Providers/Lyric/TxtLyricProvider.cs new file mode 100644 index 0000000000..96a9e9dcf3 --- /dev/null +++ b/MediaBrowser.Providers/Lyric/TxtLyricProvider.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Lyrics; +using MediaBrowser.Controller.Resolvers; + +namespace MediaBrowser.Providers.Lyric; + +/// +/// TXT Lyric Provider. +/// +public class TxtLyricProvider : ILyricProvider +{ + /// + public string Name => "TxtLyricProvider"; + + /// + /// Gets the priority. + /// + /// The priority. + public ResolverPriority Priority => ResolverPriority.Second; + + /// + public IReadOnlyCollection SupportedMediaTypes { get; } = new[] { "lrc", "elrc", "txt" }; + + /// + /// Opens lyric file for the requested item, and processes it for API return. + /// + /// The item to to process. + /// If provider can determine lyrics, returns a ; otherwise, null. + public async Task GetLyrics(BaseItem item) + { + string? lyricFilePath = this.GetLyricFilePath(item.Path); + + if (string.IsNullOrEmpty(lyricFilePath)) + { + return null; + } + + string[] lyricTextLines = await File.ReadAllLinesAsync(lyricFilePath).ConfigureAwait(false); + + if (lyricTextLines.Length == 0) + { + return null; + } + + LyricLine[] lyricList = new LyricLine[lyricTextLines.Length]; + + for (int lyricLineIndex = 0; lyricLineIndex < lyricTextLines.Length; lyricLineIndex++) + { + lyricList[lyricLineIndex] = new LyricLine(lyricTextLines[lyricLineIndex]); + } + + return new LyricResponse + { + Lyrics = lyricList + }; + } +} diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index dd43ba9cb0..3a0e9a225b 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -16,6 +16,7 @@ +