repo creation with initial code after cloning public repo

This commit is contained in:
2026-02-19 07:36:25 -05:00
commit 460884fea3
2860 changed files with 650825 additions and 0 deletions
@@ -0,0 +1,64 @@
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Books;
/// <summary>
/// Service to manage audiobook metadata.
/// </summary>
public class AudioBookMetadataService : MetadataService<AudioBook, SongInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="AudioBookMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public AudioBookMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<AudioBookMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
/// <inheritdoc />
protected override void MergeData(
MetadataResult<AudioBook> source,
MetadataResult<AudioBook> target,
MetadataField[] lockedFields,
bool replaceData,
bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
var sourceItem = source.Item;
var targetItem = target.Item;
if (replaceData || targetItem.Artists.Count == 0)
{
targetItem.Artists = sourceItem.Artists;
}
if (replaceData || string.IsNullOrEmpty(targetItem.Album))
{
targetItem.Album = sourceItem.Album;
}
}
}
@@ -0,0 +1,51 @@
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Books;
/// <summary>
/// Service to manage book metadata.
/// </summary>
public class BookMetadataService : MetadataService<Book, BookInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="BookMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public BookMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<BookMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
/// <inheritdoc />
protected override void MergeData(MetadataResult<Book> source, MetadataResult<Book> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
if (replaceData || string.IsNullOrEmpty(target.Item.SeriesName))
{
target.Item.SeriesName = source.Item.SeriesName;
}
}
}
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Books.OpenPackagingFormat
{
/// <summary>
/// Provides the primary image for EPUB items that have embedded covers.
/// </summary>
public class EpubImageProvider : IDynamicImageProvider
{
private readonly ILogger<EpubImageProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="EpubImageProvider"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{EpubImageProvider}"/> interface.</param>
public EpubImageProvider(ILogger<EpubImageProvider> logger)
{
_logger = logger;
}
/// <inheritdoc />
public string Name => "EPUB Metadata";
/// <inheritdoc />
public bool Supports(BaseItem item)
{
return item is Book;
}
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
yield return ImageType.Primary;
}
/// <inheritdoc />
public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
{
if (string.Equals(Path.GetExtension(item.Path), ".epub", StringComparison.OrdinalIgnoreCase))
{
return GetFromZip(item, cancellationToken);
}
return Task.FromResult(new DynamicImageResponse { HasImage = false });
}
private async Task<DynamicImageResponse> LoadCover(ZipArchive epub, XmlDocument opf, string opfRootDirectory, CancellationToken cancellationToken)
{
var utilities = new OpfReader<EpubImageProvider>(opf, _logger);
var coverReference = utilities.ReadCoverPath(opfRootDirectory);
if (coverReference == null)
{
return new DynamicImageResponse { HasImage = false };
}
var cover = coverReference.Value;
var coverFile = epub.GetEntry(cover.Path);
if (coverFile == null)
{
return new DynamicImageResponse { HasImage = false };
}
var memoryStream = new MemoryStream();
var coverStream = await coverFile.OpenAsync(cancellationToken).ConfigureAwait(false);
await using (coverStream.ConfigureAwait(false))
{
await coverStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
}
memoryStream.Position = 0;
var response = new DynamicImageResponse { HasImage = true, Stream = memoryStream };
response.SetFormatFromMimeType(cover.MimeType);
return response;
}
private async Task<DynamicImageResponse> GetFromZip(BaseItem item, CancellationToken cancellationToken)
{
using var epub = await ZipFile.OpenReadAsync(item.Path, cancellationToken).ConfigureAwait(false);
var opfFilePath = EpubUtils.ReadContentFilePath(epub);
if (opfFilePath == null)
{
return new DynamicImageResponse { HasImage = false };
}
var opfRootDirectory = Path.GetDirectoryName(opfFilePath);
if (opfRootDirectory == null)
{
return new DynamicImageResponse { HasImage = false };
}
var opfFile = epub.GetEntry(opfFilePath);
if (opfFile == null)
{
return new DynamicImageResponse { HasImage = false };
}
using var opfStream = await opfFile.OpenAsync(cancellationToken).ConfigureAwait(false);
var opfDocument = new XmlDocument();
opfDocument.Load(opfStream);
return await LoadCover(epub, opfDocument, opfRootDirectory, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,100 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Books.OpenPackagingFormat
{
/// <summary>
/// Provides book metadata from OPF content in an EPUB item.
/// </summary>
public class EpubProvider : ILocalMetadataProvider<Book>
{
private readonly IFileSystem _fileSystem;
private readonly ILogger<EpubProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="EpubProvider"/> class.
/// </summary>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{EpubProvider}"/> interface.</param>
public EpubProvider(IFileSystem fileSystem, ILogger<EpubProvider> logger)
{
_fileSystem = fileSystem;
_logger = logger;
}
/// <inheritdoc />
public string Name => "EPUB Metadata";
/// <inheritdoc />
public Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
{
var path = GetEpubFile(info.Path)?.FullName;
if (path is null)
{
return Task.FromResult(new MetadataResult<Book> { HasMetadata = false });
}
var result = ReadEpubAsZip(path, cancellationToken);
if (result is null)
{
return Task.FromResult(new MetadataResult<Book> { HasMetadata = false });
}
else
{
return Task.FromResult(result);
}
}
private FileSystemMetadata? GetEpubFile(string path)
{
var fileInfo = _fileSystem.GetFileSystemInfo(path);
if (fileInfo.IsDirectory)
{
return null;
}
if (!string.Equals(Path.GetExtension(fileInfo.FullName), ".epub", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return fileInfo;
}
private MetadataResult<Book>? ReadEpubAsZip(string path, CancellationToken cancellationToken)
{
using var epub = ZipFile.OpenRead(path);
var opfFilePath = EpubUtils.ReadContentFilePath(epub);
if (opfFilePath == null)
{
return null;
}
var opf = epub.GetEntry(opfFilePath);
if (opf == null)
{
return null;
}
using var opfStream = opf.Open();
var opfDocument = new XmlDocument();
opfDocument.Load(opfStream);
var utilities = new OpfReader<EpubProvider>(opfDocument, _logger);
return utilities.ReadOpfData(cancellationToken);
}
}
}
@@ -0,0 +1,35 @@
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Xml.Linq;
namespace MediaBrowser.Providers.Books.OpenPackagingFormat
{
/// <summary>
/// Utilities for EPUB files.
/// </summary>
public static class EpubUtils
{
/// <summary>
/// Attempt to read content from ZIP archive.
/// </summary>
/// <param name="epub">The ZIP archive.</param>
/// <returns>The content file path.</returns>
public static string? ReadContentFilePath(ZipArchive epub)
{
var container = epub.GetEntry(Path.Combine("META-INF", "container.xml"));
if (container == null)
{
return null;
}
using var containerStream = container.Open();
XNamespace containerNamespace = "urn:oasis:names:tc:opendocument:xmlns:container";
var containerDocument = XDocument.Load(containerStream);
var element = containerDocument.Descendants(containerNamespace + "rootfile").FirstOrDefault();
return element?.Attribute("full-path")?.Value;
}
}
}
@@ -0,0 +1,94 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Books.OpenPackagingFormat
{
/// <summary>
/// Provides metadata for book items that have an OPF file in the same directory. Supports the standard
/// content.opf filename, bespoke metadata.opf name from Calibre libraries, and OPF files that have the
/// same name as their respective books for directories with several books.
/// </summary>
public class OpfProvider : ILocalMetadataProvider<Book>, IHasItemChangeMonitor
{
private const string StandardOpfFile = "content.opf";
private const string CalibreOpfFile = "metadata.opf";
private readonly IFileSystem _fileSystem;
private readonly ILogger<OpfProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="OpfProvider"/> class.
/// </summary>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{OpfProvider}"/> interface.</param>
public OpfProvider(IFileSystem fileSystem, ILogger<OpfProvider> logger)
{
_fileSystem = fileSystem;
_logger = logger;
}
/// <inheritdoc />
public string Name => "Open Packaging Format";
/// <inheritdoc />
public bool HasChanged(BaseItem item, IDirectoryService directoryService)
{
var file = GetXmlFile(item.Path);
return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
}
/// <inheritdoc />
public Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
{
var path = GetXmlFile(info.Path).FullName;
try
{
return Task.FromResult(ReadOpfData(path, cancellationToken));
}
catch (FileNotFoundException)
{
return Task.FromResult(new MetadataResult<Book> { HasMetadata = false });
}
}
private FileSystemMetadata GetXmlFile(string path)
{
var fileInfo = _fileSystem.GetFileSystemInfo(path);
var directoryInfo = fileInfo.IsDirectory ? fileInfo : _fileSystem.GetDirectoryInfo(Path.GetDirectoryName(path)!);
// check for OPF with matching name first since it's the most specific filename
var specificFile = Path.Combine(directoryInfo.FullName, Path.GetFileNameWithoutExtension(path) + ".opf");
var file = _fileSystem.GetFileInfo(specificFile);
if (file.Exists)
{
return file;
}
file = _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, StandardOpfFile));
// check metadata.opf last since it's really only used by Calibre
return file.Exists ? file : _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, CalibreOpfFile));
}
private MetadataResult<Book> ReadOpfData(string file, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var doc = new XmlDocument();
doc.Load(file);
var utilities = new OpfReader<OpfProvider>(doc, _logger);
return utilities.ReadOpfData(cancellationToken);
}
}
}
@@ -0,0 +1,329 @@
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Xml;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Net;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Books.OpenPackagingFormat
{
/// <summary>
/// Methods used to pull metadata and other information from Open Packaging Format in XML objects.
/// </summary>
/// <typeparam name="TCategoryName">The type of category.</typeparam>
public class OpfReader<TCategoryName>
{
private const string DcNamespace = @"http://purl.org/dc/elements/1.1/";
private const string OpfNamespace = @"http://www.idpf.org/2007/opf";
private readonly XmlNamespaceManager _namespaceManager;
private readonly XmlDocument _document;
private readonly ILogger<TCategoryName> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="OpfReader{TCategoryName}"/> class.
/// </summary>
/// <param name="document">The XML document to parse.</param>
/// <param name="logger">Instance of the <see cref="ILogger{TCategoryName}"/> interface.</param>
public OpfReader(XmlDocument document, ILogger<TCategoryName> logger)
{
_document = document;
_logger = logger;
_namespaceManager = new XmlNamespaceManager(_document.NameTable);
_namespaceManager.AddNamespace("dc", DcNamespace);
_namespaceManager.AddNamespace("opf", OpfNamespace);
}
/// <summary>
/// Checks for the existence of a cover image.
/// </summary>
/// <param name="opfRootDirectory">The root directory in which the OPF file is located.</param>
/// <returns>Returns the found cover and its type or null.</returns>
public (string MimeType, string Path)? ReadCoverPath(string opfRootDirectory)
{
var coverImage = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@properties='cover-image']");
if (coverImage is not null)
{
return coverImage;
}
var coverId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='cover' and @media-type='image/*']");
if (coverId is not null)
{
return coverId;
}
var coverImageId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='*cover-image']");
if (coverImageId is not null)
{
return coverImageId;
}
var metaCoverImage = _document.SelectSingleNode("//opf:meta[@name='cover']", _namespaceManager);
var content = metaCoverImage?.Attributes?["content"]?.Value;
if (string.IsNullOrEmpty(content) || metaCoverImage is null)
{
return null;
}
var coverPath = Path.Combine("Images", content);
var coverFileManifest = _document.SelectSingleNode($"//opf:item[@href='{coverPath}']", _namespaceManager);
var mediaType = coverFileManifest?.Attributes?["media-type"]?.Value;
if (coverFileManifest?.Attributes is not null && !string.IsNullOrEmpty(mediaType) && IsValidImage(mediaType))
{
return (mediaType, Path.Combine(opfRootDirectory, coverPath));
}
var coverFileIdManifest = _document.SelectSingleNode($"//opf:item[@id='{content}']", _namespaceManager);
if (coverFileIdManifest is not null)
{
return ReadManifestItem(coverFileIdManifest, opfRootDirectory);
}
return null;
}
/// <summary>
/// Read all supported OPF data from the file.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The metadata result to update.</returns>
public MetadataResult<Book> ReadOpfData(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var book = CreateBookFromOpf();
var result = new MetadataResult<Book> { Item = book, HasMetadata = true };
FindAuthors(result);
ReadStringInto("//dc:language", language => result.ResultLanguage = language);
return result;
}
private Book CreateBookFromOpf()
{
var book = new Book
{
Name = FindMainTitle(),
ForcedSortName = FindSortTitle(),
};
ReadStringInto("//dc:description", summary => book.Overview = summary);
ReadStringInto("//dc:publisher", publisher => book.AddStudio(publisher));
ReadStringInto("//dc:identifier[@opf:scheme='AMAZON']", amazon => book.SetProviderId("Amazon", amazon));
ReadStringInto("//dc:identifier[@opf:scheme='GOOGLE']", google => book.SetProviderId("GoogleBooks", google));
ReadStringInto("//dc:identifier[@opf:scheme='ISBN']", isbn => book.SetProviderId("ISBN", isbn));
ReadStringInto("//dc:date", date =>
{
if (DateTime.TryParse(date, out var dateValue))
{
book.PremiereDate = dateValue.Date;
book.ProductionYear = dateValue.Date.Year;
}
});
var genreNodes = _document.SelectNodes("//dc:subject", _namespaceManager);
if (genreNodes?.Count > 0)
{
foreach (var node in genreNodes.Cast<XmlNode>().Where(node => !string.IsNullOrEmpty(node.InnerText) && !book.Genres.Contains(node.InnerText)))
{
// specification has no rules about content and some books combine every genre into a single element
foreach (var item in node.InnerText.Split(["/", "&", ",", ";", " - "], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
book.AddGenre(item);
}
}
}
ReadInt32AttributeInto("//opf:meta[@name='calibre:series_index']", index => book.IndexNumber = index);
ReadInt32AttributeInto("//opf:meta[@name='calibre:rating']", rating => book.CommunityRating = rating);
var seriesNameNode = _document.SelectSingleNode("//opf:meta[@name='calibre:series']", _namespaceManager);
if (!string.IsNullOrEmpty(seriesNameNode?.Attributes?["content"]?.Value))
{
try
{
book.SeriesName = seriesNameNode.Attributes["content"]?.Value;
}
catch (Exception)
{
_logger.LogError("error parsing Calibre series name");
}
}
return book;
}
private string FindMainTitle()
{
var title = string.Empty;
var titleTypes = _document.SelectNodes("//opf:meta[@property='title-type']", _namespaceManager);
if (titleTypes is not null && titleTypes.Count > 0)
{
foreach (XmlElement titleNode in titleTypes)
{
string refines = titleNode.GetAttribute("refines").TrimStart('#');
string titleType = titleNode.InnerText;
var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager);
if (titleElement is not null && string.Equals(titleType, "main", StringComparison.OrdinalIgnoreCase))
{
title = titleElement.InnerText;
}
}
}
// fallback in case there is no main title definition
if (string.IsNullOrEmpty(title))
{
ReadStringInto("//dc:title", titleString => title = titleString);
}
return title;
}
private string? FindSortTitle()
{
var titleTypes = _document.SelectNodes("//opf:meta[@property='file-as']", _namespaceManager);
if (titleTypes is not null && titleTypes.Count > 0)
{
foreach (XmlElement titleNode in titleTypes)
{
string refines = titleNode.GetAttribute("refines").TrimStart('#');
string sortTitle = titleNode.InnerText;
var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager);
if (titleElement is not null)
{
return sortTitle;
}
}
}
// search for OPF 2.0 style title_sort node
var resultElement = _document.SelectSingleNode("//opf:meta[@name='calibre:title_sort']", _namespaceManager);
var titleSort = resultElement?.Attributes?["content"]?.Value;
return titleSort;
}
private void FindAuthors(MetadataResult<Book> book)
{
var resultElement = _document.SelectNodes("//dc:creator", _namespaceManager);
if (resultElement != null && resultElement.Count > 0)
{
foreach (XmlElement creator in resultElement)
{
var creatorName = creator.InnerText;
var role = creator.GetAttribute("opf:role");
var person = new PersonInfo { Name = creatorName, Type = GetRole(role) };
book.AddPerson(person);
}
}
}
private PersonKind GetRole(string? role)
{
switch (role)
{
case "arr":
return PersonKind.Arranger;
case "art":
return PersonKind.Artist;
case "aut":
case "aqt":
case "aft":
case "aui":
default:
return PersonKind.Author;
case "edt":
return PersonKind.Editor;
case "ill":
return PersonKind.Illustrator;
case "lyr":
return PersonKind.Lyricist;
case "mus":
return PersonKind.AlbumArtist;
case "oth":
return PersonKind.Unknown;
case "trl":
return PersonKind.Translator;
}
}
private void ReadStringInto(string xmlPath, Action<string> commitResult)
{
var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.InnerText))
{
commitResult(resultElement.InnerText);
}
}
private void ReadInt32AttributeInto(string xmlPath, Action<int> commitResult)
{
var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
var resultValue = resultElement?.Attributes?["content"]?.Value;
if (!string.IsNullOrEmpty(resultValue))
{
try
{
commitResult(Convert.ToInt32(Convert.ToDouble(resultValue, CultureInfo.InvariantCulture)));
}
catch (Exception e)
{
_logger.LogError(e, "error converting to Int32");
}
}
}
private (string MimeType, string Path)? ReadEpubCoverInto(string opfRootDirectory, string xmlPath)
{
var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
if (resultElement is not null)
{
return ReadManifestItem(resultElement, opfRootDirectory);
}
return null;
}
private (string MimeType, string Path)? ReadManifestItem(XmlNode manifestNode, string opfRootDirectory)
{
var href = manifestNode.Attributes?["href"]?.Value;
var mediaType = manifestNode.Attributes?["media-type"]?.Value;
if (string.IsNullOrEmpty(href) || string.IsNullOrEmpty(mediaType) || !IsValidImage(mediaType))
{
return null;
}
var coverPath = Path.Combine(opfRootDirectory, href);
return (MimeType: mediaType, Path: coverPath);
}
private static bool IsValidImage(string? mimeType)
{
return !string.IsNullOrEmpty(mimeType) && !string.IsNullOrWhiteSpace(MimeTypes.ToExtension(mimeType));
}
}
}
@@ -0,0 +1,93 @@
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.BoxSets;
/// <summary>
/// Service to manage boxset metadata.
/// </summary>
public class BoxSetMetadataService : MetadataService<BoxSet, BoxSetInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="BoxSetMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public BoxSetMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<BoxSetMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
/// <inheritdoc />
protected override bool EnableUpdatingGenresFromChildren => true;
/// <inheritdoc />
protected override bool EnableUpdatingOfficialRatingFromChildren => true;
/// <inheritdoc />
protected override bool EnableUpdatingStudiosFromChildren => true;
/// <inheritdoc />
protected override bool EnableUpdatingPremiereDateFromChildren => true;
/// <inheritdoc />
protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(BoxSet item)
{
return item.GetLinkedChildren();
}
/// <inheritdoc />
protected override void MergeData(MetadataResult<BoxSet> source, MetadataResult<BoxSet> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
var sourceItem = source.Item;
var targetItem = target.Item;
if (mergeMetadataSettings)
{
// TODO: Change to only replace when currently empty or requested. This is currently not done because the metadata service is not handling attaching collection items based on the provider responses
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray();
}
}
/// <inheritdoc />
protected override ItemUpdateType BeforeSaveInternal(BoxSet item, bool isFullRefresh, ItemUpdateType updateType)
{
var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType);
var libraryFolderIds = item.GetLibraryFolderIds();
var itemLibraryFolderIds = item.LibraryFolderIds;
if (itemLibraryFolderIds is null || !libraryFolderIds.SequenceEqual(itemLibraryFolderIds))
{
item.LibraryFolderIds = libraryFolderIds;
updatedType |= ItemUpdateType.MetadataImport;
}
return updatedType;
}
}
@@ -0,0 +1,39 @@
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Channels;
/// <summary>
/// Service to manage channel metadata.
/// </summary>
public class ChannelMetadataService : MetadataService<Channel, ItemLookupInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="ChannelMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public ChannelMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<ChannelMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
}
@@ -0,0 +1,39 @@
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Folders;
/// <summary>
/// Service to manage collection folder metadata.
/// </summary>
public class CollectionFolderMetadataService : MetadataService<CollectionFolder, ItemLookupInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="CollectionFolderMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public CollectionFolderMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<CollectionFolderMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
}
@@ -0,0 +1,43 @@
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Folders;
/// <summary>
/// Service to manage folder metadata.
/// </summary>
public class FolderMetadataService : MetadataService<Folder, ItemLookupInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="FolderMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public FolderMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<FolderMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
/// <inheritdoc />
// Make sure the type-specific services get picked first
public override int Order => 10;
}
@@ -0,0 +1,39 @@
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Folders;
/// <summary>
/// Service to manage user view metadata.
/// </summary>
public class UserViewMetadataService : MetadataService<UserView, ItemLookupInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="UserViewMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public UserViewMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<UserViewMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
}
@@ -0,0 +1,39 @@
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Genres;
/// <summary>
/// Service to manage genre metadata.
/// </summary>
public class GenreMetadataService : MetadataService<Genre, ItemLookupInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="GenreMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public GenreMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<GenreMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
}
@@ -0,0 +1,39 @@
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.LiveTv;
/// <summary>
/// Service to manage live TV metadata.
/// </summary>
public class LiveTvMetadataService : MetadataService<LiveTvChannel, ItemLookupInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="LiveTvMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public LiveTvMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<LiveTvMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
}
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Jellyfin.Extensions;
using LrcParser.Model;
using LrcParser.Parser;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Model.Lyrics;
namespace MediaBrowser.Providers.Lyric;
/// <summary>
/// LRC Lyric Parser.
/// </summary>
public partial class LrcLyricParser : ILyricParser
{
private readonly LyricParser _lrcLyricParser;
private static readonly string[] _supportedMediaTypes = [".lrc", ".elrc"];
/// <summary>
/// Initializes a new instance of the <see cref="LrcLyricParser"/> class.
/// </summary>
public LrcLyricParser()
{
_lrcLyricParser = new LrcParser.Parser.Lrc.LrcParser();
}
/// <inheritdoc />
public string Name => "LrcLyricProvider";
/// <summary>
/// Gets the priority.
/// </summary>
/// <value>The priority.</value>
public ResolverPriority Priority => ResolverPriority.Fourth;
/// <inheritdoc />
public LyricDto? ParseLyrics(LyricFile lyrics)
{
if (!_supportedMediaTypes.Contains(Path.GetExtension(lyrics.Name.AsSpan()), StringComparison.OrdinalIgnoreCase))
{
return null;
}
Song lyricData;
try
{
lyricData = _lrcLyricParser.Decode(lyrics.Content);
}
catch (Exception)
{
// Failed to parse, return null so the next parser will be tried
return null;
}
List<LrcParser.Model.Lyric> sortedLyricData = lyricData.Lyrics.OrderBy(x => x.StartTime).ToList();
if (sortedLyricData.Count == 0)
{
return null;
}
List<LyricLine> lyricList = [];
for (var lineIndex = 0; lineIndex < sortedLyricData.Count; lineIndex++)
{
var lyric = sortedLyricData[lineIndex];
// Extract cues from time tags
var cues = new List<LyricLineCue>();
if (lyric.TimeTags.Count > 0)
{
var keys = lyric.TimeTags.Keys.ToList();
for (var tagIndex = 0; tagIndex < keys.Count - 1; tagIndex++)
{
var currentKey = keys[tagIndex];
var nextKey = keys[tagIndex + 1];
var currentPos = currentKey.State == IndexState.End ? currentKey.Index + 1 : currentKey.Index;
var nextPos = nextKey.State == IndexState.End ? nextKey.Index + 1 : nextKey.Index;
var currentMs = lyric.TimeTags[currentKey] ?? 0;
var nextMs = lyric.TimeTags[keys[tagIndex + 1]] ?? 0;
var currentSlice = lyric.Text[currentPos..nextPos];
var currentSliceTrimmed = currentSlice.Trim();
if (currentSliceTrimmed.Length > 0)
{
cues.Add(new LyricLineCue(
position: currentPos,
endPosition: nextPos,
start: TimeSpan.FromMilliseconds(currentMs).Ticks,
end: TimeSpan.FromMilliseconds(nextMs).Ticks));
}
}
var lastKey = keys[^1];
var lastPos = lastKey.State == IndexState.End ? lastKey.Index + 1 : lastKey.Index;
var lastMs = lyric.TimeTags[lastKey] ?? 0;
var lastSlice = lyric.Text[lastPos..];
var lastSliceTrimmed = lastSlice.Trim();
if (lastSliceTrimmed.Length > 0)
{
cues.Add(new LyricLineCue(
position: lastPos,
endPosition: lyric.Text.Length,
start: TimeSpan.FromMilliseconds(lastMs).Ticks,
end: lineIndex + 1 < sortedLyricData.Count ? TimeSpan.FromMilliseconds(sortedLyricData[lineIndex + 1].StartTime).Ticks : null));
}
}
long lyricStartTicks = TimeSpan.FromMilliseconds(lyric.StartTime).Ticks;
lyricList.Add(new LyricLine(lyric.Text, lyricStartTicks, cues));
}
return new LyricDto { Lyrics = lyricList };
}
}
@@ -0,0 +1,468 @@
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;
/// <summary>
/// Lyric Manager.
/// </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="logger">Instance of the <see cref="ILogger{LyricManager}"/> interface.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param>
/// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param>
public LyricManager(
ILogger<LyricManager> logger,
IFileSystem fileSystem,
ILibraryMonitor libraryMonitor,
IMediaSourceManager mediaSourceManager,
IEnumerable<ILyricProvider> lyricProviders,
IEnumerable<ILyricParser> lyricParsers)
{
_logger = logger;
_fileSystem = fileSystem;
_libraryMonitor = libraryMonitor;
_mediaSourceManager = mediaSourceManager;
_lyricProviders = lyricProviders
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
.ToArray();
_lyricParsers = lyricParsers
.OrderBy(l => l.Priority)
.ToArray();
}
/// <inheritdoc />
public event EventHandler<LyricDownloadFailureEventArgs>? LyricDownloadFailure;
/// <inheritdoc />
public Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(Audio audio, bool isAutomated, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(audio);
var request = new LyricSearchRequest
{
MediaPath = audio.Path,
SongName = audio.Name,
AlbumName = audio.Album,
AlbumArtistsNames = audio.AlbumArtists,
ArtistNames = audio.Artists,
Duration = audio.RunTimeTicks,
IsAutomated = isAutomated
};
return SearchLyricsAsync(request, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(LyricSearchRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var providers = _lyricProviders
.Where(i => !request.DisabledLyricFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase))
.OrderBy(i =>
{
var index = request.LyricFetcherOrder.IndexOf(i.Name);
return index == -1 ? int.MaxValue : index;
})
.ToArray();
// If not searching all, search one at a time until something is found
if (!request.SearchAllProviders)
{
foreach (var provider in providers)
{
var providerResult = await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false);
if (providerResult.Count > 0)
{
return providerResult;
}
}
return [];
}
var tasks = providers.Select(async provider => await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false));
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
return results.SelectMany(i => i).ToArray();
}
/// <inheritdoc />
public Task<LyricDto?> DownloadLyricsAsync(Audio audio, string lyricId, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(audio);
ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
return DownloadLyricsAsync(audio, libraryOptions, lyricId, cancellationToken);
}
/// <inheritdoc />
public async Task<LyricDto?> DownloadLyricsAsync(Audio audio, LibraryOptions libraryOptions, string lyricId, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(audio);
ArgumentNullException.ThrowIfNull(libraryOptions);
ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
var provider = GetProvider(lyricId.AsSpan().LeftPart('_').ToString());
if (provider is null)
{
return null;
}
try
{
var response = await InternalGetRemoteLyricsAsync(lyricId, cancellationToken).ConfigureAwait(false);
if (response is null)
{
_logger.LogDebug("Unable to download lyrics for {LyricId}", lyricId);
return null;
}
var parsedLyrics = await InternalParseRemoteLyricsAsync(response.Format, response.Stream, cancellationToken).ConfigureAwait(false);
if (parsedLyrics is null)
{
return null;
}
await TrySaveLyric(audio, libraryOptions, response.Format, response.Stream).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?> SaveLyricAsync(Audio audio, string format, string lyrics)
{
ArgumentNullException.ThrowIfNull(audio);
ArgumentException.ThrowIfNullOrEmpty(format);
ArgumentException.ThrowIfNullOrEmpty(lyrics);
var bytes = Encoding.UTF8.GetBytes(lyrics);
using var lyricStream = new MemoryStream(bytes, 0, bytes.Length, false, true);
return await SaveLyricAsync(audio, format, lyricStream).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<LyricDto?> SaveLyricAsync(Audio audio, string format, Stream lyrics)
{
ArgumentNullException.ThrowIfNull(audio);
ArgumentException.ThrowIfNullOrEmpty(format);
ArgumentNullException.ThrowIfNull(lyrics);
var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
var parsed = await InternalParseRemoteLyricsAsync(format, lyrics, CancellationToken.None).ConfigureAwait(false);
if (parsed is null)
{
return null;
}
await TrySaveLyric(audio, libraryOptions, format, lyrics).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.Format, lyricResponse.Stream, 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;
}
}
}
return null;
}
private ILyricProvider? GetProvider(string providerId)
{
var provider = _lyricProviders.FirstOrDefault(p => string.Equals(providerId, GetProviderId(p.Name), StringComparison.Ordinal));
if (provider is null)
{
_logger.LogWarning("Unknown provider id: {ProviderId}", providerId.ReplaceLineEndings(string.Empty));
}
return provider;
}
private string GetProviderId(string name)
=> name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
private async Task<LyricDto?> InternalParseRemoteLyricsAsync(string format, Stream lyricStream, CancellationToken cancellationToken)
{
lyricStream.Seek(0, SeekOrigin.Begin);
using var streamReader = new StreamReader(lyricStream, leaveOpen: true);
var lyrics = await streamReader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var lyricFile = new LyricFile($"lyric.{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.Format, result.Lyrics.Stream, cancellationToken).ConfigureAwait(false);
if (parsedLyrics is null)
{
continue;
}
parsedLyrics.Metadata = result.Metadata;
parsedResults.Add(new RemoteLyricInfoDto
{
Id = $"{providerId}_{result.Id}",
ProviderName = result.ProviderName,
Lyrics = parsedLyrics
});
}
return parsedResults;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading lyrics from {Provider}", provider.Name);
return [];
}
}
private async Task TrySaveLyric(
Audio audio,
LibraryOptions libraryOptions,
string format,
Stream lyricStream)
{
var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia;
var memoryStream = new MemoryStream();
await using (memoryStream.ConfigureAwait(false))
{
await using (lyricStream.ConfigureAwait(false))
{
lyricStream.Seek(0, SeekOrigin.Begin);
await lyricStream.CopyToAsync(memoryStream).ConfigureAwait(false);
memoryStream.Seek(0, SeekOrigin.Begin);
}
var savePaths = new List<string>();
var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + 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);
}
}
}
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
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.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Lyrics;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Lyric;
/// <summary>
/// Task to download lyrics.
/// </summary>
public class LyricScheduledTask : IScheduledTask
{
private const int QueryPageLimit = 100;
private static readonly BaseItemKind[] _itemKinds = [BaseItemKind.Audio];
private static readonly MediaType[] _mediaTypes = [MediaType.Audio];
private static readonly SourceType[] _sourceTypes = [SourceType.Library];
private static readonly DtoOptions _dtoOptions = new(false);
private readonly ILibraryManager _libraryManager;
private readonly ILyricManager _lyricManager;
private readonly ILogger<LyricScheduledTask> _logger;
private readonly ILocalizationManager _localizationManager;
/// <summary>
/// Initializes a new instance of the <see cref="LyricScheduledTask"/> 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="logger">Instance of the <see cref="ILogger{DownloaderScheduledTask}"/> interface.</param>
/// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
public LyricScheduledTask(
ILibraryManager libraryManager,
ILyricManager lyricManager,
ILogger<LyricScheduledTask> logger,
ILocalizationManager localizationManager)
{
_libraryManager = libraryManager;
_lyricManager = lyricManager;
_logger = logger;
_localizationManager = localizationManager;
}
/// <inheritdoc />
public string Name => _localizationManager.GetLocalizedString("TaskDownloadMissingLyrics");
/// <inheritdoc />
public string Key => "DownloadLyrics";
/// <inheritdoc />
public string Description => _localizationManager.GetLocalizedString("TaskDownloadMissingLyricsDescription");
/// <inheritdoc />
public string Category => _localizationManager.GetLocalizedString("TasksLibraryCategory");
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var totalCount = _libraryManager.GetCount(new InternalItemsQuery
{
Recursive = true,
IsVirtualItem = false,
IncludeItemTypes = _itemKinds,
DtoOptions = _dtoOptions,
MediaTypes = _mediaTypes,
SourceTypes = _sourceTypes
});
var completed = 0;
foreach (var library in _libraryManager.RootFolder.Children.ToList())
{
var libraryOptions = _libraryManager.GetLibraryOptions(library);
var itemQuery = new InternalItemsQuery
{
Recursive = true,
IsVirtualItem = false,
IncludeItemTypes = _itemKinds,
DtoOptions = _dtoOptions,
MediaTypes = _mediaTypes,
SourceTypes = _sourceTypes,
Limit = QueryPageLimit,
Parent = library
};
int previousCount;
var startIndex = 0;
do
{
itemQuery.StartIndex = startIndex;
var audioItems = _libraryManager.GetItemList(itemQuery);
foreach (var audioItem in audioItems.OfType<Audio>())
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (audioItem.GetMediaStreams().All(s => s.Type != MediaStreamType.Lyric))
{
_logger.LogDebug("Searching for lyrics for {Path}", audioItem.Path);
var lyricResults = await _lyricManager.SearchLyricsAsync(
new LyricSearchRequest
{
MediaPath = audioItem.Path,
SongName = audioItem.Name,
AlbumName = audioItem.Album,
AlbumArtistsNames = audioItem.AlbumArtists,
ArtistNames = audioItem.Artists,
Duration = audioItem.RunTimeTicks,
IsAutomated = true,
DisabledLyricFetchers = libraryOptions.DisabledLyricFetchers,
LyricFetcherOrder = libraryOptions.LyricFetcherOrder
},
cancellationToken)
.ConfigureAwait(false);
if (lyricResults.Count != 0)
{
_logger.LogDebug("Saving lyrics for {Path}", audioItem.Path);
await _lyricManager.DownloadLyricsAsync(
audioItem,
libraryOptions,
lyricResults[0].Id,
cancellationToken)
.ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading lyrics for {Path}", audioItem.Path);
}
completed++;
progress.Report(100d * completed / totalCount);
}
startIndex += QueryPageLimit;
previousCount = audioItems.Count;
} while (previousCount > 0);
}
progress.Report(100);
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return
[
new TaskTriggerInfo
{
Type = TaskTriggerInfoType.IntervalTrigger,
IntervalTicks = TimeSpan.FromHours(24).Ticks
}
];
}
}
@@ -0,0 +1,45 @@
using System;
using System.IO;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Model.Lyrics;
namespace MediaBrowser.Providers.Lyric;
/// <summary>
/// TXT Lyric Parser.
/// </summary>
public class TxtLyricParser : ILyricParser
{
private static readonly string[] _supportedMediaTypes = [".lrc", ".elrc", ".txt"];
private static readonly string[] _lineBreakCharacters = ["\r\n", "\r", "\n"];
/// <inheritdoc />
public string Name => "TxtLyricProvider";
/// <summary>
/// Gets the priority.
/// </summary>
/// <value>The priority.</value>
public ResolverPriority Priority => ResolverPriority.Fifth;
/// <inheritdoc />
public LyricDto? ParseLyrics(LyricFile lyrics)
{
if (!_supportedMediaTypes.Contains(Path.GetExtension(lyrics.Name.AsSpan()), StringComparison.OrdinalIgnoreCase))
{
return null;
}
string[] lyricTextLines = lyrics.Content.Split(_lineBreakCharacters, StringSplitOptions.None);
LyricLine[] lyricList = new LyricLine[lyricTextLines.Length];
for (int lyricLineIndex = 0; lyricLineIndex < lyricTextLines.Length; lyricLineIndex++)
{
lyricList[lyricLineIndex] = new LyricLine(lyricTextLines[lyricLineIndex].Trim());
}
return new LyricDto { Lyrics = lyricList };
}
}
@@ -0,0 +1,718 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Net;
using Microsoft.Extensions.Logging;
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum;
using Person = MediaBrowser.Controller.Entities.Person;
using Season = MediaBrowser.Controller.Entities.TV.Season;
namespace MediaBrowser.Providers.Manager
{
/// <summary>
/// Class ImageSaver.
/// </summary>
public class ImageSaver
{
/// <summary>
/// The _config.
/// </summary>
private readonly IServerConfigurationManager _config;
/// <summary>
/// The _directory watchers.
/// </summary>
private readonly ILibraryMonitor _libraryMonitor;
private readonly IFileSystem _fileSystem;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ImageSaver" /> class.
/// </summary>
/// <param name="config">The config.</param>
/// <param name="libraryMonitor">The directory watchers.</param>
/// <param name="fileSystem">The file system.</param>
/// <param name="logger">The logger.</param>
public ImageSaver(IServerConfigurationManager config, ILibraryMonitor libraryMonitor, IFileSystem fileSystem, ILogger logger)
{
_config = config;
_libraryMonitor = libraryMonitor;
_fileSystem = fileSystem;
_logger = logger;
}
private bool EnableExtraThumbsDuplication
{
get
{
var config = _config.GetConfiguration<XbmcMetadataOptions>("xbmcmetadata");
return config.EnableExtraThumbsDuplication;
}
}
/// <summary>
/// Saves the image.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="source">The source.</param>
/// <param name="mimeType">Type of the MIME.</param>
/// <param name="type">The type.</param>
/// <param name="imageIndex">Index of the image.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
/// <exception cref="ArgumentNullException">mimeType.</exception>
public Task SaveImage(BaseItem item, Stream source, string mimeType, ImageType type, int? imageIndex, CancellationToken cancellationToken)
{
return SaveImage(item, source, mimeType, type, imageIndex, null, cancellationToken);
}
public async Task SaveImage(BaseItem item, Stream source, string mimeType, ImageType type, int? imageIndex, bool? saveLocallyWithMedia, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(mimeType);
var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && item is not Audio;
if (type != ImageType.Primary && item is Episode)
{
saveLocally = false;
}
if (!item.IsFileProtocol)
{
saveLocally = false;
// If season is virtual under a physical series, save locally
if (item is Season season)
{
var series = season.Series;
if (series is not null && series.SupportsLocalMetadata && series.IsSaveLocalMetadataEnabled())
{
saveLocally = true;
}
}
}
if (saveLocallyWithMedia.HasValue && !saveLocallyWithMedia.Value)
{
saveLocally = saveLocallyWithMedia.Value;
}
if (!imageIndex.HasValue && item.AllowsMultipleImages(type))
{
imageIndex = item.GetImages(type).Count();
}
var index = imageIndex ?? 0;
var paths = GetSavePaths(item, type, imageIndex, mimeType, saveLocally);
string[] retryPaths = [];
if (saveLocally)
{
retryPaths = GetSavePaths(item, type, imageIndex, mimeType, false);
}
// If there are more than one output paths, the stream will need to be seekable
if (paths.Length > 1 && !source.CanSeek)
{
var memoryStream = new MemoryStream();
await using (source.ConfigureAwait(false))
{
await source.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
}
source = memoryStream;
}
var currentImage = GetCurrentImage(item, type, index);
var currentImageIsLocalFile = currentImage is not null && currentImage.IsLocalFile;
var currentImagePath = currentImage?.Path;
var savedPaths = new List<string>();
await using (source.ConfigureAwait(false))
{
for (int i = 0; i < paths.Length; i++)
{
if (i != 0)
{
source.Position = 0;
}
string retryPath = null;
if (paths.Length == retryPaths.Length)
{
retryPath = retryPaths[i];
}
var savedPath = await SaveImageToLocation(source, paths[i], retryPath, cancellationToken).ConfigureAwait(false);
savedPaths.Add(savedPath);
}
}
// Set the path into the item
SetImagePath(item, type, imageIndex, savedPaths[0]);
// Delete the current path
if (currentImageIsLocalFile
&& !savedPaths.Contains(currentImagePath, StringComparison.OrdinalIgnoreCase)
&& (saveLocally || currentImagePath.Contains(_config.ApplicationPaths.InternalMetadataPath, StringComparison.OrdinalIgnoreCase)))
{
var currentPath = currentImagePath;
_logger.LogInformation("Deleting previous image {0}", currentPath);
_libraryMonitor.ReportFileSystemChangeBeginning(currentPath);
try
{
_fileSystem.DeleteFile(currentPath);
// Remove local episode metadata directory if it exists and is empty
var directory = Path.GetDirectoryName(currentPath);
if (item is Episode && directory.Equals("metadata", StringComparison.Ordinal))
{
var parentDirectoryPath = Directory.GetParent(currentPath).FullName;
if (_fileSystem.DirectoryExists(parentDirectoryPath) && !_fileSystem.GetFiles(parentDirectoryPath).Any())
{
try
{
_logger.LogInformation("Deleting empty local metadata folder {Folder}", parentDirectoryPath);
Directory.Delete(parentDirectoryPath);
}
catch (UnauthorizedAccessException ex)
{
_logger.LogError(ex, "Error deleting directory {Path}", parentDirectoryPath);
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting directory {Path}", parentDirectoryPath);
}
}
}
}
catch (FileNotFoundException)
{
}
finally
{
_libraryMonitor.ReportFileSystemChangeComplete(currentPath, false);
}
}
}
public async Task SaveImage(Stream source, string path)
{
await SaveImageToLocation(source, path, path, CancellationToken.None).ConfigureAwait(false);
}
private async Task<string> SaveImageToLocation(Stream source, string path, string retryPath, CancellationToken cancellationToken)
{
try
{
await SaveImageToLocation(source, path, cancellationToken).ConfigureAwait(false);
return path;
}
catch (UnauthorizedAccessException)
{
var retry = !string.IsNullOrWhiteSpace(retryPath) &&
!string.Equals(path, retryPath, StringComparison.OrdinalIgnoreCase);
if (retry)
{
_logger.LogError("UnauthorizedAccessException - Access to path {0} is denied. Will retry saving to {1}", path, retryPath);
}
else
{
throw;
}
}
catch (IOException ex)
{
var retry = !string.IsNullOrWhiteSpace(retryPath) &&
!string.Equals(path, retryPath, StringComparison.OrdinalIgnoreCase);
if (retry)
{
_logger.LogError(ex, "IOException saving to {0}. Will retry saving to {1}", path, retryPath);
}
else
{
throw;
}
}
await SaveImageToLocation(source, retryPath, cancellationToken).ConfigureAwait(false);
return retryPath;
}
/// <summary>
/// Saves the image to location.
/// </summary>
/// <param name="source">The source.</param>
/// <param name="path">The path.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
private async Task SaveImageToLocation(Stream source, string path, CancellationToken cancellationToken)
{
_logger.LogDebug("Saving image to {0}", path);
var parentFolder = Path.GetDirectoryName(path);
try
{
_libraryMonitor.ReportFileSystemChangeBeginning(path);
_libraryMonitor.ReportFileSystemChangeBeginning(parentFolder);
Directory.CreateDirectory(Path.GetDirectoryName(path));
_fileSystem.SetAttributes(path, false, false);
var fileStreamOptions = AsyncFile.WriteOptions;
fileStreamOptions.Mode = FileMode.Create;
fileStreamOptions.Options = FileOptions.WriteThrough;
if (source.CanSeek)
{
fileStreamOptions.PreallocationSize = source.Length;
}
var fs = new FileStream(path, fileStreamOptions);
await using (fs.ConfigureAwait(false))
{
await source.CopyToAsync(fs, cancellationToken).ConfigureAwait(false);
}
if (_config.Configuration.SaveMetadataHidden)
{
SetHidden(path, true);
}
}
finally
{
_libraryMonitor.ReportFileSystemChangeComplete(path, false);
_libraryMonitor.ReportFileSystemChangeComplete(parentFolder, false);
}
}
private void SetHidden(string path, bool hidden)
{
try
{
_fileSystem.SetHidden(path, hidden);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error setting hidden attribute on {0}", path);
}
}
/// <summary>
/// Gets the save paths.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="type">The type.</param>
/// <param name="imageIndex">Index of the image.</param>
/// <param name="mimeType">Type of the MIME.</param>
/// <param name="saveLocally">if set to <c>true</c> [save locally].</param>
/// <returns>IEnumerable{System.String}.</returns>
private string[] GetSavePaths(BaseItem item, ImageType type, int? imageIndex, string mimeType, bool saveLocally)
{
if (!saveLocally || (_config.Configuration.ImageSavingConvention == ImageSavingConvention.Legacy))
{
return new[] { GetStandardSavePath(item, type, imageIndex, mimeType, saveLocally) };
}
return GetCompatibleSavePaths(item, type, imageIndex, mimeType);
}
/// <summary>
/// Gets the current image path.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="type">The type.</param>
/// <param name="imageIndex">Index of the image.</param>
/// <returns>System.String.</returns>
/// <exception cref="ArgumentNullException">
/// imageIndex
/// or
/// imageIndex.
/// </exception>
private ItemImageInfo GetCurrentImage(BaseItem item, ImageType type, int imageIndex)
{
return item.GetImageInfo(type, imageIndex);
}
/// <summary>
/// Sets the image path.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="type">The type.</param>
/// <param name="imageIndex">Index of the image.</param>
/// <param name="path">The path.</param>
/// <exception cref="ArgumentNullException">imageIndex
/// or
/// imageIndex.
/// </exception>
private void SetImagePath(BaseItem item, ImageType type, int? imageIndex, string path)
{
item.SetImagePath(type, imageIndex ?? 0, _fileSystem.GetFileInfo(path));
}
/// <summary>
/// Gets the save path.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="type">The type.</param>
/// <param name="imageIndex">Index of the image.</param>
/// <param name="mimeType">Type of the MIME.</param>
/// <param name="saveLocally">if set to <c>true</c> [save locally].</param>
/// <returns>System.String.</returns>
/// <exception cref="ArgumentNullException">
/// imageIndex
/// or
/// imageIndex.
/// </exception>
private string GetStandardSavePath(BaseItem item, ImageType type, int? imageIndex, string mimeType, bool saveLocally)
{
var season = item as Season;
var extension = MimeTypes.ToExtension(mimeType);
if (string.IsNullOrWhiteSpace(extension))
{
throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Unable to determine image file extension from mime type {0}", mimeType));
}
if (string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase))
{
extension = ".jpg";
}
extension = extension.ToLowerInvariant();
if (type == ImageType.Primary && saveLocally)
{
if (season is not null && season.IndexNumber.HasValue)
{
var seriesFolder = season.SeriesPath;
var seasonMarker = season.IndexNumber.Value == 0
? "-specials"
: season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
var imageFilename = "season" + seasonMarker + "-poster" + extension;
return Path.Combine(seriesFolder, imageFilename);
}
}
if (type == ImageType.Backdrop && saveLocally)
{
if (season is not null
&& season.IndexNumber.HasValue
&& (imageIndex is null || imageIndex == 0))
{
var seriesFolder = season.SeriesPath;
var seasonMarker = season.IndexNumber.Value == 0
? "-specials"
: season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
var imageFilename = "season" + seasonMarker + "-fanart" + extension;
return Path.Combine(seriesFolder, imageFilename);
}
}
if (type == ImageType.Thumb && saveLocally)
{
if (season is not null && season.IndexNumber.HasValue)
{
var seriesFolder = season.SeriesPath;
var seasonMarker = season.IndexNumber.Value == 0
? "-specials"
: season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
var imageFilename = "season" + seasonMarker + "-landscape" + extension;
return Path.Combine(seriesFolder, imageFilename);
}
if (item.IsInMixedFolder)
{
return GetSavePathForItemInMixedFolder(item, type, "landscape", extension);
}
return Path.Combine(item.ContainingFolderPath, "landscape" + extension);
}
if (type == ImageType.Banner && saveLocally)
{
if (season is not null && season.IndexNumber.HasValue)
{
var seriesFolder = season.SeriesPath;
var seasonMarker = season.IndexNumber.Value == 0
? "-specials"
: season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
var imageFilename = "season" + seasonMarker + "-banner" + extension;
return Path.Combine(seriesFolder, imageFilename);
}
}
if (type == ImageType.Logo && saveLocally)
{
if (season is not null && season.IndexNumber.HasValue)
{
var seriesFolder = season.SeriesPath;
var seasonMarker = season.IndexNumber.Value == 0
? "-specials"
: season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
var imageFilename = "season" + seasonMarker + "-logo" + extension;
return Path.Combine(seriesFolder, imageFilename);
}
}
string filename;
var folderName = item is MusicAlbum ||
item is MusicArtist ||
item is PhotoAlbum ||
item is Person ||
(saveLocally && _config.Configuration.ImageSavingConvention == ImageSavingConvention.Legacy) ?
"folder" :
"poster";
switch (type)
{
case ImageType.Art:
filename = "clearart";
break;
case ImageType.BoxRear:
filename = "back";
break;
case ImageType.Thumb:
filename = "landscape";
break;
case ImageType.Disc:
filename = item is MusicAlbum ? "cdart" : "disc";
break;
case ImageType.Primary:
filename = saveLocally && item is Episode ? Path.GetFileNameWithoutExtension(item.Path) : folderName;
break;
case ImageType.Backdrop:
filename = GetBackdropSaveFilename(item.GetImages(type), "backdrop", "backdrop", imageIndex);
break;
default:
filename = type.ToString().ToLowerInvariant();
break;
}
string path = null;
if (saveLocally)
{
if (type == ImageType.Primary && item is Episode)
{
path = Path.Combine(Path.GetDirectoryName(item.Path), filename + "-thumb" + extension);
}
else if (item.IsInMixedFolder)
{
path = GetSavePathForItemInMixedFolder(item, type, filename, extension);
}
if (string.IsNullOrEmpty(path))
{
path = Path.Combine(item.ContainingFolderPath, filename + extension);
}
}
// None of the save local conditions passed, so store it in our internal folders
if (string.IsNullOrEmpty(path))
{
if (string.IsNullOrEmpty(filename))
{
filename = folderName;
}
path = Path.Combine(item.GetInternalMetadataPath(), filename + extension);
}
return path;
}
private string GetBackdropSaveFilename(IEnumerable<ItemImageInfo> images, string zeroIndexFilename, string numberedIndexPrefix, int? index)
{
if (index.HasValue && index.Value == 0)
{
return zeroIndexFilename;
}
var filenames = images.Select(i => Path.GetFileNameWithoutExtension(i.Path)).ToList();
var current = 1;
while (filenames.Contains(numberedIndexPrefix + current.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase))
{
current++;
}
return numberedIndexPrefix + current.ToString(CultureInfo.InvariantCulture);
}
/// <summary>
/// Gets the compatible save paths.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="type">The type.</param>
/// <param name="imageIndex">Index of the image.</param>
/// <param name="mimeType">Type of the MIME.</param>
/// <returns>IEnumerable{System.String}.</returns>
/// <exception cref="ArgumentNullException">imageIndex.</exception>
private string[] GetCompatibleSavePaths(BaseItem item, ImageType type, int? imageIndex, string mimeType)
{
var season = item as Season;
var extension = MimeTypes.ToExtension(mimeType);
// Backdrop paths
if (type == ImageType.Backdrop)
{
if (!imageIndex.HasValue)
{
throw new ArgumentNullException(nameof(imageIndex));
}
if (imageIndex.Value == 0)
{
if (item.IsInMixedFolder)
{
return new[] { GetSavePathForItemInMixedFolder(item, type, "fanart", extension) };
}
if (season is not null && season.IndexNumber.HasValue)
{
var seriesFolder = season.SeriesPath;
var seasonMarker = season.IndexNumber.Value == 0
? "-specials"
: season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
var imageFilename = "season" + seasonMarker + "-fanart" + extension;
return new[] { Path.Combine(seriesFolder, imageFilename) };
}
return new[]
{
Path.Combine(item.ContainingFolderPath, "fanart" + extension)
};
}
var outputIndex = imageIndex.Value;
if (item.IsInMixedFolder)
{
return new[] { GetSavePathForItemInMixedFolder(item, type, "fanart" + outputIndex.ToString(CultureInfo.InvariantCulture), extension) };
}
var extraFanartFilename = GetBackdropSaveFilename(item.GetImages(ImageType.Backdrop), "fanart", "fanart", outputIndex);
var list = new List<string>
{
Path.Combine(item.ContainingFolderPath, "extrafanart", extraFanartFilename + extension)
};
if (EnableExtraThumbsDuplication)
{
list.Add(Path.Combine(item.ContainingFolderPath, "extrathumbs", "thumb" + outputIndex.ToString(CultureInfo.InvariantCulture) + extension));
}
return list.ToArray();
}
if (type == ImageType.Primary)
{
if (season is not null && season.IndexNumber.HasValue)
{
var seriesFolder = season.SeriesPath;
var seasonMarker = season.IndexNumber.Value == 0
? "-specials"
: season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
var imageFilename = "season" + seasonMarker + "-poster" + extension;
return new[] { Path.Combine(seriesFolder, imageFilename) };
}
if (item is Episode)
{
var seasonFolder = Path.GetDirectoryName(item.Path);
var imageFilename = Path.GetFileNameWithoutExtension(item.Path) + "-thumb" + extension;
return new[] { Path.Combine(seasonFolder, imageFilename) };
}
if (item.IsInMixedFolder || item is MusicVideo)
{
return new[] { GetSavePathForItemInMixedFolder(item, type, string.Empty, extension) };
}
if (item is MusicAlbum || item is MusicArtist)
{
return new[] { Path.Combine(item.ContainingFolderPath, "folder" + extension) };
}
return new[] { Path.Combine(item.ContainingFolderPath, "poster" + extension) };
}
// All other paths are the same
return new[] { GetStandardSavePath(item, type, imageIndex, mimeType, true) };
}
/// <summary>
/// Gets the save path for item in mixed folder.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="type">The type.</param>
/// <param name="imageFilename">The image filename.</param>
/// <param name="extension">The extension.</param>
/// <returns>System.String.</returns>
private string GetSavePathForItemInMixedFolder(BaseItem item, ImageType type, string imageFilename, string extension)
{
if (type == ImageType.Primary)
{
imageFilename = "poster";
}
var folder = Path.GetDirectoryName(item.Path);
return Path.Combine(folder, Path.GetFileNameWithoutExtension(item.Path) + "-" + imageFilename + extension);
}
}
}
@@ -0,0 +1,749 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Net;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Manager
{
/// <summary>
/// Utilities for managing images attached to items.
/// </summary>
public class ItemImageProvider
{
private readonly ILogger _logger;
private readonly IProviderManager _providerManager;
private readonly IFileSystem _fileSystem;
private static readonly ImageType[] AllImageTypes = Enum.GetValues<ImageType>();
/// <summary>
/// Image types that are only one per item.
/// </summary>
private static readonly ImageType[] _singularImages =
{
ImageType.Primary,
ImageType.Art,
ImageType.Banner,
ImageType.Box,
ImageType.BoxRear,
ImageType.Disc,
ImageType.Logo,
ImageType.Menu,
ImageType.Thumb
};
/// <summary>
/// Initializes a new instance of the <see cref="ItemImageProvider"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="providerManager">The provider manager for interacting with provider image references.</param>
/// <param name="fileSystem">The filesystem.</param>
public ItemImageProvider(ILogger logger, IProviderManager providerManager, IFileSystem fileSystem)
{
_logger = logger;
_providerManager = providerManager;
_fileSystem = fileSystem;
}
/// <summary>
/// Removes all existing images from the provided item.
/// </summary>
/// <param name="item">The <see cref="BaseItem"/> to remove images from.</param>
/// <param name="canDeleteLocal">Whether removing images outside metadata folder is allowed.</param>
/// <returns><c>true</c> if changes were made to the item; otherwise <c>false</c>.</returns>
public bool RemoveImages(BaseItem item, bool canDeleteLocal = false)
{
var singular = new List<ItemImageInfo>();
var itemMetadataPath = item.GetInternalMetadataPath();
for (var i = 0; i < _singularImages.Length; i++)
{
var currentImage = item.GetImageInfo(_singularImages[i], 0);
if (currentImage is not null)
{
var imageInMetadataFolder = currentImage.Path.StartsWith(itemMetadataPath, StringComparison.OrdinalIgnoreCase);
if (imageInMetadataFolder || canDeleteLocal || item.IsSaveLocalMetadataEnabled())
{
singular.Add(currentImage);
}
}
}
foreach (var backdrop in item.GetImages(ImageType.Backdrop))
{
var imageInMetadataFolder = backdrop.Path.StartsWith(itemMetadataPath, StringComparison.OrdinalIgnoreCase);
if (imageInMetadataFolder || canDeleteLocal || item.IsSaveLocalMetadataEnabled())
{
singular.Add(backdrop);
}
}
PruneImages(item, singular);
return singular.Count > 0;
}
/// <summary>
/// Verifies existing images have valid paths and adds any new local images provided.
/// </summary>
/// <param name="item">The <see cref="BaseItem"/> to validate images for.</param>
/// <param name="providers">The providers to use, must include <see cref="ILocalImageProvider"/>(s) for local scanning.</param>
/// <param name="refreshOptions">The refresh options.</param>
/// <returns><c>true</c> if changes were made to the item; otherwise <c>false</c>.</returns>
public bool ValidateImages(BaseItem item, IEnumerable<IImageProvider> providers, ImageRefreshOptions refreshOptions)
{
var hasChanges = false;
var directoryService = refreshOptions?.DirectoryService;
if (item is not Photo)
{
var images = providers.OfType<ILocalImageProvider>()
.SelectMany(i => i.GetImages(item, directoryService))
.ToList();
if (MergeImages(item, images, refreshOptions))
{
hasChanges = true;
}
}
return hasChanges;
}
/// <summary>
/// Refreshes from the providers according to the given options.
/// </summary>
/// <param name="item">The <see cref="BaseItem"/> to gather images for.</param>
/// <param name="libraryOptions">The library options.</param>
/// <param name="providers">The providers to query for images.</param>
/// <param name="refreshOptions">The refresh options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The refresh result.</returns>
public async Task<RefreshResult> RefreshImages(
BaseItem item,
LibraryOptions libraryOptions,
IEnumerable<IImageProvider> providers,
ImageRefreshOptions refreshOptions,
CancellationToken cancellationToken)
{
var oldBackdropImages = Array.Empty<ItemImageInfo>();
if (refreshOptions.IsReplacingImage(ImageType.Backdrop))
{
oldBackdropImages = item.GetImages(ImageType.Backdrop).ToArray();
}
var result = new RefreshResult { UpdateType = ItemUpdateType.None };
var typeName = item.GetType().Name;
var typeOptions = libraryOptions.GetTypeOptions(typeName) ?? new TypeOptions { Type = typeName };
// track library limits, adding buffer to allow lazy replacing of current images
var backdropLimit = typeOptions.GetLimit(ImageType.Backdrop) + oldBackdropImages.Length;
var downloadedImages = new List<ImageType>();
foreach (var provider in providers)
{
if (provider is IRemoteImageProvider remoteProvider)
{
await RefreshFromProvider(item, remoteProvider, refreshOptions, typeOptions, backdropLimit, downloadedImages, result, cancellationToken).ConfigureAwait(false);
continue;
}
if (provider is IDynamicImageProvider dynamicImageProvider)
{
await RefreshFromProvider(item, dynamicImageProvider, refreshOptions, typeOptions, downloadedImages, result, cancellationToken).ConfigureAwait(false);
}
}
// Only delete existing multi-images if new ones were added
if (oldBackdropImages.Length > 0 && oldBackdropImages.Length < item.GetImages(ImageType.Backdrop).Count())
{
PruneImages(item, oldBackdropImages);
}
return result;
}
/// <summary>
/// Refreshes from a dynamic provider.
/// </summary>
private async Task RefreshFromProvider(
BaseItem item,
IDynamicImageProvider provider,
ImageRefreshOptions refreshOptions,
TypeOptions savedOptions,
List<ImageType> downloadedImages,
RefreshResult result,
CancellationToken cancellationToken)
{
try
{
var images = provider.GetSupportedImages(item);
foreach (var imageType in images)
{
if (!savedOptions.IsEnabled(imageType))
{
continue;
}
if (!item.HasImage(imageType) || (refreshOptions.IsReplacingImage(imageType) && !downloadedImages.Contains(imageType)))
{
_logger.LogDebug("Running {Provider} for {Item}", provider.GetType().Name, item.Path ?? item.Name);
var response = await provider.GetImage(item, imageType, cancellationToken).ConfigureAwait(false);
if (response.HasImage)
{
if (string.IsNullOrEmpty(response.Path))
{
var mimeType = response.Format.GetMimeType();
await _providerManager.SaveImage(item, response.Stream, mimeType, imageType, null, cancellationToken).ConfigureAwait(false);
}
else
{
if (response.Protocol == MediaProtocol.Http)
{
_logger.LogDebug("Setting image url into item {Item}", item.Id);
var index = item.AllowsMultipleImages(imageType) ? item.GetImages(imageType).Count() : 0;
item.SetImage(
new ItemImageInfo
{
Path = response.Path,
Type = imageType
},
index);
}
else
{
var mimeType = MimeTypes.GetMimeType(response.Path);
await _providerManager.SaveImage(item, response.Path, mimeType, imageType, null, null, cancellationToken).ConfigureAwait(false);
}
}
downloadedImages.Add(imageType);
result.UpdateType |= ItemUpdateType.ImageUpdate;
}
}
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
result.ErrorMessage = ex.Message;
_logger.LogError(ex, "Error in {Provider}", provider.Name);
}
}
/// <summary>
/// Refreshes from a remote provider.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="provider">The provider.</param>
/// <param name="refreshOptions">The refresh options.</param>
/// <param name="savedOptions">The saved options.</param>
/// <param name="backdropLimit">The backdrop limit.</param>
/// <param name="downloadedImages">The downloaded images.</param>
/// <param name="result">The result.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
private async Task RefreshFromProvider(
BaseItem item,
IRemoteImageProvider provider,
ImageRefreshOptions refreshOptions,
TypeOptions savedOptions,
int backdropLimit,
List<ImageType> downloadedImages,
RefreshResult result,
CancellationToken cancellationToken)
{
try
{
if (!item.SupportsRemoteImageDownloading)
{
return;
}
if (!refreshOptions.ReplaceAllImages &&
refreshOptions.ReplaceImages.Count == 0 &&
ContainsImages(item, provider.GetSupportedImages(item).ToList(), savedOptions, backdropLimit))
{
return;
}
_logger.LogDebug("Running {Provider} for {Item}", provider.GetType().Name, item.Path ?? item.Name);
var images = await _providerManager.GetAvailableRemoteImages(
item,
new RemoteImageQuery(provider.Name)
{
IncludeAllLanguages = true,
IncludeDisabledProviders = false,
},
cancellationToken).ConfigureAwait(false);
var list = images.ToList();
int minWidth;
foreach (var imageType in _singularImages)
{
if (!savedOptions.IsEnabled(imageType))
{
continue;
}
if (!item.HasImage(imageType) || (refreshOptions.IsReplacingImage(imageType) && !downloadedImages.Contains(imageType)))
{
minWidth = savedOptions.GetMinWidth(imageType);
var downloaded = await DownloadImage(item, provider, result, list, minWidth, imageType, cancellationToken).ConfigureAwait(false);
if (downloaded)
{
downloadedImages.Add(imageType);
}
}
}
minWidth = savedOptions.GetMinWidth(ImageType.Backdrop);
var listWithNoLangFirst = list.OrderByDescending(i => string.IsNullOrEmpty(i.Language));
await DownloadMultiImages(item, ImageType.Backdrop, refreshOptions, backdropLimit, provider, result, listWithNoLangFirst, minWidth, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
result.ErrorMessage = ex.Message;
_logger.LogError(ex, "Error in {Provider}", provider.Name);
}
}
/// <summary>
/// Determines if an item already contains the given images.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="images">The images.</param>
/// <param name="savedOptions">The saved options.</param>
/// <param name="backdropLimit">The backdrop limit.</param>
/// <returns><c>true</c> if the specified item contains images; otherwise, <c>false</c>.</returns>
private bool ContainsImages(BaseItem item, List<ImageType> images, TypeOptions savedOptions, int backdropLimit)
{
// Using .Any causes the creation of a DisplayClass aka. variable capture
for (var i = 0; i < _singularImages.Length; i++)
{
var type = _singularImages[i];
if (images.Contains(type) && !item.HasImage(type) && savedOptions.GetLimit(type) > 0)
{
return false;
}
}
if (images.Contains(ImageType.Backdrop) && item.GetImages(ImageType.Backdrop).Count() < backdropLimit)
{
return false;
}
return true;
}
private void PruneImages(BaseItem item, IReadOnlyList<ItemImageInfo> images)
{
foreach (var image in images)
{
if (image.IsLocalFile)
{
try
{
_fileSystem.DeleteFile(image.Path);
}
catch (FileNotFoundException)
{
// Nothing to do, already gone
}
catch (DirectoryNotFoundException)
{
// Nothing to do, already gone
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Unable to delete {Image}", image.Path);
}
}
}
item.RemoveImages(images);
// Cleanup old metadata directory for episodes if empty, as long as it's not a virtual item
if (item is Episode && !item.IsVirtualItem)
{
var oldLocalMetadataDirectory = Path.Combine(item.ContainingFolderPath, "metadata");
if (_fileSystem.DirectoryExists(oldLocalMetadataDirectory) && !_fileSystem.GetFiles(oldLocalMetadataDirectory).Any())
{
Directory.Delete(oldLocalMetadataDirectory);
}
}
}
/// <summary>
/// Merges a list of images into the provided item, validating existing images and replacing them or adding new images as necessary.
/// </summary>
/// <param name="refreshOptions">The refresh options.</param>
/// <param name="dontReplaceImages">List of imageTypes to remove from ReplaceImages.</param>
public void UpdateReplaceImages(ImageRefreshOptions refreshOptions, ICollection<ImageType> dontReplaceImages)
{
if (refreshOptions is not null)
{
if (refreshOptions.ReplaceAllImages)
{
refreshOptions.ReplaceAllImages = false;
refreshOptions.ReplaceImages = AllImageTypes.ToList();
}
refreshOptions.ReplaceImages = refreshOptions.ReplaceImages.Except(dontReplaceImages).ToList();
}
}
/// <summary>
/// Merges a list of images into the provided item, validating existing images and replacing them or adding new images as necessary.
/// </summary>
/// <param name="item">The <see cref="BaseItem"/> to modify.</param>
/// <param name="images">The new images to place in <c>item</c>.</param>
/// <param name="refreshOptions">The refresh options.</param>
/// <returns><c>true</c> if changes were made to the item; otherwise <c>false</c>.</returns>
public bool MergeImages(BaseItem item, IReadOnlyList<LocalImageInfo> images, ImageRefreshOptions refreshOptions)
{
var changed = item.ValidateImages();
var foundImageTypes = new List<ImageType>();
for (var i = 0; i < _singularImages.Length; i++)
{
var type = _singularImages[i];
var image = GetFirstLocalImageInfoByType(images, type);
if (image is not null)
{
var currentImage = item.GetImageInfo(type, 0);
// if image file is stored with media, don't replace that later
if (item.ContainingFolderPath is not null && item.ContainingFolderPath.Contains(Path.GetDirectoryName(image.FileInfo.FullName), StringComparison.OrdinalIgnoreCase))
{
foundImageTypes.Add(type);
}
if (currentImage is null || !string.Equals(currentImage.Path, image.FileInfo.FullName, StringComparison.OrdinalIgnoreCase))
{
item.SetImagePath(type, image.FileInfo);
changed = true;
}
else
{
var newDateModified = _fileSystem.GetLastWriteTimeUtc(image.FileInfo);
// If date changed then we need to reset saved image dimensions
if (currentImage.DateModified != newDateModified && (currentImage.Width > 0 || currentImage.Height > 0))
{
currentImage.Width = 0;
currentImage.Height = 0;
changed = true;
}
currentImage.DateModified = newDateModified;
}
}
}
bool hasBackdrop = false;
bool backdropStoredWithMedia = false;
foreach (var image in images)
{
if (image.Type != ImageType.Backdrop)
{
continue;
}
hasBackdrop = true;
if (item.ContainingFolderPath is not null && item.ContainingFolderPath.Contains(Path.GetDirectoryName(image.FileInfo.FullName), StringComparison.OrdinalIgnoreCase))
{
backdropStoredWithMedia = true;
break;
}
}
if (hasBackdrop)
{
if (UpdateMultiImages(item, images, ImageType.Backdrop))
{
changed = true;
}
if (backdropStoredWithMedia)
{
foundImageTypes.Add(ImageType.Backdrop);
}
}
if (foundImageTypes.Count > 0)
{
UpdateReplaceImages(refreshOptions, foundImageTypes);
}
return changed;
}
private static LocalImageInfo GetFirstLocalImageInfoByType(IReadOnlyList<LocalImageInfo> images, ImageType type)
{
var len = images.Count;
for (var i = 0; i < len; i++)
{
var image = images[i];
if (image.Type == type)
{
return image;
}
}
return null;
}
private bool UpdateMultiImages(BaseItem item, IReadOnlyList<LocalImageInfo> images, ImageType type)
{
var changed = false;
var newImageFileInfos = images
.Where(i => i.Type == type)
.Select(i => i.FileInfo)
.ToList();
if (item.AddImages(type, newImageFileInfos))
{
changed = true;
}
return changed;
}
private async Task<bool> DownloadImage(
BaseItem item,
IRemoteImageProvider provider,
RefreshResult result,
IEnumerable<RemoteImageInfo> images,
int minWidth,
ImageType type,
CancellationToken cancellationToken)
{
var eligibleImages = images
.Where(i => i.Type == type && (i.Width is null || i.Width >= minWidth))
.ToList();
if (EnableImageStub(item) && eligibleImages.Count > 0)
{
SaveImageStub(item, type, eligibleImages.Select(i => i.Url));
result.UpdateType |= ItemUpdateType.ImageUpdate;
return true;
}
foreach (var image in eligibleImages)
{
var url = image.Url;
try
{
using var response = await provider.GetImageResponse(url, cancellationToken).ConfigureAwait(false);
// Sometimes providers send back bad urls. Just move to the next image
if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.Forbidden)
{
_logger.LogDebug("{Url} returned {StatusCode}, ignoring", url, response.StatusCode);
continue;
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("{Url} returned {StatusCode}, skipping all remaining requests", url, response.StatusCode);
break;
}
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
var mimetype = response.Content.Headers.ContentType?.MediaType;
if (mimetype is null || mimetype.Equals(MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase))
{
mimetype = MimeTypes.GetMimeType(response.RequestMessage.RequestUri.GetLeftPart(UriPartial.Path));
}
await _providerManager.SaveImage(
item,
stream,
mimetype,
type,
null,
cancellationToken).ConfigureAwait(false);
}
result.UpdateType |= ItemUpdateType.ImageUpdate;
return true;
}
catch (HttpRequestException)
{
break;
}
}
return false;
}
private bool EnableImageStub(BaseItem item)
{
if (item is LiveTvProgram)
{
return true;
}
if (!item.IsFileProtocol)
{
return true;
}
if (item is IItemByName and not MusicArtist)
{
var hasDualAccess = item as IHasDualAccess;
if (hasDualAccess is null || hasDualAccess.IsAccessedByName)
{
return true;
}
}
// We always want to use prefetched images
return false;
}
private void SaveImageStub(BaseItem item, ImageType imageType, IEnumerable<string> urls)
{
var newIndex = item.AllowsMultipleImages(imageType) ? item.GetImages(imageType).Count() : 0;
SaveImageStub(item, imageType, urls, newIndex);
}
private void SaveImageStub(BaseItem item, ImageType imageType, IEnumerable<string> urls, int newIndex)
{
var path = string.Join('|', urls.Take(1));
item.SetImage(
new ItemImageInfo
{
Path = path,
Type = imageType
},
newIndex);
}
private async Task DownloadMultiImages(BaseItem item, ImageType imageType, ImageRefreshOptions refreshOptions, int limit, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, int minWidth, CancellationToken cancellationToken)
{
foreach (var image in images.Where(i => i.Type == imageType))
{
if (item.GetImages(imageType).Count() >= limit)
{
break;
}
if (image.Width.HasValue && image.Width.Value < minWidth)
{
continue;
}
var url = image.Url;
if (EnableImageStub(item))
{
SaveImageStub(item, imageType, new[] { url });
result.UpdateType |= ItemUpdateType.ImageUpdate;
continue;
}
try
{
using var response = await provider.GetImageResponse(url, cancellationToken).ConfigureAwait(false);
// Sometimes providers send back bad urls. Just move to the next image
if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.Forbidden)
{
_logger.LogDebug("{Url} returned {StatusCode}, ignoring", url, response.StatusCode);
continue;
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("{Url} returned {StatusCode}, skipping all remaining requests", url, response.StatusCode);
break;
}
// If there's already an image of the same file size, skip it unless doing a full refresh
if (response.Content.Headers.ContentLength.HasValue && !refreshOptions.IsReplacingImage(imageType))
{
try
{
if (item.GetImages(imageType).Any(i => _fileSystem.GetFileInfo(i.Path).Length == response.Content.Headers.ContentLength.Value))
{
response.Content.Dispose();
continue;
}
}
catch (IOException ex)
{
_logger.LogError(ex, "Error examining images");
}
}
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
var mimetype = response.Content.Headers.ContentType?.MediaType;
if (mimetype is null || mimetype.Equals(MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase))
{
mimetype = MimeTypes.GetMimeType(response.RequestMessage.RequestUri.GetLeftPart(UriPartial.Path));
}
await _providerManager.SaveImage(
item,
stream,
mimetype,
imageType,
null,
cancellationToken).ConfigureAwait(false);
}
result.UpdateType |= ItemUpdateType.ImageUpdate;
}
catch (HttpRequestException)
{
break;
}
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
#pragma warning disable CS1591
using MediaBrowser.Controller.Library;
namespace MediaBrowser.Providers.Manager
{
public class RefreshResult
{
public ItemUpdateType UpdateType { get; set; }
public string? ErrorMessage { get; set; }
public int Failures { get; set; }
}
}
@@ -0,0 +1,62 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
<ProjectGuid>{442B5058-DCAF-4263-BB6A-F21E31120A1B}</ProjectGuid>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
<ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\SharedVersion.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AsyncKeyedLock" />
<PackageReference Include="LrcParser" />
<PackageReference Include="MetaBrainz.MusicBrainz" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="PlaylistsNET" />
<PackageReference Include="z440.atl.core" />
<PackageReference Include="TMDbLib" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<!-- Code Analyzers -->
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
<PackageReference Include="IDisposableAnalyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Remove="Plugins\AudioDb\Configuration\config.html" />
<EmbeddedResource Include="Plugins\AudioDb\Configuration\config.html" />
<None Remove="Plugins\Omdb\Configuration\config.html" />
<EmbeddedResource Include="Plugins\Omdb\Configuration\config.html" />
<None Remove="Plugins\MusicBrainz\Configuration\config.html" />
<EmbeddedResource Include="Plugins\MusicBrainz\Configuration\config.html" />
<None Remove="Plugins\StudioImages\Configuration\config.html" />
<EmbeddedResource Include="Plugins\StudioImages\Configuration\config.html" />
<None Remove="Plugins\Tmdb\Configuration\config.html" />
<EmbeddedResource Include="Plugins\Tmdb\Configuration\config.html" />
</ItemGroup>
</Project>
@@ -0,0 +1,592 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ATL;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Extensions;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
using static Jellyfin.Extensions.StringExtensions;
namespace MediaBrowser.Providers.MediaInfo
{
/// <summary>
/// Probes audio files for metadata.
/// </summary>
public class AudioFileProber
{
private const char InternalValueSeparator = '\u001F';
private readonly IMediaEncoder _mediaEncoder;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<AudioFileProber> _logger;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly LyricResolver _lyricResolver;
private readonly ILyricManager _lyricManager;
private readonly IMediaStreamRepository _mediaStreamRepository;
/// <summary>
/// Initializes a new instance of the <see cref="AudioFileProber"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="lyricResolver">Instance of the <see cref="LyricResolver"/> interface.</param>
/// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
/// <param name="mediaStreamRepository">Instance of the <see cref="IMediaStreamRepository"/>.</param>
public AudioFileProber(
ILogger<AudioFileProber> logger,
IMediaSourceManager mediaSourceManager,
IMediaEncoder mediaEncoder,
ILibraryManager libraryManager,
LyricResolver lyricResolver,
ILyricManager lyricManager,
IMediaStreamRepository mediaStreamRepository)
{
_mediaEncoder = mediaEncoder;
_libraryManager = libraryManager;
_logger = logger;
_mediaSourceManager = mediaSourceManager;
_lyricResolver = lyricResolver;
_lyricManager = lyricManager;
_mediaStreamRepository = mediaStreamRepository;
ATL.Settings.DisplayValueSeparator = InternalValueSeparator;
ATL.Settings.UseFileNameWhenNoTitle = false;
ATL.Settings.ID3v2_separatev2v3Values = false;
}
/// <summary>
/// Probes the specified item for metadata.
/// </summary>
/// <param name="item">The item to probe.</param>
/// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
/// <typeparam name="T">The type of item to resolve.</typeparam>
/// <returns>A <see cref="Task"/> probing the item for metadata.</returns>
public async Task<ItemUpdateType> Probe<T>(
T item,
MetadataRefreshOptions options,
CancellationToken cancellationToken)
where T : Audio
{
var path = item.Path;
var protocol = item.PathProtocol ?? MediaProtocol.File;
if (!item.IsShortcut || options.EnableRemoteContentProbe)
{
if (item.IsShortcut)
{
path = item.ShortcutPath;
protocol = _mediaSourceManager.GetPathProtocol(path);
}
var result = await _mediaEncoder.GetMediaInfo(
new MediaInfoRequest
{
MediaType = DlnaProfileType.Audio,
MediaSource = new MediaSourceInfo
{
Path = path,
Protocol = protocol
}
},
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
await FetchAsync(item, result, options, cancellationToken).ConfigureAwait(false);
}
return ItemUpdateType.MetadataImport;
}
/// <summary>
/// Fetches the specified audio.
/// </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>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
private async Task FetchAsync(
Audio audio,
Model.MediaInfo.MediaInfo mediaInfo,
MetadataRefreshOptions options,
CancellationToken cancellationToken)
{
audio.Container = mediaInfo.Container;
audio.TotalBitrate = mediaInfo.Bitrate;
audio.RunTimeTicks = mediaInfo.RunTimeTicks;
// Add external lyrics first to prevent the lrc file get overwritten on first scan
var mediaStreams = new List<MediaStream>(mediaInfo.MediaStreams);
AddExternalLyrics(audio, mediaStreams, options);
var tryExtractEmbeddedLyrics = mediaStreams.All(s => s.Type != MediaStreamType.Lyric);
if (!audio.IsLocked)
{
await FetchDataFromTags(audio, mediaInfo, options, tryExtractEmbeddedLyrics).ConfigureAwait(false);
if (tryExtractEmbeddedLyrics)
{
AddExternalLyrics(audio, mediaStreams, options);
}
}
audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric);
_mediaStreamRepository.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken);
}
/// <summary>
/// Fetches data from the tags.
/// </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="tryExtractEmbeddedLyrics">Whether to extract embedded lyrics to lrc file. </param>
private async Task FetchDataFromTags(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, MetadataRefreshOptions options, bool tryExtractEmbeddedLyrics)
{
var libraryOptions = _libraryManager.GetLibraryOptions(audio);
Track track = new Track(audio.Path);
if (track.MetadataFormats
.All(mf => string.Equals(mf.ShortName, "ID3v1", StringComparison.OrdinalIgnoreCase)))
{
_logger.LogWarning("File {File} only has ID3v1 tags, some fields may be truncated", audio.Path);
}
// We should never use the property setter of the ATL.Track class.
// That setter is meant for its own tag parser and external editor usage and will have unwanted side effects
// For example, setting the Year property will also set the Date property, which is not what we want here.
// To properly handle fallback values, we make a clone of those fields when valid.
var trackTitle = (string.IsNullOrEmpty(track.Title) ? mediaInfo.Name : track.Title)?.Trim();
var trackAlbum = (string.IsNullOrEmpty(track.Album) ? mediaInfo.Album : track.Album)?.Trim();
var trackYear = track.Year is null or 0 ? mediaInfo.ProductionYear : track.Year;
var trackTrackNumber = track.TrackNumber is null or 0 ? mediaInfo.IndexNumber : track.TrackNumber;
var trackDiscNumber = track.DiscNumber is null or 0 ? mediaInfo.ParentIndexNumber : track.DiscNumber;
// Some users may use a misbehaved tag editor that writes a null character in the tag when not allowed by the standard.
trackTitle = GetSanitizedStringTag(trackTitle, audio.Path);
trackAlbum = GetSanitizedStringTag(trackAlbum, audio.Path);
var trackAlbumArtist = GetSanitizedStringTag(track.AlbumArtist, audio.Path);
var trackArist = GetSanitizedStringTag(track.Artist, audio.Path);
var trackComposer = GetSanitizedStringTag(track.Composer, audio.Path);
var trackGenre = GetSanitizedStringTag(track.Genre, audio.Path);
if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
{
var people = new List<PersonInfo>();
string[]? albumArtists = null;
if (libraryOptions.PreferNonstandardArtistsTag)
{
TryGetSanitizedAdditionalFields(track, "ALBUMARTISTS", out var albumArtistsTagString);
if (albumArtistsTagString is not null)
{
albumArtists = albumArtistsTagString.Split(InternalValueSeparator);
}
}
if (albumArtists is null || albumArtists.Length == 0)
{
albumArtists = string.IsNullOrEmpty(trackAlbumArtist) ? [] : trackAlbumArtist.Split(InternalValueSeparator);
}
if (libraryOptions.UseCustomTagDelimiters)
{
albumArtists = albumArtists.SelectMany(a => SplitWithCustomDelimiter(a, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray();
}
foreach (var albumArtist in albumArtists)
{
if (!string.IsNullOrWhiteSpace(albumArtist))
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = albumArtist,
Type = PersonKind.AlbumArtist
});
}
}
string[]? performers = null;
if (libraryOptions.PreferNonstandardArtistsTag)
{
TryGetSanitizedAdditionalFields(track, "ARTISTS", out var artistsTagString);
if (artistsTagString is not null)
{
performers = artistsTagString.Split(InternalValueSeparator);
}
}
if (performers is null || performers.Length == 0)
{
performers = string.IsNullOrEmpty(trackArist) ? [] : trackArist.Split(InternalValueSeparator);
}
if (libraryOptions.UseCustomTagDelimiters)
{
performers = performers.SelectMany(p => SplitWithCustomDelimiter(p, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray();
}
foreach (var performer in performers)
{
if (!string.IsNullOrWhiteSpace(performer))
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = performer,
Type = PersonKind.Artist
});
}
}
if (!string.IsNullOrWhiteSpace(trackComposer))
{
foreach (var composer in trackComposer.Split(InternalValueSeparator))
{
if (!string.IsNullOrWhiteSpace(composer))
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = composer,
Type = PersonKind.Composer
});
}
}
}
_libraryManager.UpdatePeople(audio, people);
if (options.ReplaceAllMetadata && performers.Length != 0)
{
audio.Artists = performers;
}
else if (!options.ReplaceAllMetadata
&& (audio.Artists is null || audio.Artists.Count == 0))
{
audio.Artists = performers;
}
if (albumArtists.Length == 0)
{
// Album artists not provided, fall back to performers (artists).
albumArtists = performers;
}
if (options.ReplaceAllMetadata && albumArtists.Length != 0)
{
audio.AlbumArtists = albumArtists;
}
else if (!options.ReplaceAllMetadata
&& (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0))
{
audio.AlbumArtists = albumArtists;
}
}
if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(trackTitle))
{
audio.Name = trackTitle;
}
if (options.ReplaceAllMetadata)
{
audio.Album = trackAlbum;
audio.IndexNumber = trackTrackNumber;
audio.ParentIndexNumber = trackDiscNumber;
}
else
{
audio.Album ??= trackAlbum;
audio.IndexNumber ??= trackTrackNumber;
audio.ParentIndexNumber ??= trackDiscNumber;
}
if (track.Date.HasValue)
{
audio.PremiereDate = track.Date;
}
if (trackYear.HasValue)
{
var year = trackYear.Value;
audio.ProductionYear = year;
// ATL library handles such fallback this with its own internal logic, but we also need to handle it here for the ffprobe fallbacks.
if (!audio.PremiereDate.HasValue)
{
try
{
audio.PremiereDate = new DateTime(year, 01, 01);
}
catch (ArgumentOutOfRangeException ex)
{
_logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.Path, trackYear);
}
}
}
if (!audio.LockedFields.Contains(MetadataField.Genres))
{
var genres = string.IsNullOrEmpty(trackGenre) ? [] : trackGenre.Split(InternalValueSeparator).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (libraryOptions.UseCustomTagDelimiters)
{
genres = genres.SelectMany(g => SplitWithCustomDelimiter(g, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray();
}
genres = genres.Trimmed().Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (options.ReplaceAllMetadata || audio.Genres is null || audio.Genres.Length == 0 || audio.Genres.All(string.IsNullOrWhiteSpace))
{
audio.Genres = genres;
}
}
TryGetSanitizedAdditionalFields(track, "REPLAYGAIN_TRACK_GAIN", out var trackGainTag);
if (trackGainTag is not null)
{
if (trackGainTag.EndsWith("db", StringComparison.OrdinalIgnoreCase))
{
trackGainTag = trackGainTag[..^2].Trim();
}
if (float.TryParse(trackGainTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) && float.IsFinite(value))
{
audio.NormalizationGain = value;
}
}
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
{
if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ARTISTID", out var musicBrainzArtistTag)
|| TryGetSanitizedAdditionalFields(track, "MusicBrainz Artist Id", out musicBrainzArtistTag))
&& !string.IsNullOrEmpty(musicBrainzArtistTag))
{
var id = GetFirstMusicBrainzId(musicBrainzArtistTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
audio.TrySetProviderId(MetadataProvider.MusicBrainzArtist, id);
}
}
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
{
if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ALBUMARTISTID", out var musicBrainzReleaseArtistIdTag)
|| TryGetSanitizedAdditionalFields(track, "MusicBrainz Album Artist Id", out musicBrainzReleaseArtistIdTag))
&& !string.IsNullOrEmpty(musicBrainzReleaseArtistIdTag))
{
var id = GetFirstMusicBrainzId(musicBrainzReleaseArtistIdTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbumArtist, id);
}
}
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
{
if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ALBUMID", out var musicBrainzReleaseIdTag)
|| TryGetSanitizedAdditionalFields(track, "MusicBrainz Album Id", out musicBrainzReleaseIdTag))
&& !string.IsNullOrEmpty(musicBrainzReleaseIdTag))
{
var id = GetFirstMusicBrainzId(musicBrainzReleaseIdTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbum, id);
}
}
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
{
if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_RELEASEGROUPID", out var musicBrainzReleaseGroupIdTag)
|| TryGetSanitizedAdditionalFields(track, "MusicBrainz Release Group Id", out musicBrainzReleaseGroupIdTag))
&& !string.IsNullOrEmpty(musicBrainzReleaseGroupIdTag))
{
var id = GetFirstMusicBrainzId(musicBrainzReleaseGroupIdTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
audio.TrySetProviderId(MetadataProvider.MusicBrainzReleaseGroup, id);
}
}
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
{
if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_RELEASETRACKID", out var trackMbId)
|| TryGetSanitizedAdditionalFields(track, "MusicBrainz Release Track Id", out trackMbId))
&& !string.IsNullOrEmpty(trackMbId))
{
var id = GetFirstMusicBrainzId(trackMbId, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
audio.TrySetProviderId(MetadataProvider.MusicBrainzTrack, id);
}
}
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzRecording, out _))
{
if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_TRACKID", out var recordingMbId)
|| TryGetSanitizedAdditionalFields(track, "MusicBrainz Track Id", out recordingMbId))
&& !string.IsNullOrEmpty(recordingMbId))
{
audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, recordingMbId);
}
else if (TryGetSanitizedUFIDFields(track, out var owner, out var identifier) && !string.IsNullOrEmpty(owner) && !string.IsNullOrEmpty(identifier))
{
// If tagged with MB Picard, the format is 'http://musicbrainz.org\0<recording MBID>'
if (owner.Contains("musicbrainz.org", StringComparison.OrdinalIgnoreCase))
{
audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, identifier);
}
}
}
// Save extracted lyrics if they exist,
// and if the audio doesn't yet have lyrics.
// ATL supports both SRT and LRC formats as synchronized lyrics, but we only want to save LRC format.
var supportedLyrics = track.Lyrics.Where(l => l.Format != LyricsInfo.LyricsFormat.SRT).ToList();
var candidateSynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is not LyricsInfo.LyricsFormat.UNSYNCHRONIZED and not LyricsInfo.LyricsFormat.OTHER && l.SynchronizedLyrics is not null);
var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.UNSYNCHRONIZED or LyricsInfo.LyricsFormat.OTHER && l.UnsynchronizedLyrics is not null);
var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUnsynchronizedLyric?.UnsynchronizedLyrics;
if (!string.IsNullOrWhiteSpace(lyrics)
&& tryExtractEmbeddedLyrics)
{
await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false);
}
}
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();
if (externalLyricFiles.Count > 0)
{
currentStreams.Add(externalLyricFiles[0]);
}
}
private List<string> SplitWithCustomDelimiter(string val, char[] tagDelimiters, string[] whitelist)
{
var items = new List<string>();
var temp = val;
foreach (var whitelistItem in whitelist)
{
if (string.IsNullOrWhiteSpace(whitelistItem))
{
continue;
}
var originalTemp = temp;
temp = temp.Replace(whitelistItem, string.Empty, StringComparison.OrdinalIgnoreCase);
if (!string.Equals(temp, originalTemp, StringComparison.OrdinalIgnoreCase))
{
items.Add(whitelistItem);
}
}
var items2 = temp.Split(tagDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).DistinctNames();
items.AddRange(items2);
return items;
}
// MusicBrainz IDs are multi-value tags, so we need to split them
// However, our current provider can only have one single ID, which means we need to pick the first one
private string? GetFirstMusicBrainzId(string tag, bool useCustomTagDelimiters, char[] tagDelimiters, string[] whitelist)
{
var val = tag.Split(InternalValueSeparator).FirstOrDefault();
if (val is not null && useCustomTagDelimiters)
{
val = SplitWithCustomDelimiter(val, tagDelimiters, whitelist).FirstOrDefault();
}
return val;
}
private string? GetSanitizedStringTag(string? tag, string filePath)
{
if (string.IsNullOrEmpty(tag))
{
return null;
}
var result = tag.TruncateAtNull();
if (result.Length != tag.Length)
{
_logger.LogWarning("Audio file {File} contains a null character in its tag, but this is not allowed by its tagging standard. All characters after the null char will be discarded. Please fix your file", filePath);
}
return result;
}
private bool TryGetSanitizedAdditionalFields(Track track, string field, out string? value)
{
var hasField = TryGetAdditionalFieldWithFallback(track, field, out value);
value = GetSanitizedStringTag(value, track.Path);
return hasField;
}
private bool TryGetSanitizedUFIDFields(Track track, out string? owner, out string? identifier)
{
var hasField = TryGetAdditionalFieldWithFallback(track, "UFID", out string? value);
if (hasField && !string.IsNullOrEmpty(value))
{
string[] parts = value.Split('\0');
if (parts.Length == 2)
{
owner = GetSanitizedStringTag(parts[0], track.Path);
identifier = GetSanitizedStringTag(parts[1], track.Path);
return true;
}
}
owner = null;
identifier = null;
return false;
}
// Build the explicit mka-style fallback key (e.g., ARTISTS -> track.artists, "MusicBrainz Artist Id" -> track.musicbrainz_artist_id)
private static string GetMkaFallbackKey(string key)
{
if (string.IsNullOrWhiteSpace(key))
{
return key;
}
var normalized = key.Trim().Replace(' ', '_').ToLowerInvariant();
return "track." + normalized;
}
// First try the normal key exactly; if missing, try the mka-style fallback key.
private bool TryGetAdditionalFieldWithFallback(Track track, string key, out string? value)
{
// Prefer the normal key (as-is, case-sensitive)
if (track.AdditionalFields.TryGetValue(key, out value))
{
return true;
}
// Fallback to mka-style: "track." + lower-case(original key)
var fallbackKey = GetMkaFallbackKey(key);
if (track.AdditionalFields.TryGetValue(fallbackKey, out value))
{
return true;
}
value = null;
return false;
}
}
}
@@ -0,0 +1,156 @@
#pragma warning disable CA1826 // CA1826 Do not use Enumerable methods on Indexable collections.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
namespace MediaBrowser.Providers.MediaInfo
{
/// <summary>
/// Uses <see cref="IMediaEncoder"/> to extract embedded images.
/// </summary>
public class AudioImageProvider : IDynamicImageProvider
{
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IMediaEncoder _mediaEncoder;
private readonly IServerConfigurationManager _config;
private readonly IFileSystem _fileSystem;
/// <summary>
/// Initializes a new instance of the <see cref="AudioImageProvider"/> class.
/// </summary>
/// <param name="mediaSourceManager">The media source manager for fetching item streams.</param>
/// <param name="mediaEncoder">The media encoder for extracting embedded images.</param>
/// <param name="config">The server configuration manager for getting image paths.</param>
/// <param name="fileSystem">The filesystem.</param>
public AudioImageProvider(IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IServerConfigurationManager config, IFileSystem fileSystem)
{
_mediaSourceManager = mediaSourceManager;
_mediaEncoder = mediaEncoder;
_config = config;
_fileSystem = fileSystem;
}
private string AudioImagesPath => Path.Combine(_config.ApplicationPaths.CachePath, "extracted-audio-images");
/// <inheritdoc />
public string Name => "Image Extractor";
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
return new[] { ImageType.Primary };
}
/// <inheritdoc />
public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
{
var imageStreams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
{
ItemId = item.Id,
Type = MediaStreamType.EmbeddedImage
});
// Can't extract if we didn't find a video stream in the file
if (imageStreams.Count == 0)
{
return Task.FromResult(new DynamicImageResponse { HasImage = false });
}
return GetImage((Audio)item, imageStreams, cancellationToken);
}
private async Task<DynamicImageResponse> GetImage(Audio item, IReadOnlyList<MediaStream> imageStreams, CancellationToken cancellationToken)
{
var path = GetAudioImagePath(item);
if (!File.Exists(path))
{
var directoryName = Path.GetDirectoryName(path) ?? throw new InvalidOperationException($"Invalid path '{path}'");
Directory.CreateDirectory(directoryName);
var imageStream = imageStreams.FirstOrDefault(i => (i.Comment ?? string.Empty).Contains("front", StringComparison.OrdinalIgnoreCase)) ??
imageStreams.FirstOrDefault(i => (i.Comment ?? string.Empty).Contains("cover", StringComparison.OrdinalIgnoreCase)) ??
imageStreams.FirstOrDefault();
var imageStreamIndex = imageStream?.Index;
var tempFile = await _mediaEncoder.ExtractAudioImage(item.Path, imageStreamIndex, cancellationToken).ConfigureAwait(false);
File.Copy(tempFile, path, true);
try
{
_fileSystem.DeleteFile(tempFile);
}
catch
{
}
}
return new DynamicImageResponse
{
HasImage = true,
Path = path
};
}
private string GetAudioImagePath(Audio item)
{
string filename;
if (item.GetType() == typeof(Audio))
{
if (item.AlbumArtists.Count > 0
&& !string.IsNullOrWhiteSpace(item.Album)
&& !string.IsNullOrWhiteSpace(item.AlbumArtists[0]))
{
filename = (item.Album + "-" + item.AlbumArtists[0]).GetMD5().ToString("N", CultureInfo.InvariantCulture);
}
else
{
filename = item.Id.ToString("N", CultureInfo.InvariantCulture);
}
filename += ".jpg";
}
else
{
// If it's an audio book or audio podcast, allow unique image per item
filename = item.Id.ToString("N", CultureInfo.InvariantCulture) + ".jpg";
}
var prefix = filename.AsSpan().Slice(0, 1);
return Path.Join(AudioImagesPath, prefix, filename);
}
/// <inheritdoc />
public bool Supports(BaseItem item)
{
if (item.IsShortcut)
{
return false;
}
if (!item.IsFileProtocol)
{
return false;
}
return item is Audio;
}
}
}
@@ -0,0 +1,40 @@
using Emby.Naming.Common;
using MediaBrowser.Controller.Entities;
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 audio files for <see cref="Video"/>.
/// </summary>
public class AudioResolver : MediaInfoResolver
{
/// <summary>
/// Initializes a new instance of the <see cref="AudioResolver"/> class for external audio 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 AudioResolver(
ILogger<AudioResolver> logger,
ILocalizationManager localizationManager,
IMediaEncoder mediaEncoder,
IFileSystem fileSystem,
NamingOptions namingOptions)
: base(
logger,
localizationManager,
mediaEncoder,
fileSystem,
namingOptions,
DlnaProfileType.Audio)
{
}
}
}
@@ -0,0 +1,245 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Net;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.MediaInfo
{
/// <summary>
/// Uses <see cref="IMediaEncoder"/> to extract embedded images.
/// </summary>
public class EmbeddedImageProvider : IDynamicImageProvider, IHasOrder
{
private static readonly string[] _primaryImageFileNames =
{
"poster",
"folder",
"cover",
"default",
"movie",
"show"
};
private static readonly string[] _backdropImageFileNames =
{
"backdrop",
"background",
"art"
};
private static readonly string[] _logoImageFileNames =
{
"logo",
};
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IMediaEncoder _mediaEncoder;
private readonly ILogger<EmbeddedImageProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="EmbeddedImageProvider"/> class.
/// </summary>
/// <param name="mediaSourceManager">The media source manager for fetching item streams and attachments.</param>
/// <param name="mediaEncoder">The media encoder for extracting attached/embedded images.</param>
/// <param name="logger">The logger.</param>
public EmbeddedImageProvider(IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, ILogger<EmbeddedImageProvider> logger)
{
_mediaSourceManager = mediaSourceManager;
_mediaEncoder = mediaEncoder;
_logger = logger;
}
/// <inheritdoc />
public string Name => "Embedded Image Extractor";
/// <inheritdoc />
// Default to after internet image providers but before Screen Grabber
public int Order => 99;
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
if (item is Video)
{
if (item is Episode)
{
return new[]
{
ImageType.Primary,
};
}
return new[]
{
ImageType.Primary,
ImageType.Backdrop,
ImageType.Logo,
};
}
return Array.Empty<ImageType>();
}
/// <inheritdoc />
public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
{
var video = (Video)item;
// No support for these
if (video.IsPlaceHolder || video.VideoType == VideoType.Dvd)
{
return Task.FromResult(new DynamicImageResponse { HasImage = false });
}
return GetEmbeddedImage(video, type, cancellationToken);
}
private async Task<DynamicImageResponse> GetEmbeddedImage(Video item, ImageType type, CancellationToken cancellationToken)
{
MediaSourceInfo mediaSource = new MediaSourceInfo
{
VideoType = item.VideoType,
IsoType = item.IsoType,
Protocol = item.PathProtocol ?? MediaProtocol.File,
};
string[] imageFileNames = type switch
{
ImageType.Primary => _primaryImageFileNames,
ImageType.Backdrop => _backdropImageFileNames,
ImageType.Logo => _logoImageFileNames,
_ => Array.Empty<string>()
};
if (imageFileNames.Length == 0)
{
_logger.LogWarning("Attempted to load unexpected image type: {Type}", type);
return new DynamicImageResponse { HasImage = false };
}
// Try attachments first
var attachmentStream = _mediaSourceManager.GetMediaAttachments(item.Id)
.FirstOrDefault(attachment => !string.IsNullOrEmpty(attachment.FileName)
&& imageFileNames.Any(name => attachment.FileName.Contains(name, StringComparison.OrdinalIgnoreCase)));
if (attachmentStream is not null)
{
return await ExtractAttachment(item, attachmentStream, mediaSource, cancellationToken).ConfigureAwait(false);
}
// Fall back to EmbeddedImage streams
var imageStreams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
{
ItemId = item.Id,
Type = MediaStreamType.EmbeddedImage
});
if (imageStreams.Count == 0)
{
// Can't extract if we don't have any EmbeddedImage streams
return new DynamicImageResponse { HasImage = false };
}
// Extract first stream containing an element of imageFileNames
var imageStream = imageStreams
.FirstOrDefault(stream => !string.IsNullOrEmpty(stream.Comment)
&& imageFileNames.Any(name => stream.Comment.Contains(name, StringComparison.OrdinalIgnoreCase)));
// Primary type only: default to first image if none found by label
if (imageStream is null)
{
if (type == ImageType.Primary)
{
imageStream = imageStreams[0];
}
else
{
// No streams matched, abort
return new DynamicImageResponse { HasImage = false };
}
}
var format = imageStream.Codec switch
{
"bmp" => ImageFormat.Bmp,
"gif" => ImageFormat.Gif,
"mjpeg" => ImageFormat.Jpg,
"png" => ImageFormat.Png,
"webp" => ImageFormat.Webp,
_ => ImageFormat.Jpg
};
string extractedImagePath =
await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, imageStream, imageStream.Index, format, cancellationToken)
.ConfigureAwait(false);
return new DynamicImageResponse
{
Format = format,
HasImage = true,
Path = extractedImagePath,
Protocol = MediaProtocol.File
};
}
private async Task<DynamicImageResponse> ExtractAttachment(Video item, MediaAttachment attachmentStream, MediaSourceInfo mediaSource, CancellationToken cancellationToken)
{
var extension = string.IsNullOrEmpty(attachmentStream.MimeType)
? Path.GetExtension(attachmentStream.FileName)
: MimeTypes.ToExtension(attachmentStream.MimeType);
ImageFormat format = extension switch
{
".bmp" => ImageFormat.Bmp,
".gif" => ImageFormat.Gif,
".png" => ImageFormat.Png,
".webp" => ImageFormat.Webp,
_ => ImageFormat.Jpg
};
string extractedAttachmentPath =
await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, null, attachmentStream.Index, format, cancellationToken)
.ConfigureAwait(false);
return new DynamicImageResponse
{
Format = format,
HasImage = true,
Path = extractedAttachmentPath,
Protocol = MediaProtocol.File
};
}
/// <inheritdoc />
public bool Supports(BaseItem item)
{
if (item.IsShortcut)
{
return false;
}
if (!item.IsFileProtocol)
{
return false;
}
return item is Video video && !video.IsPlaceHolder && video.IsCompleteMedia;
}
}
}
@@ -0,0 +1,674 @@
#pragma warning disable CA1068, CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.MediaInfo
{
public class FFProbeVideoInfo
{
private readonly ILogger<FFProbeVideoInfo> _logger;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IMediaEncoder _mediaEncoder;
private readonly IBlurayExaminer _blurayExaminer;
private readonly ILocalizationManager _localization;
private readonly IChapterManager _chapterManager;
private readonly IServerConfigurationManager _config;
private readonly ISubtitleManager _subtitleManager;
private readonly ILibraryManager _libraryManager;
private readonly AudioResolver _audioResolver;
private readonly SubtitleResolver _subtitleResolver;
private readonly IMediaAttachmentRepository _mediaAttachmentRepository;
private readonly IMediaStreamRepository _mediaStreamRepository;
public FFProbeVideoInfo(
ILogger<FFProbeVideoInfo> logger,
IMediaSourceManager mediaSourceManager,
IMediaEncoder mediaEncoder,
IBlurayExaminer blurayExaminer,
ILocalizationManager localization,
IChapterManager chapterManager,
IServerConfigurationManager config,
ISubtitleManager subtitleManager,
ILibraryManager libraryManager,
AudioResolver audioResolver,
SubtitleResolver subtitleResolver,
IMediaAttachmentRepository mediaAttachmentRepository,
IMediaStreamRepository mediaStreamRepository)
{
_logger = logger;
_mediaSourceManager = mediaSourceManager;
_mediaEncoder = mediaEncoder;
_blurayExaminer = blurayExaminer;
_localization = localization;
_chapterManager = chapterManager;
_config = config;
_subtitleManager = subtitleManager;
_libraryManager = libraryManager;
_audioResolver = audioResolver;
_subtitleResolver = subtitleResolver;
_mediaAttachmentRepository = mediaAttachmentRepository;
_mediaStreamRepository = mediaStreamRepository;
_mediaStreamRepository = mediaStreamRepository;
}
public async Task<ItemUpdateType> ProbeVideo<T>(
T item,
MetadataRefreshOptions options,
CancellationToken cancellationToken)
where T : Video
{
BlurayDiscInfo? blurayDiscInfo = null;
Model.MediaInfo.MediaInfo? mediaInfoResult = null;
if (!item.IsShortcut || options.EnableRemoteContentProbe)
{
if (item.VideoType == VideoType.Dvd)
{
// Get list of playable .vob files
var vobs = _mediaEncoder.GetPrimaryPlaylistVobFiles(item.Path, null);
// Return if no playable .vob files are found
if (vobs.Count == 0)
{
_logger.LogError("No playable .vob files found in DVD structure, skipping FFprobe.");
return ItemUpdateType.MetadataImport;
}
// Fetch metadata of first .vob file
mediaInfoResult = await GetMediaInfo(
new Video
{
Path = vobs[0]
},
cancellationToken).ConfigureAwait(false);
// Sum up the runtime of all .vob files skipping the first .vob
for (var i = 1; i < vobs.Count; i++)
{
var tmpMediaInfo = await GetMediaInfo(
new Video
{
Path = vobs[i]
},
cancellationToken).ConfigureAwait(false);
mediaInfoResult.RunTimeTicks += tmpMediaInfo.RunTimeTicks;
}
}
else if (item.VideoType == VideoType.BluRay)
{
// Get BD disc information
blurayDiscInfo = GetBDInfo(item.Path);
// Return if no playable .m2ts files are found
if (blurayDiscInfo is null || blurayDiscInfo.Files.Length == 0)
{
_logger.LogError("No playable .m2ts files found in Blu-ray structure, skipping FFprobe.");
return ItemUpdateType.MetadataImport;
}
// Fetch metadata of first .m2ts file
mediaInfoResult = await GetMediaInfo(
new Video
{
Path = blurayDiscInfo.Files[0]
},
cancellationToken).ConfigureAwait(false);
}
else
{
mediaInfoResult = await GetMediaInfo(item, cancellationToken).ConfigureAwait(false);
}
cancellationToken.ThrowIfCancellationRequested();
}
await Fetch(item, cancellationToken, mediaInfoResult, blurayDiscInfo, options).ConfigureAwait(false);
return ItemUpdateType.MetadataImport;
}
private Task<Model.MediaInfo.MediaInfo> GetMediaInfo(
Video item,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var path = item.Path;
var protocol = item.PathProtocol ?? MediaProtocol.File;
if (item.IsShortcut)
{
path = item.ShortcutPath;
protocol = _mediaSourceManager.GetPathProtocol(path);
}
return _mediaEncoder.GetMediaInfo(
new MediaInfoRequest
{
ExtractChapters = true,
MediaType = DlnaProfileType.Video,
MediaSource = new MediaSourceInfo
{
Path = path,
Protocol = protocol,
VideoType = item.VideoType,
IsoType = item.IsoType
}
},
cancellationToken);
}
protected async Task Fetch(
Video video,
CancellationToken cancellationToken,
Model.MediaInfo.MediaInfo? mediaInfo,
BlurayDiscInfo? blurayInfo,
MetadataRefreshOptions options)
{
List<MediaStream> mediaStreams = new List<MediaStream>();
IReadOnlyList<MediaAttachment> mediaAttachments;
ChapterInfo[] chapters;
// Add external streams before adding the streams from the file to preserve stream IDs on remote videos
await AddExternalSubtitlesAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false);
await AddExternalAudioAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false);
var startIndex = mediaStreams.Count == 0 ? 0 : (mediaStreams.Max(i => i.Index) + 1);
if (mediaInfo is not null)
{
foreach (var mediaStream in mediaInfo.MediaStreams)
{
mediaStream.Index = startIndex++;
mediaStreams.Add(mediaStream);
}
mediaAttachments = mediaInfo.MediaAttachments;
video.TotalBitrate = mediaInfo.Bitrate;
video.RunTimeTicks = mediaInfo.RunTimeTicks;
video.Container = mediaInfo.Container;
var videoType = video.VideoType;
if (videoType == VideoType.BluRay || videoType == VideoType.Dvd)
{
video.Size = mediaInfo.Size;
}
chapters = mediaInfo.Chapters ?? [];
if (blurayInfo is not null)
{
FetchBdInfo(video, ref chapters, mediaStreams, blurayInfo);
}
}
else
{
foreach (var mediaStream in video.GetMediaStreams())
{
if (!mediaStream.IsExternal)
{
mediaStream.Index = startIndex++;
mediaStreams.Add(mediaStream);
}
}
mediaAttachments = [];
chapters = [];
}
var libraryOptions = _libraryManager.GetLibraryOptions(video);
if (mediaInfo is not null)
{
FetchEmbeddedInfo(video, mediaInfo, options, libraryOptions);
FetchPeople(video, mediaInfo, options);
video.Timestamp = mediaInfo.Timestamp;
video.Video3DFormat ??= mediaInfo.Video3DFormat;
}
if (libraryOptions.AllowEmbeddedSubtitles == EmbeddedSubtitleOptions.AllowText || libraryOptions.AllowEmbeddedSubtitles == EmbeddedSubtitleOptions.AllowNone)
{
_logger.LogDebug("Disabling embedded image subtitles for {Path} due to DisableEmbeddedImageSubtitles setting", video.Path);
mediaStreams.RemoveAll(i => i.Type == MediaStreamType.Subtitle && !i.IsExternal && !i.IsTextSubtitleStream);
}
if (libraryOptions.AllowEmbeddedSubtitles == EmbeddedSubtitleOptions.AllowImage || libraryOptions.AllowEmbeddedSubtitles == EmbeddedSubtitleOptions.AllowNone)
{
_logger.LogDebug("Disabling embedded text subtitles for {Path} due to DisableEmbeddedTextSubtitles setting", video.Path);
mediaStreams.RemoveAll(i => i.Type == MediaStreamType.Subtitle && !i.IsExternal && i.IsTextSubtitleStream);
}
var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video);
video.Height = videoStream?.Height ?? 0;
video.Width = videoStream?.Width ?? 0;
video.DefaultVideoStreamIndex = videoStream?.Index;
video.HasSubtitles = mediaStreams.Any(i => i.Type == MediaStreamType.Subtitle);
_mediaStreamRepository.SaveMediaStreams(video.Id, mediaStreams, cancellationToken);
_mediaAttachmentRepository.SaveMediaAttachments(video.Id, mediaAttachments, cancellationToken);
if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh
|| options.MetadataRefreshMode == MetadataRefreshMode.Default)
{
if (_config.Configuration.DummyChapterDuration > 0 && chapters.Length == 0 && mediaStreams.Any(i => i.Type == MediaStreamType.Video))
{
chapters = CreateDummyChapters(video);
}
NormalizeChapterNames(chapters);
var extractDuringScan = false;
if (libraryOptions is not null)
{
extractDuringScan = libraryOptions.ExtractChapterImagesDuringLibraryScan;
}
await _chapterManager.RefreshChapterImages(video, options.DirectoryService, chapters, extractDuringScan, false, cancellationToken).ConfigureAwait(false);
_chapterManager.SaveChapters(video, chapters);
}
}
private void NormalizeChapterNames(ChapterInfo[] chapters)
{
for (int i = 0; i < chapters.Length; i++)
{
string? name = chapters[i].Name;
// Check if the name is empty and/or if the name is a time
// Some ripping programs do that.
if (string.IsNullOrWhiteSpace(name)
|| TimeSpan.TryParse(name, out _))
{
chapters[i].Name = string.Format(
CultureInfo.InvariantCulture,
_localization.GetLocalizedString("ChapterNameValue"),
(i + 1).ToString(CultureInfo.InvariantCulture));
}
}
}
private void FetchBdInfo(Video video, ref ChapterInfo[] chapters, List<MediaStream> mediaStreams, BlurayDiscInfo blurayInfo)
{
var ffmpegVideoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video);
var externalStreams = mediaStreams.Where(s => s.IsExternal).ToList();
// Fill video properties from the BDInfo result
mediaStreams.Clear();
// Rebuild the list with external streams first
int index = 0;
foreach (var stream in externalStreams.Concat(blurayInfo.MediaStreams))
{
stream.Index = index++;
mediaStreams.Add(stream);
}
if (blurayInfo.RunTimeTicks.HasValue && blurayInfo.RunTimeTicks.Value > 0)
{
video.RunTimeTicks = blurayInfo.RunTimeTicks;
}
if (blurayInfo.Chapters is not null)
{
double[] brChapter = blurayInfo.Chapters;
chapters = new ChapterInfo[brChapter.Length];
for (int i = 0; i < brChapter.Length; i++)
{
chapters[i] = new ChapterInfo
{
StartPositionTicks = TimeSpan.FromSeconds(brChapter[i]).Ticks
};
}
}
var blurayVideoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video);
// Use the ffprobe values if these are empty
if (blurayVideoStream is not null && ffmpegVideoStream is not null)
{
// Always use ffmpeg's detected codec since that is what the rest of the codebase expects.
blurayVideoStream.Codec = ffmpegVideoStream.Codec;
blurayVideoStream.BitRate = blurayVideoStream.BitRate.GetValueOrDefault() == 0 ? ffmpegVideoStream.BitRate : blurayVideoStream.BitRate;
blurayVideoStream.Width = blurayVideoStream.Width.GetValueOrDefault() == 0 ? ffmpegVideoStream.Width : blurayVideoStream.Width;
blurayVideoStream.Height = blurayVideoStream.Height.GetValueOrDefault() == 0 ? ffmpegVideoStream.Height : blurayVideoStream.Height;
blurayVideoStream.ColorRange = ffmpegVideoStream.ColorRange;
blurayVideoStream.ColorSpace = ffmpegVideoStream.ColorSpace;
blurayVideoStream.ColorTransfer = ffmpegVideoStream.ColorTransfer;
blurayVideoStream.ColorPrimaries = ffmpegVideoStream.ColorPrimaries;
}
}
/// <summary>
/// Gets information about the longest playlist on a bdrom.
/// </summary>
/// <param name="path">The path.</param>
/// <returns>VideoStream.</returns>
private BlurayDiscInfo? GetBDInfo(string path)
{
ArgumentException.ThrowIfNullOrEmpty(path);
try
{
return _blurayExaminer.GetDiscInfo(path);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting BDInfo");
return null;
}
}
private void FetchEmbeddedInfo(Video video, Model.MediaInfo.MediaInfo data, MetadataRefreshOptions refreshOptions, LibraryOptions libraryOptions)
{
var replaceData = refreshOptions.ReplaceAllMetadata;
if (!video.IsLocked && !video.LockedFields.Contains(MetadataField.OfficialRating))
{
if (string.IsNullOrWhiteSpace(video.OfficialRating) || replaceData)
{
video.OfficialRating = data.OfficialRating;
}
}
if (!video.IsLocked && !video.LockedFields.Contains(MetadataField.Genres))
{
if (video.Genres.Length == 0 || replaceData)
{
video.Genres = [];
foreach (var genre in data.Genres.Trimmed())
{
video.AddGenre(genre);
}
}
}
if (!video.IsLocked && !video.LockedFields.Contains(MetadataField.Studios))
{
if (video.Studios.Length == 0 || replaceData)
{
video.SetStudios(data.Studios);
}
}
if (!video.IsLocked && video is MusicVideo musicVideo)
{
if (string.IsNullOrEmpty(musicVideo.Album) || replaceData)
{
musicVideo.Album = data.Album;
}
if (musicVideo.Artists.Count == 0 || replaceData)
{
musicVideo.Artists = data.Artists;
}
}
if (data.ProductionYear.HasValue)
{
if (!video.ProductionYear.HasValue || replaceData)
{
video.ProductionYear = data.ProductionYear;
}
}
if (data.PremiereDate.HasValue)
{
if (!video.PremiereDate.HasValue || replaceData)
{
video.PremiereDate = data.PremiereDate;
}
}
if (data.IndexNumber.HasValue)
{
if (!video.IndexNumber.HasValue || replaceData)
{
video.IndexNumber = data.IndexNumber;
}
}
if (data.ParentIndexNumber.HasValue)
{
if (!video.ParentIndexNumber.HasValue || replaceData)
{
video.ParentIndexNumber = data.ParentIndexNumber;
}
}
if (!video.IsLocked && !video.LockedFields.Contains(MetadataField.Name))
{
if (!string.IsNullOrWhiteSpace(data.Name) && libraryOptions.EnableEmbeddedTitles)
{
// Separate option to use the embedded name for extras because it will often be the same name as the movie
if (!video.ExtraType.HasValue || libraryOptions.EnableEmbeddedExtrasTitles)
{
video.Name = data.Name;
}
}
if (!string.IsNullOrWhiteSpace(data.ForcedSortName))
{
video.ForcedSortName = data.ForcedSortName;
}
}
// If we don't have a ProductionYear try and get it from PremiereDate
if (video.PremiereDate.HasValue && !video.ProductionYear.HasValue)
{
video.ProductionYear = video.PremiereDate.Value.ToLocalTime().Year;
}
if (!video.IsLocked && !video.LockedFields.Contains(MetadataField.Overview))
{
if (string.IsNullOrWhiteSpace(video.Overview) || replaceData)
{
video.Overview = data.Overview;
}
}
}
private void FetchPeople(Video video, Model.MediaInfo.MediaInfo data, MetadataRefreshOptions options)
{
if (video.IsLocked
|| video.LockedFields.Contains(MetadataField.Cast)
|| data.People.Length == 0)
{
return;
}
if (options.ReplaceAllMetadata || _libraryManager.GetPeople(video).Count == 0)
{
var people = new List<PersonInfo>();
foreach (var person in data.People)
{
if (!string.IsNullOrWhiteSpace(person.Name))
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = person.Name,
Type = person.Type,
Role = person.Role?.Trim()
});
}
}
_libraryManager.UpdatePeople(video, people);
}
}
/// <summary>
/// Adds the external subtitles.
/// </summary>
/// <param name="video">The video.</param>
/// <param name="currentStreams">The current streams.</param>
/// <param name="options">The refreshOptions.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
private async Task AddExternalSubtitlesAsync(
Video video,
List<MediaStream> currentStreams,
MetadataRefreshOptions options,
CancellationToken cancellationToken)
{
var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
var externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken).ConfigureAwait(false);
var enableSubtitleDownloading = options.MetadataRefreshMode == MetadataRefreshMode.Default ||
options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh;
var subtitleOptions = _config.GetConfiguration<SubtitleOptions>("subtitles");
var libraryOptions = _libraryManager.GetLibraryOptions(video);
string[] subtitleDownloadLanguages;
bool skipIfEmbeddedSubtitlesPresent;
bool skipIfAudioTrackMatches;
bool requirePerfectMatch;
bool enabled;
if (libraryOptions.SubtitleDownloadLanguages is null)
{
subtitleDownloadLanguages = subtitleOptions.DownloadLanguages;
skipIfEmbeddedSubtitlesPresent = subtitleOptions.SkipIfEmbeddedSubtitlesPresent;
skipIfAudioTrackMatches = subtitleOptions.SkipIfAudioTrackMatches;
requirePerfectMatch = subtitleOptions.RequirePerfectMatch;
enabled = (subtitleOptions.DownloadEpisodeSubtitles &&
video is Episode) ||
(subtitleOptions.DownloadMovieSubtitles &&
video is Movie);
}
else
{
subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages;
skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent;
skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches;
requirePerfectMatch = libraryOptions.RequirePerfectSubtitleMatch;
enabled = true;
}
if (enableSubtitleDownloading && enabled)
{
var downloadedLanguages = await new SubtitleDownloader(
_logger,
_subtitleManager).DownloadSubtitles(
video,
currentStreams.Concat(externalSubtitleStreams).ToList(),
skipIfEmbeddedSubtitlesPresent,
skipIfAudioTrackMatches,
requirePerfectMatch,
subtitleDownloadLanguages,
libraryOptions.DisabledSubtitleFetchers,
libraryOptions.SubtitleFetcherOrder,
true,
cancellationToken).ConfigureAwait(false);
// Rescan
if (downloadedLanguages.Count > 0)
{
externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, true, cancellationToken).ConfigureAwait(false);
}
}
video.SubtitleFiles = externalSubtitleStreams.Select(i => i.Path).Distinct().ToArray();
currentStreams.AddRange(externalSubtitleStreams);
}
/// <summary>
/// Adds the external audio.
/// </summary>
/// <param name="video">The video.</param>
/// <param name="currentStreams">The current streams.</param>
/// <param name="options">The refreshOptions.</param>
/// <param name="cancellationToken">The cancellation token.</param>
private async Task AddExternalAudioAsync(
Video video,
List<MediaStream> currentStreams,
MetadataRefreshOptions options,
CancellationToken cancellationToken)
{
var startIndex = currentStreams.Count == 0 ? 0 : currentStreams.Max(i => i.Index) + 1;
var externalAudioStreams = await _audioResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken).ConfigureAwait(false);
video.AudioFiles = externalAudioStreams.Select(i => i.Path).Distinct().ToArray();
currentStreams.AddRange(externalAudioStreams);
}
/// <summary>
/// Creates dummy chapters.
/// </summary>
/// <param name="video">The video.</param>
/// <returns>An array of dummy chapters.</returns>
internal ChapterInfo[] CreateDummyChapters(Video video)
{
var runtime = video.RunTimeTicks.GetValueOrDefault();
// Only process files with a runtime greater than 0 and less than 12h. The latter are likely corrupted.
if (runtime < 0 || runtime > TimeSpan.FromHours(12).Ticks)
{
throw new ArgumentException(
string.Format(
CultureInfo.InvariantCulture,
"{0} has an invalid runtime of {1} minutes",
video.Name,
TimeSpan.FromTicks(runtime).TotalMinutes));
}
long dummyChapterDuration = TimeSpan.FromSeconds(_config.Configuration.DummyChapterDuration).Ticks;
if (runtime <= dummyChapterDuration)
{
return [];
}
int chapterCount = (int)(runtime / dummyChapterDuration);
var chapters = new ChapterInfo[chapterCount];
long currentChapterTicks = 0;
for (int i = 0; i < chapterCount; i++)
{
chapters[i] = new ChapterInfo
{
StartPositionTicks = currentChapterTicks
};
currentChapterTicks += dummyChapterDuration;
}
return chapters;
}
}
}
@@ -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)
{
}
}
@@ -0,0 +1,347 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
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;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.MediaInfo
{
/// <summary>
/// Resolves external files for <see cref="Video"/>.
/// </summary>
public abstract class MediaInfoResolver
{
/// <summary>
/// The <see cref="ExternalPathParser"/> instance.
/// </summary>
private readonly ExternalPathParser _externalPathParser;
/// <summary>
/// The <see cref="IMediaEncoder"/> instance.
/// </summary>
private readonly IMediaEncoder _mediaEncoder;
private readonly ILogger _logger;
private readonly IFileSystem _fileSystem;
/// <summary>
/// The <see cref="NamingOptions"/> instance.
/// </summary>
private readonly NamingOptions _namingOptions;
/// <summary>
/// The <see cref="DlnaProfileType"/> of the files this resolver should resolve.
/// </summary>
private readonly DlnaProfileType _type;
/// <summary>
/// Initializes a new instance of the <see cref="MediaInfoResolver"/> class.
/// </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>
/// <param name="type">The <see cref="DlnaProfileType"/> of the parsed file.</param>
protected MediaInfoResolver(
ILogger logger,
ILocalizationManager localizationManager,
IMediaEncoder mediaEncoder,
IFileSystem fileSystem,
NamingOptions namingOptions,
DlnaProfileType type)
{
_logger = logger;
_mediaEncoder = mediaEncoder;
_fileSystem = fileSystem;
_namingOptions = namingOptions;
_type = type;
_externalPathParser = new ExternalPathParser(namingOptions, localizationManager, _type);
}
/// <summary>
/// Retrieves the external streams for the provided video.
/// </summary>
/// <param name="video">The <see cref="Video"/> 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>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The external streams located.</returns>
public async Task<IReadOnlyList<MediaStream>> GetExternalStreamsAsync(
Video video,
int startIndex,
IDirectoryService directoryService,
bool clearCache,
CancellationToken cancellationToken)
{
if (!video.IsFileProtocol)
{
return Array.Empty<MediaStream>();
}
var pathInfos = GetExternalFiles(video, directoryService, clearCache);
if (!pathInfos.Any())
{
return Array.Empty<MediaStream>();
}
var mediaStreams = new List<MediaStream>();
foreach (var pathInfo in pathInfos)
{
if (!pathInfo.Path.AsSpan().EndsWith(".strm", StringComparison.OrdinalIgnoreCase))
{
try
{
var mediaInfo = await GetMediaInfo(pathInfo.Path, _type, cancellationToken).ConfigureAwait(false);
if (mediaInfo.MediaStreams.Count == 1)
{
MediaStream mediaStream = mediaInfo.MediaStreams[0];
if ((mediaStream.Type == MediaStreamType.Audio && _type == DlnaProfileType.Audio)
|| (mediaStream.Type == MediaStreamType.Subtitle && _type == DlnaProfileType.Subtitle))
{
mediaStream.Index = startIndex++;
mediaStream.IsDefault = pathInfo.IsDefault;
mediaStream.IsForced = pathInfo.IsForced || mediaStream.IsForced;
mediaStream.IsHearingImpaired = pathInfo.IsHearingImpaired || mediaStream.IsHearingImpaired;
mediaStreams.Add(MergeMetadata(mediaStream, pathInfo));
}
}
else
{
foreach (MediaStream mediaStream in mediaInfo.MediaStreams)
{
if ((mediaStream.Type == MediaStreamType.Audio && _type == DlnaProfileType.Audio)
|| (mediaStream.Type == MediaStreamType.Subtitle && _type == DlnaProfileType.Subtitle))
{
mediaStream.Index = startIndex++;
mediaStreams.Add(MergeMetadata(mediaStream, pathInfo));
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting external streams from {Path}", pathInfo.Path);
continue;
}
}
}
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>
/// Returns the external file infos for the given video.
/// </summary>
/// <param name="video">The <see cref="Video"/> 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(
Video video,
IDirectoryService directoryService,
bool clearCache)
{
if (!video.IsFileProtocol)
{
return Array.Empty<ExternalPathParserResult>();
}
// Check if video folder exists
string folder = video.ContainingFolderPath;
if (!_fileSystem.DirectoryExists(folder))
{
return Array.Empty<ExternalPathParserResult>();
}
var files = directoryService.GetFilePaths(folder, clearCache, true).ToList();
files.Remove(video.Path);
var internalMetadataPath = video.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 = video.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 external file infos for the given audio.
/// </summary>
/// <param name="audio">The <see cref="Audio"/> object to search external files for.</param>
/// <param name="directoryService">The directory service to search for files.</param>
/// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
/// <returns>The external file paths located.</returns>
public IReadOnlyList<ExternalPathParserResult> GetExternalFiles(
Audio audio,
IDirectoryService directoryService,
bool clearCache)
{
if (!audio.IsFileProtocol)
{
return Array.Empty<ExternalPathParserResult>();
}
string folder = audio.ContainingFolderPath;
var files = directoryService.GetFilePaths(folder, clearCache, true).ToList();
files.Remove(audio.Path);
var internalMetadataPath = audio.GetInternalMetadataPath();
if (_fileSystem.DirectoryExists(internalMetadataPath))
{
files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true));
}
if (files.Count == 0)
{
return Array.Empty<ExternalPathParserResult>();
}
var externalPathInfos = new List<ExternalPathParserResult>();
ReadOnlySpan<char> prefix = audio.FileNameWithoutExtension;
foreach (var file in files)
{
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan());
if (fileNameWithoutExtension.Length >= prefix.Length
&& prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase)
&& (fileNameWithoutExtension.Length == prefix.Length || _namingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[prefix.Length])))
{
var externalPathInfo = _externalPathParser.ParseFile(file, fileNameWithoutExtension[prefix.Length..].ToString());
if (externalPathInfo is not null)
{
externalPathInfos.Add(externalPathInfo);
}
}
}
return externalPathInfos;
}
/// <summary>
/// Returns the media info of the given file.
/// </summary>
/// <param name="path">The path to the file.</param>
/// <param name="type">The <see cref="DlnaProfileType"/>.</param>
/// <param name="cancellationToken">The cancellation token to cancel operation.</param>
/// <returns>The media info for the given file.</returns>
private Task<Model.MediaInfo.MediaInfo> GetMediaInfo(string path, DlnaProfileType type, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return _mediaEncoder.GetMediaInfo(
new MediaInfoRequest
{
MediaType = type,
MediaSource = new MediaSourceInfo
{
Path = path,
Protocol = MediaProtocol.File
}
},
cancellationToken);
}
/// <summary>
/// Merges path metadata into stream metadata.
/// </summary>
/// <param name="mediaStream">The <see cref="MediaStream"/> object.</param>
/// <param name="pathInfo">The <see cref="ExternalPathParserResult"/> object.</param>
/// <returns>The modified mediaStream.</returns>
private MediaStream MergeMetadata(MediaStream mediaStream, ExternalPathParserResult pathInfo)
{
mediaStream.Path = pathInfo.Path;
mediaStream.IsExternal = true;
mediaStream.Title = string.IsNullOrEmpty(mediaStream.Title) ? (string.IsNullOrEmpty(pathInfo.Title) ? null : pathInfo.Title) : mediaStream.Title;
mediaStream.Language = string.IsNullOrEmpty(mediaStream.Language) ? (string.IsNullOrEmpty(pathInfo.Language) ? null : pathInfo.Language) : mediaStream.Language;
return mediaStream;
}
}
}
@@ -0,0 +1,299 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Emby.Naming.Common;
using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.MediaInfo
{
/// <summary>
/// The probe provider.
/// </summary>
public class ProbeProvider : ICustomMetadataProvider<Episode>,
ICustomMetadataProvider<MusicVideo>,
ICustomMetadataProvider<Movie>,
ICustomMetadataProvider<Trailer>,
ICustomMetadataProvider<Video>,
ICustomMetadataProvider<Audio>,
ICustomMetadataProvider<AudioBook>,
IHasOrder,
IForcedProvider,
IPreRefreshProvider,
IHasItemChangeMonitor
{
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);
/// <summary>
/// Initializes a new instance of the <see cref="ProbeProvider"/> class.
/// </summary>
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
/// <param name="blurayExaminer">Instance of the <see cref="IBlurayExaminer"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="chapterManager">Instance of the <see cref="IChapterManager"/> interface.</param>
/// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
/// <param name="subtitleManager">Instance of the <see cref="ISubtitleManager"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/>.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="namingOptions">The <see cref="NamingOptions"/>.</param>
/// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
/// <param name="mediaAttachmentRepository">Instance of the <see cref="IMediaAttachmentRepository"/> interface.</param>
/// <param name="mediaStreamRepository">Instance of the <see cref="IMediaStreamRepository"/> interface.</param>
public ProbeProvider(
IMediaSourceManager mediaSourceManager,
IMediaEncoder mediaEncoder,
IBlurayExaminer blurayExaminer,
ILocalizationManager localization,
IChapterManager chapterManager,
IServerConfigurationManager config,
ISubtitleManager subtitleManager,
ILibraryManager libraryManager,
IFileSystem fileSystem,
ILoggerFactory loggerFactory,
NamingOptions namingOptions,
ILyricManager lyricManager,
IMediaAttachmentRepository mediaAttachmentRepository,
IMediaStreamRepository mediaStreamRepository)
{
_logger = loggerFactory.CreateLogger<ProbeProvider>();
_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,
mediaEncoder,
blurayExaminer,
localization,
chapterManager,
config,
subtitleManager,
libraryManager,
_audioResolver,
_subtitleResolver,
mediaAttachmentRepository,
mediaStreamRepository);
_audioProber = new AudioFileProber(
loggerFactory.CreateLogger<AudioFileProber>(),
mediaSourceManager,
mediaEncoder,
libraryManager,
_lyricResolver,
lyricManager,
mediaStreamRepository);
}
/// <inheritdoc />
public string Name => "Probe Provider";
/// <inheritdoc />
public int Order => 100;
/// <inheritdoc />
public bool HasChanged(BaseItem item, IDirectoryService directoryService)
{
var video = item as Video;
if (video is null || video.VideoType == VideoType.VideoFile || video.VideoType == VideoType.Iso)
{
var path = item.Path;
if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol)
{
var file = directoryService.GetFile(path);
if (file is not null && item.HasChanged(file.LastWriteTimeUtc))
{
_logger.LogDebug("Refreshing {ItemPath} due to file system modification.", path);
return true;
}
}
}
if (video is not null
&& item.SupportsLocalMetadata
&& !video.IsPlaceHolder)
{
var externalFiles = new HashSet<string>(_subtitleResolver.GetExternalFiles(video, directoryService, false).Select(info => info.Path), StringComparer.OrdinalIgnoreCase);
if (!new HashSet<string>(video.SubtitleFiles, StringComparer.Ordinal).SetEquals(externalFiles))
{
_logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path);
return true;
}
externalFiles = new HashSet<string>(_audioResolver.GetExternalFiles(video, directoryService, false).Select(info => info.Path), StringComparer.OrdinalIgnoreCase);
if (!new HashSet<string>(video.AudioFiles, StringComparer.Ordinal).SetEquals(externalFiles))
{
_logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path);
return true;
}
}
if (item is Audio audio
&& item.SupportsLocalMetadata)
{
var externalFiles = new HashSet<string>(_lyricResolver.GetExternalFiles(audio, directoryService, false).Select(info => info.Path), StringComparer.OrdinalIgnoreCase);
if (!new HashSet<string>(audio.LyricFiles, StringComparer.Ordinal).SetEquals(externalFiles))
{
_logger.LogDebug("Refreshing {ItemPath} due to external lyrics change.", item.Path);
return true;
}
}
return false;
}
/// <inheritdoc />
public Task<ItemUpdateType> FetchAsync(Episode item, MetadataRefreshOptions options, CancellationToken cancellationToken)
{
return FetchVideoInfo(item, options, cancellationToken);
}
/// <inheritdoc />
public Task<ItemUpdateType> FetchAsync(MusicVideo item, MetadataRefreshOptions options, CancellationToken cancellationToken)
{
return FetchVideoInfo(item, options, cancellationToken);
}
/// <inheritdoc />
public Task<ItemUpdateType> FetchAsync(Movie item, MetadataRefreshOptions options, CancellationToken cancellationToken)
{
return FetchVideoInfo(item, options, cancellationToken);
}
/// <inheritdoc />
public Task<ItemUpdateType> FetchAsync(Trailer item, MetadataRefreshOptions options, CancellationToken cancellationToken)
{
return FetchVideoInfo(item, options, cancellationToken);
}
/// <inheritdoc />
public Task<ItemUpdateType> FetchAsync(Video item, MetadataRefreshOptions options, CancellationToken cancellationToken)
{
return FetchVideoInfo(item, options, cancellationToken);
}
/// <inheritdoc />
public Task<ItemUpdateType> FetchAsync(Audio item, MetadataRefreshOptions options, CancellationToken cancellationToken)
{
return FetchAudioInfo(item, options, cancellationToken);
}
/// <inheritdoc />
public Task<ItemUpdateType> FetchAsync(AudioBook item, MetadataRefreshOptions options, CancellationToken cancellationToken)
{
return FetchAudioInfo(item, options, cancellationToken);
}
/// <summary>
/// Fetches video information for an item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
/// <typeparam name="T">The type of item to resolve.</typeparam>
/// <returns>A <see cref="Task"/> fetching the <see cref="ItemUpdateType"/> for an item.</returns>
public Task<ItemUpdateType> FetchVideoInfo<T>(T item, MetadataRefreshOptions options, CancellationToken cancellationToken)
where T : Video
{
if (item.IsPlaceHolder)
{
return _cachedTask;
}
if (!item.IsCompleteMedia)
{
return _cachedTask;
}
if (item.IsVirtualItem)
{
return _cachedTask;
}
if (!options.EnableRemoteContentProbe && !item.IsFileProtocol)
{
return _cachedTask;
}
if (item.IsShortcut)
{
FetchShortcutInfo(item);
}
return _videoProber.ProbeVideo(item, options, cancellationToken);
}
private string NormalizeStrmLine(string line)
{
return line.Replace("\t", string.Empty, StringComparison.Ordinal)
.Replace("\r", string.Empty, StringComparison.Ordinal)
.Replace("\n", string.Empty, StringComparison.Ordinal)
.Trim();
}
private void FetchShortcutInfo(BaseItem item)
{
item.ShortcutPath = File.ReadAllLines(item.Path)
.Select(NormalizeStrmLine)
.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i) && !i.StartsWith('#'));
}
/// <summary>
/// Fetches audio information for an item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
/// <typeparam name="T">The type of item to resolve.</typeparam>
/// <returns>A <see cref="Task"/> fetching the <see cref="ItemUpdateType"/> for an item.</returns>
public Task<ItemUpdateType> FetchAudioInfo<T>(T item, MetadataRefreshOptions options, CancellationToken cancellationToken)
where T : Audio
{
if (item.IsVirtualItem)
{
return _cachedTask;
}
if (!options.EnableRemoteContentProbe && !item.IsFileProtocol)
{
return _cachedTask;
}
if (item.IsShortcut)
{
FetchShortcutInfo(item);
}
return _audioProber.Probe(item, options, cancellationToken);
}
}
}
@@ -0,0 +1,213 @@
#nullable disable
#pragma warning disable CA1002, CS1591
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.MediaInfo
{
public class SubtitleDownloader
{
private readonly ILogger _logger;
private readonly ISubtitleManager _subtitleManager;
public SubtitleDownloader(ILogger logger, ISubtitleManager subtitleManager)
{
_logger = logger;
_subtitleManager = subtitleManager;
}
public async Task<List<string>> DownloadSubtitles(
Video video,
IReadOnlyList<MediaStream> mediaStreams,
bool skipIfEmbeddedSubtitlesPresent,
bool skipIfAudioTrackMatches,
bool requirePerfectMatch,
IEnumerable<string> languages,
string[] disabledSubtitleFetchers,
string[] subtitleFetcherOrder,
bool isAutomated,
CancellationToken cancellationToken)
{
var downloadedLanguages = new List<string>();
foreach (var lang in languages)
{
var downloaded = await DownloadSubtitles(
video,
mediaStreams,
skipIfEmbeddedSubtitlesPresent,
skipIfAudioTrackMatches,
requirePerfectMatch,
lang,
disabledSubtitleFetchers,
subtitleFetcherOrder,
isAutomated,
cancellationToken).ConfigureAwait(false);
if (downloaded)
{
downloadedLanguages.Add(lang);
}
}
return downloadedLanguages;
}
public Task<bool> DownloadSubtitles(
Video video,
IReadOnlyList<MediaStream> mediaStreams,
bool skipIfEmbeddedSubtitlesPresent,
bool skipIfAudioTrackMatches,
bool requirePerfectMatch,
string lang,
string[] disabledSubtitleFetchers,
string[] subtitleFetcherOrder,
bool isAutomated,
CancellationToken cancellationToken)
{
if (video.VideoType != VideoType.VideoFile)
{
return Task.FromResult(false);
}
if (!video.IsCompleteMedia)
{
return Task.FromResult(false);
}
VideoContentType mediaType;
if (video is Episode)
{
mediaType = VideoContentType.Episode;
}
else if (video is Movie)
{
mediaType = VideoContentType.Movie;
}
else
{
// These are the only supported types
return Task.FromResult(false);
}
return DownloadSubtitles(
video,
mediaStreams,
skipIfEmbeddedSubtitlesPresent,
skipIfAudioTrackMatches,
requirePerfectMatch,
lang,
disabledSubtitleFetchers,
subtitleFetcherOrder,
mediaType,
isAutomated,
cancellationToken);
}
private async Task<bool> DownloadSubtitles(
Video video,
IReadOnlyList<MediaStream> mediaStreams,
bool skipIfEmbeddedSubtitlesPresent,
bool skipIfAudioTrackMatches,
bool requirePerfectMatch,
string language,
string[] disabledSubtitleFetchers,
string[] subtitleFetcherOrder,
VideoContentType mediaType,
bool isAutomated,
CancellationToken cancellationToken)
{
// There's already subtitles for this language
if (mediaStreams.Any(i => i.Type == MediaStreamType.Subtitle && i.IsTextSubtitleStream && string.Equals(i.Language, language, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
var audioStreams = mediaStreams.Where(i => i.Type == MediaStreamType.Audio).ToList();
var defaultAudioStreams = audioStreams.Where(i => i.IsDefault).ToList();
// If none are marked as default, just take a guess
if (defaultAudioStreams.Count == 0)
{
defaultAudioStreams = audioStreams.Take(1).ToList();
}
// There's already a default audio stream for this language
if (skipIfAudioTrackMatches &&
defaultAudioStreams.Any(i => string.Equals(i.Language, language, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
// There's an internal subtitle stream for this language
if (skipIfEmbeddedSubtitlesPresent &&
mediaStreams.Any(i => i.Type == MediaStreamType.Subtitle && !i.IsExternal && string.Equals(i.Language, language, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
var request = new SubtitleSearchRequest
{
ContentType = mediaType,
IndexNumber = video.IndexNumber,
Language = language,
MediaPath = video.Path,
Name = video.Name,
ParentIndexNumber = video.ParentIndexNumber,
ProductionYear = video.ProductionYear,
ProviderIds = video.ProviderIds,
// Stop as soon as we find something
SearchAllProviders = false,
IsPerfectMatch = requirePerfectMatch,
DisabledSubtitleFetchers = disabledSubtitleFetchers,
SubtitleFetcherOrder = subtitleFetcherOrder,
IsAutomated = isAutomated
};
if (video is Episode episode)
{
request.IndexNumberEnd = episode.IndexNumberEnd;
request.SeriesName = episode.SeriesName;
}
try
{
var searchResults = await _subtitleManager.SearchSubtitles(request, cancellationToken).ConfigureAwait(false);
var result = searchResults.FirstOrDefault();
if (result is not null)
{
await _subtitleManager.DownloadSubtitles(video, result.Id, cancellationToken).ConfigureAwait(false);
return true;
}
}
catch (RateLimitExceededException)
{
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading subtitles");
}
return false;
}
}
}
@@ -0,0 +1,40 @@
using Emby.Naming.Common;
using MediaBrowser.Controller.Entities;
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 subtitle files for <see cref="Video"/>.
/// </summary>
public class SubtitleResolver : MediaInfoResolver
{
/// <summary>
/// Initializes a new instance of the <see cref="SubtitleResolver"/> 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 SubtitleResolver(
ILogger<SubtitleResolver> logger,
ILocalizationManager localizationManager,
IMediaEncoder mediaEncoder,
IFileSystem fileSystem,
NamingOptions namingOptions)
: base(
logger,
localizationManager,
mediaEncoder,
fileSystem,
namingOptions,
DlnaProfileType.Subtitle)
{
}
}
}
@@ -0,0 +1,223 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Providers;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.MediaInfo
{
public class SubtitleScheduledTask : IScheduledTask
{
private readonly ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _config;
private readonly ISubtitleManager _subtitleManager;
private readonly ILogger<SubtitleScheduledTask> _logger;
private readonly ILocalizationManager _localization;
public SubtitleScheduledTask(
ILibraryManager libraryManager,
IServerConfigurationManager config,
ISubtitleManager subtitleManager,
ILogger<SubtitleScheduledTask> logger,
ILocalizationManager localization)
{
_libraryManager = libraryManager;
_config = config;
_subtitleManager = subtitleManager;
_logger = logger;
_localization = localization;
}
public string Name => _localization.GetLocalizedString("TaskDownloadMissingSubtitles");
public string Description => _localization.GetLocalizedString("TaskDownloadMissingSubtitlesDescription");
public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
public string Key => "DownloadSubtitles";
public bool IsHidden => false;
public bool IsEnabled => true;
public bool IsLogged => true;
private SubtitleOptions GetOptions()
{
return _config.GetConfiguration<SubtitleOptions>("subtitles");
}
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var options = GetOptions();
var types = new[] { BaseItemKind.Episode, BaseItemKind.Movie };
var dict = new Dictionary<Guid, BaseItem>();
foreach (var library in _libraryManager.RootFolder.Children.ToList())
{
var libraryOptions = _libraryManager.GetLibraryOptions(library);
string[] subtitleDownloadLanguages;
bool skipIfEmbeddedSubtitlesPresent;
bool skipIfAudioTrackMatches;
if (libraryOptions.SubtitleDownloadLanguages is null)
{
subtitleDownloadLanguages = options.DownloadLanguages;
skipIfEmbeddedSubtitlesPresent = options.SkipIfEmbeddedSubtitlesPresent;
skipIfAudioTrackMatches = options.SkipIfAudioTrackMatches;
}
else
{
subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages;
skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent;
skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches;
}
foreach (var lang in subtitleDownloadLanguages)
{
var query = new InternalItemsQuery
{
MediaTypes = new[] { MediaType.Video },
IsVirtualItem = false,
IncludeItemTypes = types,
DtoOptions = new DtoOptions(true),
SourceTypes = new[] { SourceType.Library },
Parent = library,
Recursive = true
};
if (skipIfAudioTrackMatches)
{
query.HasNoAudioTrackWithLanguage = lang;
}
if (skipIfEmbeddedSubtitlesPresent)
{
// Exclude if it already has any subtitles of the same language
query.HasNoSubtitleTrackWithLanguage = lang;
}
else
{
// Exclude if it already has external subtitles of the same language
query.HasNoExternalSubtitleTrackWithLanguage = lang;
}
var videosByLanguage = _libraryManager.GetItemList(query);
foreach (var video in videosByLanguage)
{
dict[video.Id] = video;
}
}
}
var videos = dict.Values.ToList();
if (videos.Count == 0)
{
return;
}
var numComplete = 0;
foreach (var video in videos)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await DownloadSubtitles(video as Video, options, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading subtitles for {Path}", video.Path);
}
// Update progress
numComplete++;
double percent = numComplete;
percent /= videos.Count;
progress.Report(100 * percent);
}
}
private async Task<bool> DownloadSubtitles(Video video, SubtitleOptions options, CancellationToken cancellationToken)
{
var mediaStreams = video.GetMediaStreams();
var libraryOptions = _libraryManager.GetLibraryOptions(video);
string[] subtitleDownloadLanguages;
bool skipIfEmbeddedSubtitlesPresent;
bool skipIfAudioTrackMatches;
bool requirePerfectMatch;
if (libraryOptions.SubtitleDownloadLanguages is null)
{
subtitleDownloadLanguages = options.DownloadLanguages;
skipIfEmbeddedSubtitlesPresent = options.SkipIfEmbeddedSubtitlesPresent;
skipIfAudioTrackMatches = options.SkipIfAudioTrackMatches;
requirePerfectMatch = options.RequirePerfectMatch;
}
else
{
subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages;
skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent;
skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches;
requirePerfectMatch = libraryOptions.RequirePerfectSubtitleMatch;
}
var downloadedLanguages = await new SubtitleDownloader(
_logger,
_subtitleManager).DownloadSubtitles(
video,
mediaStreams,
skipIfEmbeddedSubtitlesPresent,
skipIfAudioTrackMatches,
requirePerfectMatch,
subtitleDownloadLanguages,
libraryOptions.DisabledSubtitleFetchers,
libraryOptions.SubtitleFetcherOrder,
true,
cancellationToken).ConfigureAwait(false);
// Rescan
if (downloadedLanguages.Count > 0)
{
await video.RefreshMetadata(cancellationToken).ConfigureAwait(false);
return false;
}
return true;
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
// Every so often
new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks }
};
}
}
}
@@ -0,0 +1,134 @@
#pragma warning disable CA1826 // CA1826 Do not use Enumerable methods on Indexable collections.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.MediaInfo
{
/// <summary>
/// Uses <see cref="IMediaEncoder"/> to create still images from the main video.
/// </summary>
public class VideoImageProvider : IDynamicImageProvider, IHasOrder
{
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IMediaEncoder _mediaEncoder;
private readonly ILogger<VideoImageProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="VideoImageProvider"/> class.
/// </summary>
/// <param name="mediaSourceManager">The media source manager for fetching item streams.</param>
/// <param name="mediaEncoder">The media encoder for capturing images.</param>
/// <param name="logger">The logger.</param>
public VideoImageProvider(IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, ILogger<VideoImageProvider> logger)
{
_mediaSourceManager = mediaSourceManager;
_mediaEncoder = mediaEncoder;
_logger = logger;
}
/// <inheritdoc />
public string Name => "Screen Grabber";
/// <inheritdoc />
// Make sure this comes after internet image providers
public int Order => 100;
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
return new[] { ImageType.Primary };
}
/// <inheritdoc />
public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
{
var video = (Video)item;
// No support for these
if (video.IsPlaceHolder || video.VideoType == VideoType.Dvd)
{
return Task.FromResult(new DynamicImageResponse { HasImage = false });
}
// Can't extract if we didn't find a video stream in the file
if (!video.DefaultVideoStreamIndex.HasValue)
{
_logger.LogInformation("Skipping image extraction due to missing DefaultVideoStreamIndex for {Path}.", video.Path ?? string.Empty);
return Task.FromResult(new DynamicImageResponse { HasImage = false });
}
return GetVideoImage(video, cancellationToken);
}
private async Task<DynamicImageResponse> GetVideoImage(Video item, CancellationToken cancellationToken)
{
MediaSourceInfo mediaSource = new MediaSourceInfo
{
VideoType = item.VideoType,
IsoType = item.IsoType,
Protocol = item.PathProtocol ?? MediaProtocol.File,
};
// If we know the duration, grab it from 10% into the video. Otherwise just 10 seconds in.
// Always use 10 seconds for dvd because our duration could be out of whack
var imageOffset = item.VideoType != VideoType.Dvd && item.RunTimeTicks > 0
? TimeSpan.FromTicks(item.RunTimeTicks.Value / 10)
: TimeSpan.FromSeconds(10);
var query = new MediaStreamQuery { ItemId = item.Id, Index = item.DefaultVideoStreamIndex };
var videoStream = _mediaSourceManager.GetMediaStreams(query).FirstOrDefault();
if (videoStream is null)
{
query.Type = MediaStreamType.Video;
query.Index = null;
videoStream = _mediaSourceManager.GetMediaStreams(query).FirstOrDefault();
}
if (videoStream is null)
{
_logger.LogInformation("Skipping image extraction: no video stream found for {Path}.", item.Path ?? string.Empty);
return new DynamicImageResponse { HasImage = false };
}
string extractedImagePath = await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, videoStream, item.Video3DFormat, imageOffset, cancellationToken).ConfigureAwait(false);
return new DynamicImageResponse
{
Format = ImageFormat.Jpg,
HasImage = true,
Path = extractedImagePath,
Protocol = MediaProtocol.File
};
}
/// <inheritdoc />
public bool Supports(BaseItem item)
{
if (item.IsShortcut)
{
return false;
}
if (!item.IsFileProtocol)
{
return false;
}
return item is Video video && !video.IsPlaceHolder && video.IsCompleteMedia;
}
}
}
@@ -0,0 +1,36 @@
#pragma warning disable CS1591
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Movies
{
public class ImdbExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "IMDb";
/// <inheritdoc />
public string Key => MetadataProvider.Imdb.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public bool Supports(IHasProviderIds item)
{
// Supports images for tv movies
if (item is LiveTvProgram tvProgram && tvProgram.IsMovie)
{
return true;
}
return item is Movie || item is MusicVideo || item is Series || item is Episode || item is Trailer;
}
}
}
@@ -0,0 +1,32 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Movies;
/// <summary>
/// External URLs for IMDb.
/// </summary>
public class ImdbExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "IMDb";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
var baseUrl = "https://www.imdb.com/";
if (item.TryGetProviderId(MetadataProvider.Imdb, out var externalId))
{
if (item is Person)
{
yield return baseUrl + $"name/{externalId}";
}
else
{
yield return baseUrl + $"title/{externalId}";
}
}
}
}
@@ -0,0 +1,24 @@
#pragma warning disable CS1591
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Movies
{
public class ImdbPersonExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "IMDb";
/// <inheritdoc />
public string Key => MetadataProvider.Imdb.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Person;
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Person;
}
}
@@ -0,0 +1,54 @@
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Movies;
/// <summary>
/// Service to manage movie metadata.
/// </summary>
public class MovieMetadataService : MetadataService<Movie, MovieInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="MovieMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public MovieMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<MovieMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
/// <inheritdoc />
protected override void MergeData(MetadataResult<Movie> source, MetadataResult<Movie> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
var sourceItem = source.Item;
var targetItem = target.Item;
if (replaceData || string.IsNullOrEmpty(targetItem.CollectionName))
{
targetItem.CollectionName = sourceItem.CollectionName;
}
}
}
@@ -0,0 +1,56 @@
using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Movies;
/// <summary>
/// Service to manage trailer metadata.
/// </summary>
public class TrailerMetadataService : MetadataService<Trailer, TrailerInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="TrailerMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public TrailerMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<TrailerMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
/// <inheritdoc />
protected override void MergeData(MetadataResult<Trailer> source, MetadataResult<Trailer> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
if (replaceData || target.Item.TrailerTypes.Length == 0)
{
target.Item.TrailerTypes = source.Item.TrailerTypes;
}
else
{
target.Item.TrailerTypes = target.Item.TrailerTypes.Concat(source.Item.TrailerTypes).Distinct().ToArray();
}
}
}
@@ -0,0 +1,81 @@
#pragma warning disable CS1591
using System.Linq;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Music
{
public static class AlbumInfoExtensions
{
public static string? GetAlbumArtist(this AlbumInfo info)
{
var id = info.SongInfos.SelectMany(i => i.AlbumArtists)
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
if (!string.IsNullOrEmpty(id))
{
return id;
}
return info.AlbumArtists.Count > 0 ? info.AlbumArtists[0] : default;
}
public static string? GetReleaseGroupId(this AlbumInfo info)
{
var id = info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup);
if (string.IsNullOrEmpty(id))
{
return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
return id;
}
public static string? GetReleaseId(this AlbumInfo info)
{
var id = info.GetProviderId(MetadataProvider.MusicBrainzAlbum);
if (string.IsNullOrEmpty(id))
{
return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbum))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
return id;
}
public static string? GetMusicBrainzArtistId(this AlbumInfo info)
{
info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzAlbumArtist.ToString(), out string? id);
if (string.IsNullOrEmpty(id))
{
info.ArtistProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out id);
}
if (string.IsNullOrEmpty(id))
{
return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
return id;
}
public static string? GetMusicBrainzArtistId(this ArtistInfo info)
{
info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out var id);
if (string.IsNullOrEmpty(id))
{
return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
return id;
}
}
}
@@ -0,0 +1,266 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Music;
/// <summary>
/// The album metadata service.
/// </summary>
public class AlbumMetadataService : MetadataService<MusicAlbum, AlbumInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="AlbumMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public AlbumMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<AlbumMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
/// <inheritdoc />
protected override bool EnableUpdatingPremiereDateFromChildren => true;
/// <inheritdoc />
protected override bool EnableUpdatingGenresFromChildren => true;
/// <inheritdoc />
protected override bool EnableUpdatingStudiosFromChildren => true;
/// <inheritdoc />
protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(MusicAlbum item)
=> item.GetRecursiveChildren(i => i is Audio);
/// <inheritdoc />
protected override Task AfterMetadataRefresh(MusicAlbum item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
{
base.AfterMetadataRefresh(item, refreshOptions, cancellationToken);
SetPeople(item);
return Task.CompletedTask;
}
/// <inheritdoc />
protected override ItemUpdateType UpdateMetadataFromChildren(MusicAlbum item, IReadOnlyList<BaseItem> children, bool isFullRefresh, ItemUpdateType currentUpdateType)
{
var updateType = base.UpdateMetadataFromChildren(item, children, isFullRefresh, currentUpdateType);
// don't update user-changeable metadata for locked items
if (item.IsLocked)
{
return updateType;
}
if (isFullRefresh || currentUpdateType > ItemUpdateType.None)
{
if (!item.LockedFields.Contains(MetadataField.Name))
{
var name = children.Select(i => i.Album).FirstOrDefault(i => !string.IsNullOrEmpty(i));
if (!string.IsNullOrEmpty(name)
&& !string.Equals(item.Name, name, StringComparison.Ordinal))
{
item.Name = name;
updateType |= ItemUpdateType.MetadataEdit;
}
}
var songs = children.Cast<Audio>().ToArray();
updateType |= SetArtistsFromSongs(item, songs);
updateType |= SetAlbumArtistFromSongs(item, songs);
updateType |= SetAlbumFromSongs(item, songs);
}
return updateType;
}
private ItemUpdateType SetAlbumArtistFromSongs(MusicAlbum item, IReadOnlyList<Audio> songs)
{
var updateType = ItemUpdateType.None;
var albumArtists = songs
.SelectMany(i => i.AlbumArtists)
.GroupBy(i => i, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.ToArray();
updateType |= SetProviderIdFromSongs(item, songs, MetadataProvider.MusicBrainzAlbumArtist);
if (!item.AlbumArtists.SequenceEqual(albumArtists, StringComparer.Ordinal))
{
item.AlbumArtists = albumArtists;
updateType |= ItemUpdateType.MetadataEdit;
}
return updateType;
}
private ItemUpdateType SetArtistsFromSongs(MusicAlbum item, IReadOnlyList<Audio> songs)
{
var updateType = ItemUpdateType.None;
var artists = songs
.SelectMany(i => i.Artists)
.GroupBy(i => i, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.ToArray();
if (!item.Artists.SequenceEqual(artists, StringComparer.Ordinal))
{
item.Artists = artists;
updateType |= ItemUpdateType.MetadataEdit;
}
return updateType;
}
private ItemUpdateType SetAlbumFromSongs(MusicAlbum item, IReadOnlyList<Audio> songs)
{
var updateType = ItemUpdateType.None;
updateType |= SetProviderIdFromSongs(item, songs, MetadataProvider.MusicBrainzAlbum);
updateType |= SetProviderIdFromSongs(item, songs, MetadataProvider.MusicBrainzReleaseGroup);
return updateType;
}
private ItemUpdateType SetProviderIdFromSongs(BaseItem item, IReadOnlyList<Audio> songs, MetadataProvider provider)
{
var ids = songs
.Select(i => i.GetProviderId(provider))
.GroupBy(i => i)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.ToArray();
var id = item.GetProviderId(provider);
if (ids.Length != 0)
{
var firstId = ids[0];
if (!string.IsNullOrEmpty(firstId)
&& (string.IsNullOrEmpty(id)
|| !id.Equals(firstId, StringComparison.OrdinalIgnoreCase)))
{
item.SetProviderId(provider, firstId);
return ItemUpdateType.MetadataEdit;
}
}
return ItemUpdateType.None;
}
private void SetProviderId(MusicAlbum sourceItem, MusicAlbum targetItem, MetadataProvider provider)
{
var source = sourceItem.GetProviderId(provider);
var target = targetItem.GetProviderId(provider);
if (!string.IsNullOrEmpty(source)
&& (string.IsNullOrEmpty(target)
|| !target.Equals(source, StringComparison.Ordinal)))
{
targetItem.SetProviderId(provider, source);
}
}
private void SetPeople(MusicAlbum item)
{
if (item.AlbumArtists.Any() || item.Artists.Any())
{
var people = new List<PersonInfo>();
foreach (var albumArtist in item.AlbumArtists)
{
if (!string.IsNullOrWhiteSpace(albumArtist))
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = albumArtist,
Type = PersonKind.AlbumArtist
});
}
}
foreach (var artist in item.Artists)
{
if (!string.IsNullOrWhiteSpace(artist))
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = artist,
Type = PersonKind.Artist
});
}
}
LibraryManager.UpdatePeople(item, people);
}
}
/// <inheritdoc />
protected override void MergeData(
MetadataResult<MusicAlbum> source,
MetadataResult<MusicAlbum> target,
MetadataField[] lockedFields,
bool replaceData,
bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
var sourceItem = source.Item;
var targetItem = target.Item;
if (replaceData || targetItem.Artists.Count == 0)
{
targetItem.Artists = sourceItem.Artists;
}
else
{
targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)))
{
SetProviderId(sourceItem, targetItem, MetadataProvider.MusicBrainzAlbumArtist);
}
if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzAlbum)))
{
SetProviderId(sourceItem, targetItem, MetadataProvider.MusicBrainzAlbum);
}
if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)))
{
SetProviderId(sourceItem, targetItem, MetadataProvider.MusicBrainzReleaseGroup);
}
}
}
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Music;
/// <summary>
/// Service to manage artist metadata.
/// </summary>
public class ArtistMetadataService : MetadataService<MusicArtist, ArtistInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="ArtistMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public ArtistMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<ArtistMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
/// <inheritdoc />
protected override bool EnableUpdatingGenresFromChildren => true;
/// <inheritdoc />
protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(MusicArtist item)
{
return item.IsAccessedByName
? item.GetTaggedItems(new InternalItemsQuery
{
Recursive = true,
IsFolder = false
})
: item.GetRecursiveChildren(i => i is IHasArtist && !i.IsFolder);
}
}
@@ -0,0 +1,84 @@
using System;
using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Music;
/// <summary>
/// The audio metadata service.
/// </summary>
public class AudioMetadataService : MetadataService<Audio, SongInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="AudioMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public AudioMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<AudioMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
private void SetProviderId(Audio sourceItem, Audio targetItem, bool replaceData, MetadataProvider provider)
{
var target = targetItem.GetProviderId(provider);
if (replaceData || string.IsNullOrEmpty(target))
{
var source = sourceItem.GetProviderId(provider);
if (!string.IsNullOrEmpty(source)
&& (string.IsNullOrEmpty(target)
|| !target.Equals(source, StringComparison.Ordinal)))
{
targetItem.SetProviderId(provider, source);
}
}
}
/// <inheritdoc />
protected override void MergeData(MetadataResult<Audio> source, MetadataResult<Audio> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
var sourceItem = source.Item;
var targetItem = target.Item;
if (replaceData || targetItem.Artists.Count == 0)
{
targetItem.Artists = sourceItem.Artists;
}
else
{
targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
if (replaceData || string.IsNullOrEmpty(targetItem.Album))
{
targetItem.Album = sourceItem.Album;
}
SetProviderId(sourceItem, targetItem, replaceData, MetadataProvider.MusicBrainzAlbumArtist);
SetProviderId(sourceItem, targetItem, replaceData, MetadataProvider.MusicBrainzAlbum);
SetProviderId(sourceItem, targetItem, replaceData, MetadataProvider.MusicBrainzReleaseGroup);
}
}
+25
View File
@@ -0,0 +1,25 @@
#pragma warning disable CS1591
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Music
{
public class ImvdbId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "IMVDb";
/// <inheritdoc />
public string Key => "IMVDb";
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public bool Supports(IHasProviderIds item)
=> item is MusicVideo;
}
}
@@ -0,0 +1,70 @@
using System;
using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Music;
/// <summary>
/// Service to manage music video metadata.
/// </summary>
public class MusicVideoMetadataService : MetadataService<MusicVideo, MusicVideoInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="MusicVideoMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public MusicVideoMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<MusicVideoMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
/// <inheritdoc />
protected override void MergeData(
MetadataResult<MusicVideo> source,
MetadataResult<MusicVideo> target,
MetadataField[] lockedFields,
bool replaceData,
bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
var sourceItem = source.Item;
var targetItem = target.Item;
if (replaceData || string.IsNullOrEmpty(targetItem.Album))
{
targetItem.Album = sourceItem.Album;
}
if (replaceData || targetItem.Artists.Count == 0)
{
targetItem.Artists = sourceItem.Artists;
}
else
{
targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
}
}
@@ -0,0 +1,39 @@
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.MusicGenres;
/// <summary>
/// Service to manage music genre metadata.
/// </summary>
public class MusicGenreMetadataService : MetadataService<MusicGenre, ItemLookupInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="MusicGenreMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public MusicGenreMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<MusicGenreMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
}
@@ -0,0 +1,39 @@
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.People;
/// <summary>
/// Service to manage person metadata.
/// </summary>
public class PersonMetadataService : MetadataService<Person, PersonLookupInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="PersonMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public PersonMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<PersonMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
}
@@ -0,0 +1,39 @@
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Photos;
/// <summary>
/// Service to manage photo album metadata.
/// </summary>
public class PhotoAlbumMetadataService : MetadataService<PhotoAlbum, ItemLookupInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="PhotoAlbumMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public PhotoAlbumMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<PhotoAlbumMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
}
@@ -0,0 +1,39 @@
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Photos;
/// <summary>
/// Service to manage photo metadata.
/// </summary>
public class PhotoMetadataService : MetadataService<Photo, ItemLookupInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="PhotoMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public PhotoMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<PhotoMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
}
@@ -0,0 +1,227 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
using PlaylistsNET.Content;
namespace MediaBrowser.Providers.Playlists;
/// <summary>
/// Local playlist provider.
/// </summary>
public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
IHasOrder,
IForcedProvider,
IHasItemChangeMonitor
{
private readonly IFileSystem _fileSystem;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<PlaylistItemsProvider> _logger;
private readonly CollectionType[] _ignoredCollections = [CollectionType.livetv, CollectionType.boxsets, CollectionType.playlists];
/// <summary>
/// Initializes a new instance of the <see cref="PlaylistItemsProvider"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{PlaylistItemsProvider}"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
public PlaylistItemsProvider(ILogger<PlaylistItemsProvider> logger, ILibraryManager libraryManager, IFileSystem fileSystem)
{
_logger = logger;
_libraryManager = libraryManager;
_fileSystem = fileSystem;
}
/// <inheritdoc />
public string Name => "Playlist Item Provider";
/// <inheritdoc />
public int Order => 100;
/// <inheritdoc />
public Task<MetadataResult<Playlist>> GetMetadata(
ItemInfo info,
IDirectoryService directoryService,
CancellationToken cancellationToken)
{
var result = new MetadataResult<Playlist>()
{
Item = new Playlist
{
Path = info.Path
}
};
Fetch(result);
return Task.FromResult(result);
}
private void Fetch(MetadataResult<Playlist> result)
{
var item = result.Item;
var path = item.Path;
if (!Playlist.IsPlaylistFile(path))
{
return;
}
var extension = Path.GetExtension(path);
if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase))
{
return;
}
var items = GetItems(path, extension).ToArray();
if (items.Length > 0)
{
result.HasMetadata = true;
item.LinkedChildren = items;
}
return;
}
private IEnumerable<LinkedChild> GetItems(string path, string extension)
{
var libraryRoots = _libraryManager.GetUserRootFolder().Children
.OfType<CollectionFolder>()
.Where(f => f.CollectionType.HasValue && !_ignoredCollections.Contains(f.CollectionType.Value))
.SelectMany(f => f.PhysicalLocations)
.Distinct()
.ToList();
using (var stream = File.OpenRead(path))
{
if (string.Equals(".wpl", extension, StringComparison.OrdinalIgnoreCase))
{
return GetWplItems(stream, path, libraryRoots);
}
if (string.Equals(".zpl", extension, StringComparison.OrdinalIgnoreCase))
{
return GetZplItems(stream, path, libraryRoots);
}
if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase))
{
return GetM3uItems(stream, path, libraryRoots);
}
if (string.Equals(".m3u8", extension, StringComparison.OrdinalIgnoreCase))
{
return GetM3uItems(stream, path, libraryRoots);
}
if (string.Equals(".pls", extension, StringComparison.OrdinalIgnoreCase))
{
return GetPlsItems(stream, path, libraryRoots);
}
}
return Enumerable.Empty<LinkedChild>();
}
private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string playlistPath, List<string> libraryRoots)
{
var content = new PlsContent();
var playlist = content.GetFromStream(stream);
return playlist.PlaylistEntries
.Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
.Where(i => i is not null);
}
private IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots)
{
var content = new M3uContent();
var playlist = content.GetFromStream(stream);
return playlist.PlaylistEntries
.Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
.Where(i => i is not null);
}
private IEnumerable<LinkedChild> GetZplItems(Stream stream, string playlistPath, List<string> libraryRoots)
{
var content = new ZplContent();
var playlist = content.GetFromStream(stream);
return playlist.PlaylistEntries
.Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
.Where(i => i is not null);
}
private IEnumerable<LinkedChild> GetWplItems(Stream stream, string playlistPath, List<string> libraryRoots)
{
var content = new WplContent();
var playlist = content.GetFromStream(stream);
return playlist.PlaylistEntries
.Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
.Where(i => i is not null);
}
private LinkedChild GetLinkedChild(string itemPath, string playlistPath, List<string> libraryRoots)
{
if (TryGetPlaylistItemPath(itemPath, playlistPath, libraryRoots, out var parsedPath))
{
return new LinkedChild
{
Path = parsedPath,
Type = LinkedChildType.Manual
};
}
return null;
}
private bool TryGetPlaylistItemPath(string itemPath, string playlistPath, List<string> libraryPaths, out string path)
{
path = null;
string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath);
if (!File.Exists(pathToCheck))
{
return false;
}
foreach (var libraryPath in libraryPaths)
{
if (pathToCheck.StartsWith(libraryPath, StringComparison.OrdinalIgnoreCase))
{
path = pathToCheck;
return true;
}
}
return false;
}
/// <inheritdoc />
public bool HasChanged(BaseItem item, IDirectoryService directoryService)
{
var path = item.Path;
if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol)
{
var file = directoryService.GetFile(path);
if (file is not null && item.HasChanged(file.LastWriteTimeUtc))
{
_logger.LogDebug("Refreshing {Path} due to date modified timestamp change.", path);
return true;
}
}
return false;
}
}
@@ -0,0 +1,88 @@
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Playlists;
/// <summary>
/// Service to manage playlist metadata.
/// </summary>
public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="PlaylistMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> 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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
public PlaylistMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<PlaylistMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager,
IExternalDataManager externalDataManager,
IItemRepository itemRepository)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
}
/// <inheritdoc />
protected override bool EnableUpdatingGenresFromChildren => true;
/// <inheritdoc />
protected override bool EnableUpdatingOfficialRatingFromChildren => true;
/// <inheritdoc />
protected override bool EnableUpdatingStudiosFromChildren => true;
/// <inheritdoc />
protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(Playlist item)
=> item.GetLinkedChildren();
/// <inheritdoc />
protected override void MergeData(MetadataResult<Playlist> source, MetadataResult<Playlist> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
var sourceItem = source.Item;
var targetItem = target.Item;
if (mergeMetadataSettings)
{
targetItem.PlaylistMediaType = sourceItem.PlaylistMediaType;
if (replaceData || targetItem.LinkedChildren.Length == 0)
{
targetItem.LinkedChildren = sourceItem.LinkedChildren;
}
else
{
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray();
}
if (replaceData || targetItem.Shares.Count == 0)
{
targetItem.Shares = sourceItem.Shares;
}
else
{
targetItem.Shares = sourceItem.Shares.Concat(targetItem.Shares).DistinctBy(s => s.UserId).ToArray();
}
}
}
}
@@ -0,0 +1,24 @@
#pragma warning disable CS1591
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class AudioDbAlbumExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "TheAudioDb";
/// <inheritdoc />
public string Key => MetadataProvider.AudioDbAlbum.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is MusicAlbum;
}
}
@@ -0,0 +1,31 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.AudioDb;
/// <summary>
/// External artist URLs for AudioDb.
/// </summary>
public class AudioDbAlbumExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "TheAudioDb Album";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item.TryGetProviderId(MetadataProvider.AudioDbAlbum, out var externalId))
{
var baseUrl = "https://www.theaudiodb.com/";
switch (item)
{
case MusicAlbum:
yield return baseUrl + $"album/{externalId}";
break;
}
}
}
}
@@ -0,0 +1,111 @@
#nullable disable
#pragma warning disable CS1591
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class AudioDbAlbumImageProvider : IRemoteImageProvider, IHasOrder
{
private readonly IServerConfigurationManager _config;
private readonly IHttpClientFactory _httpClientFactory;
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
public AudioDbAlbumImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory)
{
_config = config;
_httpClientFactory = httpClientFactory;
}
/// <inheritdoc />
public string Name => "TheAudioDB";
/// <inheritdoc />
// After embedded and fanart
public int Order => 2;
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
yield return ImageType.Primary;
yield return ImageType.Disc;
}
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out var id))
{
await AudioDbAlbumProvider.Current.EnsureInfo(id, cancellationToken).ConfigureAwait(false);
var path = AudioDbAlbumProvider.GetAlbumInfoPath(_config.ApplicationPaths, id);
FileStream jsonStream = AsyncFile.OpenRead(path);
await using (jsonStream.ConfigureAwait(false))
{
var obj = await JsonSerializer.DeserializeAsync<AudioDbAlbumProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (obj is not null && obj.album is not null && obj.album.Count > 0)
{
return GetImages(obj.album[0]);
}
}
}
return [];
}
private List<RemoteImageInfo> GetImages(AudioDbAlbumProvider.Album item)
{
var list = new List<RemoteImageInfo>();
if (!string.IsNullOrWhiteSpace(item.strAlbumThumb))
{
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Url = item.strAlbumThumb,
Type = ImageType.Primary
});
}
if (!string.IsNullOrWhiteSpace(item.strAlbumCDart))
{
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Url = item.strAlbumCDart,
Type = ImageType.Disc
});
}
return list;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
return httpClient.GetAsync(url, cancellationToken);
}
/// <inheritdoc />
public bool Supports(BaseItem item)
=> item is MusicAlbum;
}
}
@@ -0,0 +1,303 @@
#nullable disable
#pragma warning disable CA1002, CS1591, SA1300
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
using MediaBrowser.Providers.Music;
namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class AudioDbAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder
{
private readonly IServerConfigurationManager _config;
private readonly IFileSystem _fileSystem;
private readonly IHttpClientFactory _httpClientFactory;
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
#pragma warning disable SA1401, CA2211
public static AudioDbAlbumProvider Current;
#pragma warning restore SA1401, CA2211
public AudioDbAlbumProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClientFactory httpClientFactory)
{
_config = config;
_fileSystem = fileSystem;
_httpClientFactory = httpClientFactory;
Current = this;
}
/// <inheritdoc />
public string Name => "TheAudioDB";
/// <inheritdoc />
// After music brainz
public int Order => 1;
/// <inheritdoc />
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken)
=> Task.FromResult(Enumerable.Empty<RemoteSearchResult>());
/// <inheritdoc />
public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<MusicAlbum>();
var id = info.GetReleaseGroupId();
if (!string.IsNullOrWhiteSpace(id))
{
await EnsureInfo(id, cancellationToken).ConfigureAwait(false);
var path = GetAlbumInfoPath(_config.ApplicationPaths, id);
FileStream jsonStream = AsyncFile.OpenRead(path);
await using (jsonStream.ConfigureAwait(false))
{
var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (obj is not null && obj.album is not null && obj.album.Count > 0)
{
result.Item = new MusicAlbum();
result.HasMetadata = true;
ProcessResult(result.Item, obj.album[0], info.MetadataLanguage);
}
}
}
return result;
}
private void ProcessResult(MusicAlbum item, Album result, string preferredLanguage)
{
if (Plugin.Instance.Configuration.ReplaceAlbumName && !string.IsNullOrWhiteSpace(result.strAlbum))
{
item.Album = result.strAlbum;
}
if (!string.IsNullOrWhiteSpace(result.strArtist))
{
item.AlbumArtists = [result.strArtist];
}
if (!string.IsNullOrEmpty(result.intYearReleased))
{
item.ProductionYear = int.Parse(result.intYearReleased, CultureInfo.InvariantCulture);
}
if (!string.IsNullOrEmpty(result.strGenre))
{
item.Genres = [result.strGenre];
}
item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist);
item.SetProviderId(MetadataProvider.AudioDbAlbum, result.idAlbum);
item.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, result.strMusicBrainzArtistID);
item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, result.strMusicBrainzID);
string overview = null;
if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase))
{
overview = result.strDescriptionDE;
}
else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase))
{
overview = result.strDescriptionFR;
}
else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase))
{
overview = result.strDescriptionNL;
}
else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase))
{
overview = result.strDescriptionRU;
}
else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase))
{
overview = result.strDescriptionIT;
}
else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase))
{
overview = result.strDescriptionPT;
}
if (string.IsNullOrWhiteSpace(overview))
{
overview = result.strDescriptionEN;
}
item.Overview = (overview ?? string.Empty).StripHtml();
}
internal async Task EnsureInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken)
{
var xmlPath = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId);
var fileInfo = _fileSystem.GetFileSystemInfo(xmlPath);
if (fileInfo.Exists
&& (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2)
{
return;
}
await DownloadInfo(musicBrainzReleaseGroupId, cancellationToken).ConfigureAwait(false);
}
internal async Task DownloadInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var url = AudioDbArtistProvider.BaseUrl + "/album-mb.php?i=" + musicBrainzReleaseGroupId;
var path = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId);
var fileInfo = _fileSystem.GetFileSystemInfo(path);
if (fileInfo.Exists && (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2)
{
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(path));
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false);
var fileStreamOptions = AsyncFile.WriteOptions;
fileStreamOptions.Mode = FileMode.Create;
var fs = new FileStream(path, fileStreamOptions);
await using (fs.ConfigureAwait(false))
{
await response.Content.CopyToAsync(fs, cancellationToken).ConfigureAwait(false);
}
}
private static string GetAlbumDataPath(IApplicationPaths appPaths, string musicBrainzReleaseGroupId)
{
var dataPath = Path.Combine(GetAlbumDataPath(appPaths), musicBrainzReleaseGroupId);
return dataPath;
}
private static string GetAlbumDataPath(IApplicationPaths appPaths)
{
var dataPath = Path.Combine(appPaths.CachePath, "audiodb-album");
return dataPath;
}
internal static string GetAlbumInfoPath(IApplicationPaths appPaths, string musicBrainzReleaseGroupId)
{
var dataPath = GetAlbumDataPath(appPaths, musicBrainzReleaseGroupId);
return Path.Combine(dataPath, "album.json");
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
#pragma warning disable CA1034, CA2227
public class Album
{
public string idAlbum { get; set; }
public string idArtist { get; set; }
public string strAlbum { get; set; }
public string strArtist { get; set; }
public string intYearReleased { get; set; }
public string strGenre { get; set; }
public string strSubGenre { get; set; }
public string strReleaseFormat { get; set; }
public string intSales { get; set; }
public string strAlbumThumb { get; set; }
public string strAlbumCDart { get; set; }
public string strDescriptionEN { get; set; }
public string strDescriptionDE { get; set; }
public string strDescriptionFR { get; set; }
public string strDescriptionCN { get; set; }
public string strDescriptionIT { get; set; }
public string strDescriptionJP { get; set; }
public string strDescriptionRU { get; set; }
public string strDescriptionES { get; set; }
public string strDescriptionPT { get; set; }
public string strDescriptionSE { get; set; }
public string strDescriptionNL { get; set; }
public string strDescriptionHU { get; set; }
public string strDescriptionNO { get; set; }
public string strDescriptionIL { get; set; }
public string strDescriptionPL { get; set; }
public object intLoved { get; set; }
public object intScore { get; set; }
public string strReview { get; set; }
public object strMood { get; set; }
public object strTheme { get; set; }
public object strSpeed { get; set; }
public object strLocation { get; set; }
public string strMusicBrainzID { get; set; }
public string strMusicBrainzArtistID { get; set; }
public object strItunesID { get; set; }
public object strAmazonID { get; set; }
public string strLocked { get; set; }
}
public class RootObject
{
public List<Album> album { get; set; }
}
}
}
@@ -0,0 +1,24 @@
#pragma warning disable CS1591
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class AudioDbArtistExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "TheAudioDb";
/// <inheritdoc />
public string Key => MetadataProvider.AudioDbArtist.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Artist;
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is MusicArtist;
}
}
@@ -0,0 +1,32 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.AudioDb;
/// <summary>
/// External artist URLs for AudioDb.
/// </summary>
public class AudioDbArtistExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "TheAudioDb Artist";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item.TryGetProviderId(MetadataProvider.AudioDbArtist, out var externalId))
{
var baseUrl = "https://www.theaudiodb.com/";
switch (item)
{
case MusicArtist:
case Person:
yield return baseUrl + $"artist/{externalId}";
break;
}
}
}
}
@@ -0,0 +1,155 @@
#nullable disable
#pragma warning disable CS1591
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class AudioDbArtistImageProvider : IRemoteImageProvider, IHasOrder
{
private readonly IServerConfigurationManager _config;
private readonly IHttpClientFactory _httpClientFactory;
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
public AudioDbArtistImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory)
{
_config = config;
_httpClientFactory = httpClientFactory;
}
/// <inheritdoc />
public string Name => "TheAudioDB";
/// <inheritdoc />
// After fanart
public int Order => 1;
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
return
[
ImageType.Primary,
ImageType.Logo,
ImageType.Banner,
ImageType.Backdrop
];
}
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var id))
{
await AudioDbArtistProvider.Current.EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false);
var path = AudioDbArtistProvider.GetArtistInfoPath(_config.ApplicationPaths, id);
FileStream jsonStream = AsyncFile.OpenRead(path);
await using (jsonStream.ConfigureAwait(false))
{
var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (obj is not null && obj.artists is not null && obj.artists.Count > 0)
{
return GetImages(obj.artists[0]);
}
}
}
return [];
}
private List<RemoteImageInfo> GetImages(AudioDbArtistProvider.Artist item)
{
var list = new List<RemoteImageInfo>();
if (!string.IsNullOrWhiteSpace(item.strArtistThumb))
{
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Url = item.strArtistThumb,
Type = ImageType.Primary
});
}
if (!string.IsNullOrWhiteSpace(item.strArtistLogo))
{
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Url = item.strArtistLogo,
Type = ImageType.Logo
});
}
if (!string.IsNullOrWhiteSpace(item.strArtistBanner))
{
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Url = item.strArtistBanner,
Type = ImageType.Banner
});
}
if (!string.IsNullOrWhiteSpace(item.strArtistFanart))
{
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Url = item.strArtistFanart,
Type = ImageType.Backdrop
});
}
if (!string.IsNullOrWhiteSpace(item.strArtistFanart2))
{
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Url = item.strArtistFanart2,
Type = ImageType.Backdrop
});
}
if (!string.IsNullOrWhiteSpace(item.strArtistFanart3))
{
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Url = item.strArtistFanart3,
Type = ImageType.Backdrop
});
}
return list;
}
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
return httpClient.GetAsync(url, cancellationToken);
}
/// <inheritdoc />
public bool Supports(BaseItem item)
=> item is MusicArtist;
}
}
@@ -0,0 +1,290 @@
#nullable disable
#pragma warning disable CA1034, CS1591, CA1002, SA1028, SA1300
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
using MediaBrowser.Providers.Music;
namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class AudioDbArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IHasOrder
{
private const string ApiKey = "195003";
public const string BaseUrl = "https://www.theaudiodb.com/api/v1/json/" + ApiKey;
private readonly IServerConfigurationManager _config;
private readonly IFileSystem _fileSystem;
private readonly IHttpClientFactory _httpClientFactory;
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
public AudioDbArtistProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClientFactory httpClientFactory)
{
_config = config;
_fileSystem = fileSystem;
_httpClientFactory = httpClientFactory;
Current = this;
}
public static AudioDbArtistProvider Current { get; private set; }
/// <inheritdoc />
public string Name => "TheAudioDB";
/// <inheritdoc />
// After musicbrainz
public int Order => 1;
/// <inheritdoc />
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken)
=> Task.FromResult(Enumerable.Empty<RemoteSearchResult>());
/// <inheritdoc />
public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<MusicArtist>();
var id = info.GetMusicBrainzArtistId();
if (!string.IsNullOrWhiteSpace(id))
{
await EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false);
var path = GetArtistInfoPath(_config.ApplicationPaths, id);
FileStream jsonStream = AsyncFile.OpenRead(path);
await using (jsonStream.ConfigureAwait(false))
{
var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (obj is not null && obj.artists is not null && obj.artists.Count > 0)
{
result.Item = new MusicArtist();
result.HasMetadata = true;
ProcessResult(result.Item, obj.artists[0], info.MetadataLanguage);
}
}
}
return result;
}
private void ProcessResult(MusicArtist item, Artist result, string preferredLanguage)
{
// item.HomePageUrl = result.strWebsite;
if (!string.IsNullOrEmpty(result.strGenre))
{
item.Genres = new[] { result.strGenre };
}
item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist);
item.SetProviderId(MetadataProvider.MusicBrainzArtist, result.strMusicBrainzID);
string overview = null;
if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase))
{
overview = result.strBiographyDE;
}
else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase))
{
overview = result.strBiographyFR;
}
else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase))
{
overview = result.strBiographyNL;
}
else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase))
{
overview = result.strBiographyRU;
}
else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase))
{
overview = result.strBiographyIT;
}
else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase))
{
overview = result.strBiographyPT;
}
if (string.IsNullOrWhiteSpace(overview))
{
overview = result.strBiographyEN;
}
item.Overview = (overview ?? string.Empty).StripHtml();
}
internal async Task EnsureArtistInfo(string musicBrainzId, CancellationToken cancellationToken)
{
var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
var fileInfo = _fileSystem.GetFileSystemInfo(xmlPath);
if (fileInfo.Exists
&& (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2)
{
return;
}
await DownloadArtistInfo(musicBrainzId, cancellationToken).ConfigureAwait(false);
}
internal async Task DownloadArtistInfo(string musicBrainzId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var url = BaseUrl + "/artist-mb.php?i=" + musicBrainzId;
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
Directory.CreateDirectory(Path.GetDirectoryName(path));
var fileStreamOptions = AsyncFile.WriteOptions;
fileStreamOptions.Mode = FileMode.Create;
var xmlFileStream = new FileStream(path, fileStreamOptions);
await using (xmlFileStream.ConfigureAwait(false))
{
await response.Content.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Gets the artist data path.
/// </summary>
/// <param name="appPaths">The application paths.</param>
/// <param name="musicBrainzArtistId">The music brainz artist identifier.</param>
/// <returns>System.String.</returns>
private static string GetArtistDataPath(IApplicationPaths appPaths, string musicBrainzArtistId)
=> Path.Combine(GetArtistDataPath(appPaths), musicBrainzArtistId);
/// <summary>
/// Gets the artist data path.
/// </summary>
/// <param name="appPaths">The application paths.</param>
/// <returns>System.String.</returns>
private static string GetArtistDataPath(IApplicationPaths appPaths)
=> Path.Combine(appPaths.CachePath, "audiodb-artist");
internal static string GetArtistInfoPath(IApplicationPaths appPaths, string musicBrainzArtistId)
{
var dataPath = GetArtistDataPath(appPaths, musicBrainzArtistId);
return Path.Combine(dataPath, "artist.json");
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public class Artist
{
public string idArtist { get; set; }
public string strArtist { get; set; }
public string strArtistAlternate { get; set; }
public object idLabel { get; set; }
public string intFormedYear { get; set; }
public string intBornYear { get; set; }
public object intDiedYear { get; set; }
public object strDisbanded { get; set; }
public string strGenre { get; set; }
public string strSubGenre { get; set; }
public string strWebsite { get; set; }
public string strFacebook { get; set; }
public string strTwitter { get; set; }
public string strBiographyEN { get; set; }
public string strBiographyDE { get; set; }
public string strBiographyFR { get; set; }
public string strBiographyCN { get; set; }
public string strBiographyIT { get; set; }
public string strBiographyJP { get; set; }
public string strBiographyRU { get; set; }
public string strBiographyES { get; set; }
public string strBiographyPT { get; set; }
public string strBiographySE { get; set; }
public string strBiographyNL { get; set; }
public string strBiographyHU { get; set; }
public string strBiographyNO { get; set; }
public string strBiographyIL { get; set; }
public string strBiographyPL { get; set; }
public string strGender { get; set; }
public string intMembers { get; set; }
public string strCountry { get; set; }
public string strCountryCode { get; set; }
public string strArtistThumb { get; set; }
public string strArtistLogo { get; set; }
public string strArtistFanart { get; set; }
public string strArtistFanart2 { get; set; }
public string strArtistFanart3 { get; set; }
public string strArtistBanner { get; set; }
public string strMusicBrainzID { get; set; }
public object strLastFMChart { get; set; }
public string strLocked { get; set; }
}
#pragma warning disable CA2227
public class RootObject
{
public List<Artist> artists { get; set; }
}
}
}
@@ -0,0 +1,24 @@
#pragma warning disable CS1591
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class AudioDbOtherAlbumExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "TheAudioDb";
/// <inheritdoc />
public string Key => MetadataProvider.AudioDbAlbum.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Album;
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio;
}
}
@@ -0,0 +1,24 @@
#pragma warning disable CS1591
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class AudioDbOtherArtistExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "TheAudioDb";
/// <inheritdoc />
public string Key => MetadataProvider.AudioDbArtist.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist;
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum;
}
}
@@ -0,0 +1,11 @@
#pragma warning disable CS1591
using MediaBrowser.Model.Plugins;
namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class PluginConfiguration : BasePluginConfiguration
{
public bool ReplaceAlbumName { get; set; }
}
}
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html>
<head>
<title>TheAudioDB</title>
</head>
<body>
<div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<h1>TheAudioDB</h1>
<form class="configForm">
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="replaceAlbumName" />
<span>When an album is found during a metadata search, replace the name with the value on the server.</span>
</label>
<br />
<div>
<button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var PluginConfig = {
pluginId: "a629c0da-fac5-4c7e-931a-7174223f14c8"
};
document.querySelector('.configPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) {
document.querySelector('#replaceAlbumName').checked = config.ReplaceAlbumName;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('.configForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) {
config.ReplaceAlbumName = document.querySelector('#replaceAlbumName').checked;
ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>
@@ -0,0 +1,41 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
public static Plugin Instance { get; private set; }
public override Guid Id => new Guid("a629c0da-fac5-4c7e-931a-7174223f14c8");
public override string Name => "AudioDB";
public override string Description => "Get artist and album metadata or images from AudioDB.";
// TODO remove when plugin removed from server.
public override string ConfigurationFileName => "Jellyfin.Plugin.AudioDb.xml";
public IEnumerable<PluginPageInfo> GetPages()
{
yield return new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html"
};
}
}
}
@@ -0,0 +1,57 @@
using MediaBrowser.Model.Plugins;
namespace MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
/// <summary>
/// MusicBrainz plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// The default server URL.
/// </summary>
public const string DefaultServer = "https://musicbrainz.org";
/// <summary>
/// The default rate limit.
/// </summary>
public const double DefaultRateLimit = 1.0;
private string _server = DefaultServer;
private double _rateLimit = DefaultRateLimit;
/// <summary>
/// Gets or sets the server URL.
/// </summary>
public string Server
{
get => _server;
set => _server = value.TrimEnd('/');
}
/// <summary>
/// Gets or sets the rate limit.
/// </summary>
public double RateLimit
{
get => _rateLimit;
set
{
if (value < DefaultRateLimit && _server == DefaultServer)
{
_rateLimit = DefaultRateLimit;
}
else
{
_rateLimit = value;
}
}
}
/// <summary>
/// Gets or sets a value indicating whether to replace the artist name.
/// </summary>
public bool ReplaceArtistName { get; set; }
}
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html>
<head>
<title>MusicBrainz</title>
</head>
<body>
<div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<h1>MusicBrainz</h1>
<form class="configForm">
<div class="inputContainer">
<input is="emby-input" type="text" id="server" required label="Server" />
<div class="fieldDescription">This can be a mirror of the official server or even a custom server.</div>
</div>
<div class="inputContainer">
<input is="emby-input" type="number" id="rateLimit" required pattern="[0-9]*" min="0" max="10" step=".01" label="Rate Limit" />
<div class="fieldDescription">Span of time between requests in seconds. The official server is limited to one request every seconds.</div>
</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="replaceArtistName" />
<span>When an artist is found during a metadata search, replace the artist name with the value on the server.</span>
</label>
<br />
<div>
<button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var MusicBrainzPluginConfig = {
uniquePluginId: "8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a"
};
document.querySelector('.configPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MusicBrainzPluginConfig.uniquePluginId).then(function (config) {
var server = document.querySelector('#server');
server.value = config.Server;
server.dispatchEvent(new Event('change', {
bubbles: true,
cancelable: false
}));
var rateLimit = document.querySelector('#rateLimit');
rateLimit.value = config.RateLimit;
rateLimit.dispatchEvent(new Event('change', {
bubbles: true,
cancelable: false
}));
document.querySelector('#replaceArtistName').checked = config.ReplaceArtistName;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('.configForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MusicBrainzPluginConfig.uniquePluginId).then(function (config) {
config.Server = document.querySelector('#server').value;
config.RateLimit = document.querySelector('#rateLimit').value;
config.ReplaceArtistName = document.querySelector('#replaceArtistName').checked;
ApiClient.updatePluginConfiguration(MusicBrainzPluginConfig.uniquePluginId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>
@@ -0,0 +1,24 @@
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// MusicBrainz album artist external id.
/// </summary>
public class MusicBrainzAlbumArtistExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "MusicBrainz";
/// <inheritdoc />
public string Key => MetadataProvider.MusicBrainzAlbumArtist.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist;
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio;
}
@@ -0,0 +1,28 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// External album artist URLs for MusicBrainz.
/// </summary>
public class MusicBrainzAlbumArtistExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "MusicBrainz Album Artist";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item is MusicAlbum)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out var externalId))
{
yield return Plugin.Instance!.Configuration.Server + $"/artist/{externalId}";
}
}
}
}
@@ -0,0 +1,24 @@
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// MusicBrainz album external id.
/// </summary>
public class MusicBrainzAlbumExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "MusicBrainz";
/// <inheritdoc />
public string Key => MetadataProvider.MusicBrainzAlbum.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Album;
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum;
}
@@ -0,0 +1,28 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// External album URLs for MusicBrainz.
/// </summary>
public class MusicBrainzAlbumExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "MusicBrainz Album";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item is MusicAlbum)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out var externalId))
{
yield return Plugin.Instance!.Configuration.Server + $"/release/{externalId}";
}
}
}
}
@@ -0,0 +1,307 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Providers;
using MediaBrowser.Providers.Music;
using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
using MetaBrainz.MusicBrainz;
using MetaBrainz.MusicBrainz.Interfaces.Entities;
using MetaBrainz.MusicBrainz.Interfaces.Searches;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// Music album metadata provider for MusicBrainz.
/// </summary>
public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder, IDisposable
{
private readonly ILogger<MusicBrainzAlbumProvider> _logger;
private Query _musicBrainzQuery;
/// <summary>
/// Initializes a new instance of the <see cref="MusicBrainzAlbumProvider"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
public MusicBrainzAlbumProvider(ILogger<MusicBrainzAlbumProvider> logger)
{
_logger = logger;
_musicBrainzQuery = new Query();
ReloadConfig(null, MusicBrainz.Plugin.Instance!.Configuration);
MusicBrainz.Plugin.Instance!.ConfigurationChanged += ReloadConfig;
}
/// <inheritdoc />
public string Name => "MusicBrainz";
/// <inheritdoc />
public int Order => 0;
private void ReloadConfig(object? sender, BasePluginConfiguration e)
{
var configuration = (PluginConfiguration)e;
if (Uri.TryCreate(configuration.Server, UriKind.Absolute, out var server))
{
Query.DefaultServer = server.DnsSafeHost;
Query.DefaultPort = server.Port;
Query.DefaultUrlScheme = server.Scheme;
}
else
{
// Fallback to official server
_logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server");
var defaultServer = new Uri(PluginConfiguration.DefaultServer);
Query.DefaultServer = defaultServer.Host;
Query.DefaultPort = defaultServer.Port;
Query.DefaultUrlScheme = defaultServer.Scheme;
}
Query.DelayBetweenRequests = configuration.RateLimit;
_musicBrainzQuery = new Query();
}
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken)
{
var releaseId = searchInfo.GetReleaseId();
var releaseGroupId = searchInfo.GetReleaseGroupId();
if (!string.IsNullOrEmpty(releaseId))
{
var releaseResult = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
return GetReleaseResult(releaseResult).SingleItemAsEnumerable();
}
if (!string.IsNullOrEmpty(releaseGroupId))
{
var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false);
// No need to pass the cancellation token to GetReleaseGroupResultAsync as we're already passing it to ToBlockingEnumerable
return GetReleaseGroupResultAsync(releaseGroupResult.Releases, CancellationToken.None).ToBlockingEnumerable(cancellationToken);
}
var artistMusicBrainzId = searchInfo.GetMusicBrainzArtistId();
if (!string.IsNullOrWhiteSpace(artistMusicBrainzId))
{
var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{searchInfo.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken)
.ConfigureAwait(false);
if (releaseSearchResults.Results.Count > 0)
{
return GetReleaseSearchResult(releaseSearchResults.Results);
}
}
else
{
// I'm sure there is a better way but for now it resolves search for 12" Mixes
var queryName = searchInfo.Name.Replace("\"", string.Empty, StringComparison.Ordinal);
var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{queryName}\" AND artist:\"{searchInfo.GetAlbumArtist()}\"c", null, null, false, cancellationToken)
.ConfigureAwait(false);
if (releaseSearchResults.Results.Count > 0)
{
return GetReleaseSearchResult(releaseSearchResults.Results);
}
}
return Enumerable.Empty<RemoteSearchResult>();
}
private IEnumerable<RemoteSearchResult> GetReleaseSearchResult(IEnumerable<ISearchResult<IRelease>>? releaseSearchResults)
{
if (releaseSearchResults is null)
{
yield break;
}
foreach (var result in releaseSearchResults)
{
yield return GetReleaseResult(result.Item);
}
}
private async IAsyncEnumerable<RemoteSearchResult> GetReleaseGroupResultAsync(IEnumerable<IRelease>? releaseSearchResults, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (releaseSearchResults is null)
{
yield break;
}
foreach (var result in releaseSearchResults)
{
// Fetch full release info, otherwise artists are missing
var fullResult = await _musicBrainzQuery.LookupReleaseAsync(result.Id, Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
yield return GetReleaseResult(fullResult);
}
}
private RemoteSearchResult GetReleaseResult(IRelease releaseSearchResult)
{
var searchResult = new RemoteSearchResult
{
Name = releaseSearchResult.Title,
ProductionYear = releaseSearchResult.Date?.Year,
PremiereDate = releaseSearchResult.Date?.NearestDate,
SearchProviderName = Name
};
// Add artists and use first as album artist
var artists = releaseSearchResult.ArtistCredit;
if (artists is not null && artists.Count > 0)
{
var artistResults = new RemoteSearchResult[artists.Count];
for (int i = 0; i < artists.Count; i++)
{
var artist = artists[i];
var artistResult = new RemoteSearchResult
{
Name = artist.Name
};
if (artist.Artist?.Id is not null)
{
artistResult.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Artist!.Id.ToString());
}
artistResults[i] = artistResult;
}
searchResult.AlbumArtist = artistResults[0];
searchResult.Artists = artistResults;
}
searchResult.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseSearchResult.Id.ToString());
if (releaseSearchResult.ReleaseGroup?.Id is not null)
{
searchResult.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseSearchResult.ReleaseGroup.Id.ToString());
}
return searchResult;
}
/// <inheritdoc />
public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken)
{
// TODO: This sets essentially nothing. As-is, it's mostly useless. Make it actually pull metadata and use it.
var releaseId = info.GetReleaseId();
var releaseGroupId = info.GetReleaseGroupId();
var result = new MetadataResult<MusicAlbum>
{
Item = new MusicAlbum()
};
// If there is a release group, but no release ID, try to match the release
if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId))
{
// TODO: Actually try to match the release. Simply taking the first result is stupid.
var releaseGroup = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false);
var release = releaseGroup.Releases?.Count > 0 ? releaseGroup.Releases[0] : null;
if (release is not null)
{
releaseId = release.Id.ToString();
result.HasMetadata = true;
}
}
// If there is no release ID, lookup a release with the info we have
if (string.IsNullOrWhiteSpace(releaseId))
{
var artistMusicBrainzId = info.GetMusicBrainzArtistId();
IRelease? releaseResult = null;
if (!string.IsNullOrEmpty(artistMusicBrainzId))
{
var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{info.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken)
.ConfigureAwait(false);
releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null;
}
else if (!string.IsNullOrEmpty(info.GetAlbumArtist()))
{
var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{info.Name}\" AND artist:{info.GetAlbumArtist()}", null, null, false, cancellationToken)
.ConfigureAwait(false);
releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null;
}
if (releaseResult is not null)
{
releaseId = releaseResult.Id.ToString();
if (releaseResult.ReleaseGroup?.Id is not null)
{
releaseGroupId = releaseResult.ReleaseGroup.Id.ToString();
}
result.HasMetadata = true;
result.Item.ProductionYear = releaseResult.Date?.Year;
result.Item.Overview = releaseResult.Annotation;
}
}
// If we have a release ID but not a release group ID, lookup the release group
if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId))
{
var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
releaseGroupId = release.ReleaseGroup?.Id.ToString();
result.HasMetadata = true;
}
// If we have a release ID and a release group ID
if (!string.IsNullOrWhiteSpace(releaseId) || !string.IsNullOrWhiteSpace(releaseGroupId))
{
result.HasMetadata = true;
}
if (result.HasMetadata)
{
if (!string.IsNullOrEmpty(releaseId))
{
result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId);
}
if (!string.IsNullOrEmpty(releaseGroupId))
{
result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId);
}
}
return result;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Dispose all resources.
/// </summary>
/// <param name="disposing">Whether to dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_musicBrainzQuery.Dispose();
}
}
}
@@ -0,0 +1,24 @@
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// MusicBrainz artist external id.
/// </summary>
public class MusicBrainzArtistExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "MusicBrainz";
/// <inheritdoc />
public string Key => MetadataProvider.MusicBrainzArtist.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Artist;
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is MusicArtist;
}
@@ -0,0 +1,32 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// External artist URLs for MusicBrainz.
/// </summary>
public class MusicBrainzArtistExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "MusicBrainz Artist";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var externalId))
{
switch (item)
{
case MusicArtist:
case Person:
yield return Plugin.Instance!.Configuration.Server + $"/artist/{externalId}";
break;
}
}
}
}
@@ -0,0 +1,186 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Providers;
using MediaBrowser.Providers.Music;
using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
using MetaBrainz.MusicBrainz;
using MetaBrainz.MusicBrainz.Interfaces.Entities;
using MetaBrainz.MusicBrainz.Interfaces.Searches;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// MusicBrainz artist provider.
/// </summary>
public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IDisposable
{
private readonly ILogger<MusicBrainzArtistProvider> _logger;
private Query _musicBrainzQuery;
/// <summary>
/// Initializes a new instance of the <see cref="MusicBrainzArtistProvider"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
public MusicBrainzArtistProvider(ILogger<MusicBrainzArtistProvider> logger)
{
_logger = logger;
_musicBrainzQuery = new Query();
ReloadConfig(null, MusicBrainz.Plugin.Instance!.Configuration);
MusicBrainz.Plugin.Instance!.ConfigurationChanged += ReloadConfig;
}
/// <inheritdoc />
public string Name => "MusicBrainz";
private void ReloadConfig(object? sender, BasePluginConfiguration e)
{
var configuration = (PluginConfiguration)e;
if (Uri.TryCreate(configuration.Server, UriKind.Absolute, out var server))
{
Query.DefaultServer = server.DnsSafeHost;
Query.DefaultPort = server.Port;
Query.DefaultUrlScheme = server.Scheme;
}
else
{
// Fallback to official server
_logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server");
var defaultServer = new Uri(PluginConfiguration.DefaultServer);
Query.DefaultServer = defaultServer.Host;
Query.DefaultPort = defaultServer.Port;
Query.DefaultUrlScheme = defaultServer.Scheme;
}
Query.DelayBetweenRequests = configuration.RateLimit;
_musicBrainzQuery = new Query();
}
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken)
{
var artistId = searchInfo.GetMusicBrainzArtistId();
if (!string.IsNullOrWhiteSpace(artistId))
{
var artistResult = await _musicBrainzQuery.LookupArtistAsync(new Guid(artistId), Include.Aliases, null, null, cancellationToken).ConfigureAwait(false);
return GetResultFromResponse(artistResult).SingleItemAsEnumerable();
}
var artistSearchResults = await _musicBrainzQuery.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken)
.ConfigureAwait(false);
if (artistSearchResults.Results.Count > 0)
{
return GetResultsFromResponse(artistSearchResults.Results);
}
if (searchInfo.Name.HasDiacritics())
{
// Try again using the search with an accented characters query
var artistAccentsSearchResults = await _musicBrainzQuery.FindArtistsAsync($"artistaccent:\"{searchInfo.Name}\"", null, null, false, cancellationToken)
.ConfigureAwait(false);
if (artistAccentsSearchResults.Results.Count > 0)
{
return GetResultsFromResponse(artistAccentsSearchResults.Results);
}
}
return Enumerable.Empty<RemoteSearchResult>();
}
private IEnumerable<RemoteSearchResult> GetResultsFromResponse(IEnumerable<ISearchResult<IArtist>>? releaseSearchResults)
{
if (releaseSearchResults is null)
{
yield break;
}
foreach (var result in releaseSearchResults)
{
yield return GetResultFromResponse(result.Item);
}
}
private RemoteSearchResult GetResultFromResponse(IArtist artist)
{
var searchResult = new RemoteSearchResult
{
Name = artist.Name,
ProductionYear = artist.LifeSpan?.Begin?.Year,
PremiereDate = artist.LifeSpan?.Begin?.NearestDate,
SearchProviderName = Name,
};
searchResult.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Id.ToString());
return searchResult;
}
/// <inheritdoc />
public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<MusicArtist> { Item = new MusicArtist() };
var musicBrainzId = info.GetMusicBrainzArtistId();
if (string.IsNullOrWhiteSpace(musicBrainzId))
{
var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false);
var singleResult = searchResults.FirstOrDefault();
if (singleResult is not null)
{
musicBrainzId = singleResult.GetProviderId(MetadataProvider.MusicBrainzArtist);
result.Item.Overview = singleResult.Overview;
if (Plugin.Instance!.Configuration.ReplaceArtistName)
{
result.Item.Name = singleResult.Name;
}
}
}
if (!string.IsNullOrWhiteSpace(musicBrainzId))
{
result.HasMetadata = true;
result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzId);
}
return result;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Dispose all resources.
/// </summary>
/// <param name="disposing">Whether to dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_musicBrainzQuery.Dispose();
}
}
}
@@ -0,0 +1,24 @@
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// MusicBrainz other artist external id.
/// </summary>
public class MusicBrainzOtherArtistExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "MusicBrainz";
/// <inheritdoc />
public string Key => MetadataProvider.MusicBrainzArtist.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist;
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio or MusicAlbum;
}
@@ -0,0 +1,24 @@
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// MusicBrainz recording id.
/// </summary>
public class MusicBrainzRecordingId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "MusicBrainz";
/// <inheritdoc />
public string Key => MetadataProvider.MusicBrainzRecording.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Recording;
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio;
}
@@ -0,0 +1,24 @@
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// MusicBrainz release group external id.
/// </summary>
public class MusicBrainzReleaseGroupExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "MusicBrainz";
/// <inheritdoc />
public string Key => MetadataProvider.MusicBrainzReleaseGroup.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup;
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio or MusicAlbum;
}
@@ -0,0 +1,28 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// External release group URLs for MusicBrainz.
/// </summary>
public class MusicBrainzReleaseGroupExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "MusicBrainz Release Group";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item is MusicAlbum)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out var externalId))
{
yield return Plugin.Instance!.Configuration.Server + $"/release-group/{externalId}";
}
}
}
}
@@ -0,0 +1,28 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// External track URLs for MusicBrainz.
/// </summary>
public class MusicBrainzTrackExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "MusicBrainz Track";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item is Audio)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out var externalId))
{
yield return Plugin.Instance!.Configuration.Server + $"/track/{externalId}";
}
}
}
}
@@ -0,0 +1,24 @@
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// MusicBrainz track id.
/// </summary>
public class MusicBrainzTrackId : IExternalId
{
/// <inheritdoc />
public string ProviderName => "MusicBrainz";
/// <inheritdoc />
public string Key => MetadataProvider.MusicBrainzTrack.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Track;
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio;
}
@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using System.Net.Http.Headers;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
using MetaBrainz.MusicBrainz;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// Plugin instance.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
/// <param name="applicationHost">Instance of the <see cref="IApplicationHost"/> interface.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IApplicationHost applicationHost)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
// TODO: Change this to "JellyfinMusicBrainzPlugin" once we take it out of the server repo.
Query.DefaultUserAgent.Add(new ProductInfoHeaderValue(applicationHost.Name.Replace(' ', '-'), applicationHost.ApplicationVersionString));
Query.DefaultUserAgent.Add(new ProductInfoHeaderValue($"({applicationHost.ApplicationUserAgentAddress})"));
Query.DelayBetweenRequests = Instance.Configuration.RateLimit;
Query.DefaultServer = Instance.Configuration.Server;
}
/// <summary>
/// Gets the current plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public override Guid Id => new Guid("8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a");
/// <inheritdoc />
public override string Name => "MusicBrainz";
/// <inheritdoc />
public override string Description => "Get artist and album metadata from any MusicBrainz server.";
/// <inheritdoc />
// TODO remove when plugin removed from server.
public override string ConfigurationFileName => "Jellyfin.Plugin.MusicBrainz.xml";
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
yield return new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html"
};
}
}
@@ -0,0 +1,11 @@
#pragma warning disable CS1591
using MediaBrowser.Model.Plugins;
namespace MediaBrowser.Providers.Plugins.Omdb
{
public class PluginConfiguration : BasePluginConfiguration
{
public bool CastAndCrew { get; set; }
}
}
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html>
<head>
<title>OMDb</title>
</head>
<body>
<div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<h1>OMDb</h1>
<form class="configForm">
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="castAndCrew" />
<span>Collect information about the cast and other crew members from OMDb.</span>
</label>
<br />
<div>
<button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var PluginConfig = {
pluginId: "a628c0da-fac5-4c7e-9d1a-7134223f14c8"
};
document.querySelector('.configPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) {
document.querySelector('#castAndCrew').checked = config.CastAndCrew;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('.configForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) {
config.CastAndCrew = document.querySelector('#castAndCrew').checked;
ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>
@@ -0,0 +1,44 @@
using System;
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MediaBrowser.Providers.Plugins.Omdb
{
/// <summary>
/// Converts a string <c>N/A</c> to <c>string.Empty</c>.
/// </summary>
public class JsonOmdbNotAvailableInt32Converter : JsonConverter<int?>
{
/// <inheritdoc />
public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
{
var str = reader.GetString();
if (str is null || str.Equals("N/A", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var converter = TypeDescriptor.GetConverter(typeToConvert);
return (int?)converter.ConvertFromString(str);
}
return JsonSerializer.Deserialize<int>(ref reader, options);
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, int? value, JsonSerializerOptions options)
{
if (value.HasValue)
{
writer.WriteNumberValue(value.Value);
}
else
{
writer.WriteNullValue();
}
}
}
}
@@ -0,0 +1,42 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using Jellyfin.Extensions.Json;
namespace MediaBrowser.Providers.Plugins.Omdb
{
/// <summary>
/// Converts a string <c>N/A</c> to <c>string.Empty</c>.
/// </summary>
public class JsonOmdbNotAvailableStringConverter : JsonConverter<string?>
{
/// <inheritdoc />
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.IsNull())
{
return null;
}
if (reader.TokenType == JsonTokenType.String)
{
// GetString can't return null here because we already handled it above
var str = reader.GetString()!;
if (str.Equals("N/A", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return str;
}
return JsonSerializer.Deserialize<string?>(ref reader, options);
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options)
{
writer.WriteStringValue(value);
}
}
}
@@ -0,0 +1,79 @@
#pragma warning disable CS1591
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.Omdb
{
public class OmdbEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder
{
private readonly OmdbItemProvider _itemProvider;
private readonly OmdbProvider _omdbProvider;
public OmdbEpisodeProvider(
IHttpClientFactory httpClientFactory,
ILibraryManager libraryManager,
IFileSystem fileSystem,
IServerConfigurationManager configurationManager)
{
_itemProvider = new OmdbItemProvider(httpClientFactory, libraryManager, fileSystem, configurationManager);
_omdbProvider = new OmdbProvider(httpClientFactory, fileSystem, configurationManager);
}
// After TheTvDb
public int Order => 1;
public string Name => "The Open Movie Database";
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken)
{
return _itemProvider.GetSearchResults(searchInfo, cancellationToken);
}
public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Episode>
{
Item = new Episode(),
QueriedById = true
};
// Allowing this will dramatically increase scan times
if (info.IsMissingEpisode)
{
return result;
}
if (info.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out string? seriesImdbId)
&& !string.IsNullOrEmpty(seriesImdbId)
&& info.IndexNumber.HasValue)
{
result.HasMetadata = await _omdbProvider.FetchEpisodeData(
result,
info.IndexNumber.Value,
info.ParentIndexNumber ?? 1,
info.GetProviderId(MetadataProvider.Imdb),
seriesImdbId,
info.MetadataLanguage,
info.MetadataCountryCode,
cancellationToken).ConfigureAwait(false);
}
return result;
}
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
return _itemProvider.GetImageResponse(url, cancellationToken);
}
}
}
@@ -0,0 +1,80 @@
#nullable disable
#pragma warning disable CS1591
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.Omdb
{
public class OmdbImageProvider : IRemoteImageProvider, IHasOrder
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly OmdbProvider _omdbProvider;
public OmdbImageProvider(IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IServerConfigurationManager configurationManager)
{
_httpClientFactory = httpClientFactory;
_omdbProvider = new OmdbProvider(_httpClientFactory, fileSystem, configurationManager);
}
public string Name => "The Open Movie Database";
// After other internet providers, because they're better
// But before fallback providers like screengrab
public int Order => 90;
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
yield return ImageType.Primary;
}
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var imdbId = item.GetProviderId(MetadataProvider.Imdb);
if (string.IsNullOrWhiteSpace(imdbId))
{
return Enumerable.Empty<RemoteImageInfo>();
}
var rootObject = await _omdbProvider.GetRootObject(imdbId, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(rootObject.Poster))
{
return Enumerable.Empty<RemoteImageInfo>();
}
// the poster url is sometimes higher quality than the poster api
return new[]
{
new RemoteImageInfo
{
ProviderName = Name,
Url = rootObject.Poster
}
};
}
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
}
public bool Supports(BaseItem item)
{
return item is Movie || item is Trailer || item is Episode;
}
}
}
@@ -0,0 +1,312 @@
#nullable disable
#pragma warning disable CS1591, SA1300
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.Omdb
{
public class OmdbItemProvider : IRemoteMetadataProvider<Series, SeriesInfo>,
IRemoteMetadataProvider<Movie, MovieInfo>, IRemoteMetadataProvider<Trailer, TrailerInfo>, IHasOrder
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILibraryManager _libraryManager;
private readonly JsonSerializerOptions _jsonOptions;
private readonly OmdbProvider _omdbProvider;
public OmdbItemProvider(
IHttpClientFactory httpClientFactory,
ILibraryManager libraryManager,
IFileSystem fileSystem,
IServerConfigurationManager configurationManager)
{
_httpClientFactory = httpClientFactory;
_libraryManager = libraryManager;
_omdbProvider = new OmdbProvider(_httpClientFactory, fileSystem, configurationManager);
_jsonOptions = new JsonSerializerOptions(JsonDefaults.Options);
_jsonOptions.Converters.Add(new JsonOmdbNotAvailableStringConverter());
_jsonOptions.Converters.Add(new JsonOmdbNotAvailableInt32Converter());
}
public string Name => "The Open Movie Database";
// After primary option
public int Order => 2;
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(TrailerInfo searchInfo, CancellationToken cancellationToken)
{
return GetSearchResultsInternal(searchInfo, true, cancellationToken);
}
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken)
{
return GetSearchResultsInternal(searchInfo, true, cancellationToken);
}
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken)
{
return GetSearchResultsInternal(searchInfo, true, cancellationToken);
}
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken)
{
return GetSearchResultsInternal(searchInfo, true, cancellationToken);
}
private async Task<IEnumerable<RemoteSearchResult>> GetSearchResultsInternal(ItemLookupInfo searchInfo, bool isSearch, CancellationToken cancellationToken)
{
var type = searchInfo switch
{
EpisodeInfo => "episode",
SeriesInfo => "series",
_ => "movie"
};
// This is a bit hacky?
var episodeSearchInfo = searchInfo as EpisodeInfo;
var indexNumberEnd = episodeSearchInfo?.IndexNumberEnd;
var imdbId = searchInfo.GetProviderId(MetadataProvider.Imdb);
var urlQuery = new StringBuilder("plot=full&r=json");
if (episodeSearchInfo is not null)
{
episodeSearchInfo.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out imdbId);
if (searchInfo.IndexNumber.HasValue)
{
urlQuery.Append("&Episode=").Append(searchInfo.IndexNumber.Value);
}
if (searchInfo.ParentIndexNumber.HasValue)
{
urlQuery.Append("&Season=").Append(searchInfo.ParentIndexNumber.Value);
}
}
if (string.IsNullOrWhiteSpace(imdbId))
{
var name = searchInfo.Name;
var year = searchInfo.Year;
if (!string.IsNullOrWhiteSpace(name))
{
var parsedName = _libraryManager.ParseName(name);
var yearInName = parsedName.Year;
name = parsedName.Name;
year ??= yearInName;
}
if (year.HasValue)
{
urlQuery.Append("&y=").Append(year);
}
// &s means search and returns a list of results as opposed to t
urlQuery.Append(isSearch ? "&s=" : "&t=");
urlQuery.Append(WebUtility.UrlEncode(name));
urlQuery.Append("&type=")
.Append(type);
}
else
{
urlQuery.Append("&i=")
.Append(imdbId);
isSearch = false;
}
var url = OmdbProvider.GetOmdbUrl(urlQuery.ToString());
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false);
if (isSearch)
{
var searchResultList = await response.Content.ReadFromJsonAsync<SearchResultList>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (searchResultList?.Search is not null)
{
var resultCount = searchResultList.Search.Count;
var result = new RemoteSearchResult[resultCount];
for (var i = 0; i < resultCount; i++)
{
result[i] = ResultToMetadataResult(searchResultList.Search[i], searchInfo, indexNumberEnd);
}
return result;
}
}
else
{
var result = await response.Content.ReadFromJsonAsync<SearchResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (string.Equals(result?.Response, "true", StringComparison.OrdinalIgnoreCase))
{
return new[] { ResultToMetadataResult(result, searchInfo, indexNumberEnd) };
}
}
return Enumerable.Empty<RemoteSearchResult>();
}
public Task<MetadataResult<Trailer>> GetMetadata(TrailerInfo info, CancellationToken cancellationToken)
{
return GetResult<Trailer>(info, cancellationToken);
}
public Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken)
{
return GetResult<Series>(info, cancellationToken);
}
public Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken)
{
return GetResult<Movie>(info, cancellationToken);
}
private RemoteSearchResult ResultToMetadataResult(SearchResult result, ItemLookupInfo searchInfo, int? indexNumberEnd)
{
var item = new RemoteSearchResult
{
IndexNumber = searchInfo.IndexNumber,
Name = result.Title,
ParentIndexNumber = searchInfo.ParentIndexNumber,
SearchProviderName = Name,
IndexNumberEnd = indexNumberEnd
};
item.SetProviderId(MetadataProvider.Imdb, result.imdbID);
if (OmdbProvider.TryParseYear(result.Year, out var parsedYear))
{
item.ProductionYear = parsedYear;
}
if (!string.IsNullOrEmpty(result.Released)
&& DateTime.TryParse(result.Released, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var released))
{
item.PremiereDate = released;
}
if (!string.IsNullOrWhiteSpace(result.Poster))
{
item.ImageUrl = result.Poster;
}
return item;
}
private async Task<MetadataResult<T>> GetResult<T>(ItemLookupInfo info, CancellationToken cancellationToken)
where T : BaseItem, new()
{
var result = new MetadataResult<T>
{
Item = new T(),
QueriedById = true
};
var imdbId = info.GetProviderId(MetadataProvider.Imdb);
if (string.IsNullOrWhiteSpace(imdbId))
{
imdbId = await GetImdbId(info, cancellationToken).ConfigureAwait(false);
result.QueriedById = false;
}
if (!string.IsNullOrEmpty(imdbId))
{
result.Item.SetProviderId(MetadataProvider.Imdb, imdbId);
result.HasMetadata = true;
await _omdbProvider.Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
}
return result;
}
private async Task<string> GetImdbId(ItemLookupInfo info, CancellationToken cancellationToken)
{
var results = await GetSearchResultsInternal(info, false, cancellationToken).ConfigureAwait(false);
var first = results.FirstOrDefault();
return first?.GetProviderId(MetadataProvider.Imdb);
}
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
}
private class SearchResult
{
public string Title { get; set; }
public string Year { get; set; }
public string Rated { get; set; }
public string Released { get; set; }
public string Season { get; set; }
public string Episode { get; set; }
public string Runtime { get; set; }
public string Genre { get; set; }
public string Director { get; set; }
public string Writer { get; set; }
public string Actors { get; set; }
public string Plot { get; set; }
public string Language { get; set; }
public string Country { get; set; }
public string Awards { get; set; }
public string Poster { get; set; }
public string Metascore { get; set; }
public string imdbRating { get; set; }
public string imdbVotes { get; set; }
public string imdbID { get; set; }
public string seriesID { get; set; }
public string Type { get; set; }
public string Response { get; set; }
}
private class SearchResultList
{
/// <summary>
/// Gets or sets the results.
/// </summary>
/// <value>The results.</value>
public List<SearchResult> Search { get; set; }
}
}
}
@@ -0,0 +1,570 @@
#nullable disable
#pragma warning disable CS159, SA1300
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
namespace MediaBrowser.Providers.Plugins.Omdb
{
/// <summary>Provider for OMDB service.</summary>
public class OmdbProvider
{
private readonly IFileSystem _fileSystem;
private readonly IServerConfigurationManager _configurationManager;
private readonly IHttpClientFactory _httpClientFactory;
private readonly JsonSerializerOptions _jsonOptions;
/// <summary>Initializes a new instance of the <see cref="OmdbProvider"/> class.</summary>
/// <param name="httpClientFactory">HttpClientFactory to use for calls to OMDB service.</param>
/// <param name="fileSystem">IFileSystem to use for store OMDB data.</param>
/// <param name="configurationManager">IServerConfigurationManager to use.</param>
public OmdbProvider(IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IServerConfigurationManager configurationManager)
{
_httpClientFactory = httpClientFactory;
_fileSystem = fileSystem;
_configurationManager = configurationManager;
_jsonOptions = new JsonSerializerOptions(JsonDefaults.Options);
// These converters need to take priority
_jsonOptions.Converters.Insert(0, new JsonOmdbNotAvailableStringConverter());
_jsonOptions.Converters.Insert(0, new JsonOmdbNotAvailableInt32Converter());
}
/// <summary>Fetches data from OMDB service.</summary>
/// <param name="itemResult">Metadata about media item.</param>
/// <param name="imdbId">IMDB ID for media.</param>
/// <param name="language">Media language.</param>
/// <param name="country">Country of origin.</param>
/// <param name="cancellationToken">CancellationToken to use for operation.</param>
/// <typeparam name="T">The first generic type parameter.</typeparam>
/// <returns>Returns a Task object that can be awaited.</returns>
public async Task Fetch<T>(MetadataResult<T> itemResult, string imdbId, string language, string country, CancellationToken cancellationToken)
where T : BaseItem
{
if (string.IsNullOrWhiteSpace(imdbId))
{
throw new ArgumentNullException(nameof(imdbId));
}
var item = itemResult.Item;
var result = await GetRootObject(imdbId, cancellationToken).ConfigureAwait(false);
var isEnglishRequested = IsConfiguredForEnglish(item, language);
// Only take the name and rating if the user's language is set to English, since Omdb has no localization
if (isEnglishRequested)
{
item.Name = result.Title;
if (string.Equals(country, "us", StringComparison.OrdinalIgnoreCase))
{
item.OfficialRating = result.Rated;
}
}
if (TryParseYear(result.Year, out var year))
{
item.ProductionYear = year;
}
var tomatoScore = result.GetRottenTomatoScore();
if (tomatoScore.HasValue)
{
item.CriticRating = tomatoScore;
}
if (!string.IsNullOrEmpty(result.imdbVotes)
&& int.TryParse(result.imdbVotes, NumberStyles.Number, CultureInfo.InvariantCulture, out var voteCount)
&& voteCount >= 0)
{
// item.VoteCount = voteCount;
}
if (float.TryParse(result.imdbRating, CultureInfo.InvariantCulture, out var imdbRating)
&& imdbRating >= 0)
{
item.CommunityRating = imdbRating;
}
if (!string.IsNullOrEmpty(result.Website))
{
item.HomePageUrl = result.Website;
}
if (!string.IsNullOrWhiteSpace(result.imdbID))
{
item.SetProviderId(MetadataProvider.Imdb, result.imdbID);
}
ParseAdditionalMetadata(itemResult, result, isEnglishRequested);
}
/// <summary>Gets data about an episode.</summary>
/// <param name="itemResult">Metadata about episode.</param>
/// <param name="episodeNumber">Episode number.</param>
/// <param name="seasonNumber">Season number.</param>
/// <param name="episodeImdbId">Episode ID.</param>
/// <param name="seriesImdbId">Season ID.</param>
/// <param name="language">Episode language.</param>
/// <param name="country">Country of origin.</param>
/// <param name="cancellationToken">CancellationToken to use for operation.</param>
/// <typeparam name="T">The first generic type parameter.</typeparam>
/// <returns>Whether operation was successful.</returns>
public async Task<bool> FetchEpisodeData<T>(MetadataResult<T> itemResult, int episodeNumber, int seasonNumber, string episodeImdbId, string seriesImdbId, string language, string country, CancellationToken cancellationToken)
where T : BaseItem
{
if (string.IsNullOrWhiteSpace(seriesImdbId))
{
throw new ArgumentNullException(nameof(seriesImdbId));
}
var item = itemResult.Item;
item.IndexNumber = episodeNumber;
item.ParentIndexNumber = seasonNumber;
var seasonResult = await GetSeasonRootObject(seriesImdbId, seasonNumber, cancellationToken).ConfigureAwait(false);
if (seasonResult?.Episodes is null)
{
return false;
}
RootObject result = null;
if (!string.IsNullOrWhiteSpace(episodeImdbId))
{
foreach (var episode in seasonResult.Episodes)
{
if (string.Equals(episodeImdbId, episode.imdbID, StringComparison.OrdinalIgnoreCase))
{
result = episode;
break;
}
}
}
// finally, search by numbers
if (result is null)
{
foreach (var episode in seasonResult.Episodes)
{
if (episode.Episode == episodeNumber)
{
result = episode;
break;
}
}
}
if (result is null)
{
return false;
}
var isEnglishRequested = IsConfiguredForEnglish(item, language);
// Only take the name and rating if the user's language is set to English, since Omdb has no localization
if (isEnglishRequested)
{
item.Name = result.Title;
if (string.Equals(country, "us", StringComparison.OrdinalIgnoreCase))
{
item.OfficialRating = result.Rated;
}
}
if (TryParseYear(result.Year, out var year))
{
item.ProductionYear = year;
}
var tomatoScore = result.GetRottenTomatoScore();
if (tomatoScore.HasValue)
{
item.CriticRating = tomatoScore;
}
if (!string.IsNullOrEmpty(result.imdbVotes)
&& int.TryParse(result.imdbVotes, NumberStyles.Number, CultureInfo.InvariantCulture, out var voteCount)
&& voteCount >= 0)
{
// item.VoteCount = voteCount;
}
if (float.TryParse(result.imdbRating, CultureInfo.InvariantCulture, out var imdbRating)
&& imdbRating >= 0)
{
item.CommunityRating = imdbRating;
}
if (!string.IsNullOrEmpty(result.Website))
{
item.HomePageUrl = result.Website;
}
item.TrySetProviderId(MetadataProvider.Imdb, result.imdbID);
ParseAdditionalMetadata(itemResult, result, isEnglishRequested);
return true;
}
internal async Task<RootObject> GetRootObject(string imdbId, CancellationToken cancellationToken)
{
var path = await EnsureItemInfo(imdbId, cancellationToken).ConfigureAwait(false);
var stream = AsyncFile.OpenRead(path);
await using (stream.ConfigureAwait(false))
{
return await JsonSerializer.DeserializeAsync<RootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
}
}
internal async Task<SeasonRootObject> GetSeasonRootObject(string imdbId, int seasonId, CancellationToken cancellationToken)
{
var path = await EnsureSeasonInfo(imdbId, seasonId, cancellationToken).ConfigureAwait(false);
var stream = AsyncFile.OpenRead(path);
await using (stream.ConfigureAwait(false))
{
return await JsonSerializer.DeserializeAsync<SeasonRootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>Gets OMDB URL.</summary>
/// <param name="query">Appends query string to URL.</param>
/// <returns>OMDB URL with optional query string.</returns>
public static string GetOmdbUrl(string query)
{
const string Url = "https://www.omdbapi.com?apikey=2c9d9507";
if (string.IsNullOrWhiteSpace(query))
{
return Url;
}
return Url + "&" + query;
}
/// <summary>
/// Extract the year from a string.
/// </summary>
/// <param name="input">The input string.</param>
/// <param name="year">The year.</param>
/// <returns>A value indicating whether the input could successfully be parsed as a year.</returns>
public static bool TryParseYear(string input, [NotNullWhen(true)] out int? year)
{
if (string.IsNullOrEmpty(input))
{
year = 0;
return false;
}
if (int.TryParse(input.AsSpan(0, 4), NumberStyles.Number, CultureInfo.InvariantCulture, out var result))
{
year = result;
return true;
}
year = 0;
return false;
}
private async Task<string> EnsureItemInfo(string imdbId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(imdbId))
{
throw new ArgumentNullException(nameof(imdbId));
}
var imdbParam = imdbId.StartsWith("tt", StringComparison.OrdinalIgnoreCase) ? imdbId : "tt" + imdbId;
var path = GetDataFilePath(imdbParam);
var fileInfo = _fileSystem.GetFileSystemInfo(path);
if (fileInfo.Exists)
{
// If it's recent or automatic updates are enabled, don't re-download
if ((DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 1)
{
return path;
}
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
}
var url = GetOmdbUrl(
string.Format(
CultureInfo.InvariantCulture,
"i={0}&plot=short&tomatoes=true&r=json",
imdbParam));
var rootObject = await _httpClientFactory.CreateClient(NamedClient.Default).GetFromJsonAsync<RootObject>(url, _jsonOptions, cancellationToken).ConfigureAwait(false);
FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
await using (jsonFileStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false);
}
return path;
}
private async Task<string> EnsureSeasonInfo(string seriesImdbId, int seasonId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(seriesImdbId))
{
throw new ArgumentException("The series IMDb ID was null or whitespace.", nameof(seriesImdbId));
}
var imdbParam = seriesImdbId.StartsWith("tt", StringComparison.OrdinalIgnoreCase) ? seriesImdbId : "tt" + seriesImdbId;
var path = GetSeasonFilePath(imdbParam, seasonId);
var fileInfo = _fileSystem.GetFileSystemInfo(path);
if (fileInfo.Exists)
{
// If it's recent or automatic updates are enabled, don't re-download
if ((DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 1)
{
return path;
}
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
}
var url = GetOmdbUrl(
string.Format(
CultureInfo.InvariantCulture,
"i={0}&season={1}&detail=full",
imdbParam,
seasonId));
var rootObject = await _httpClientFactory.CreateClient(NamedClient.Default).GetFromJsonAsync<SeasonRootObject>(url, _jsonOptions, cancellationToken).ConfigureAwait(false);
FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
await using (jsonFileStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false);
}
return path;
}
internal string GetDataFilePath(string imdbId)
{
ArgumentException.ThrowIfNullOrEmpty(imdbId);
var dataPath = Path.Combine(_configurationManager.ApplicationPaths.CachePath, "omdb");
var filename = string.Format(CultureInfo.InvariantCulture, "{0}.json", imdbId);
return Path.Combine(dataPath, filename);
}
internal string GetSeasonFilePath(string imdbId, int seasonId)
{
ArgumentException.ThrowIfNullOrEmpty(imdbId);
var dataPath = Path.Combine(_configurationManager.ApplicationPaths.CachePath, "omdb");
var filename = string.Format(CultureInfo.InvariantCulture, "{0}_season_{1}.json", imdbId, seasonId);
return Path.Combine(dataPath, filename);
}
private static void ParseAdditionalMetadata<T>(MetadataResult<T> itemResult, RootObject result, bool isEnglishRequested)
where T : BaseItem
{
var item = itemResult.Item;
// Grab series genres because IMDb data is better than TVDB. Leave movies alone
// But only do it if English is the preferred language because this data will not be localized
if (isEnglishRequested && !string.IsNullOrWhiteSpace(result.Genre))
{
item.Genres = Array.Empty<string>();
foreach (var genre in result.Genre.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
item.AddGenre(genre);
}
}
item.Overview = result.Plot;
if (!Plugin.Instance.Configuration.CastAndCrew)
{
return;
}
if (!string.IsNullOrWhiteSpace(result.Director))
{
var person = new PersonInfo
{
Name = result.Director.Trim(),
Type = PersonKind.Director
};
itemResult.AddPerson(person);
}
if (!string.IsNullOrWhiteSpace(result.Writer))
{
var person = new PersonInfo
{
Name = result.Writer.Trim(),
Type = PersonKind.Writer
};
itemResult.AddPerson(person);
}
if (!string.IsNullOrWhiteSpace(result.Actors))
{
var actorList = result.Actors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var actor in actorList)
{
var person = new PersonInfo
{
Name = actor,
Type = PersonKind.Actor
};
itemResult.AddPerson(person);
}
}
}
private static bool IsConfiguredForEnglish(BaseItem item, string language)
{
if (string.IsNullOrEmpty(language))
{
language = item.GetPreferredMetadataLanguage();
}
// The data isn't localized and so can only be used for English users
return string.Equals(language, "en", StringComparison.OrdinalIgnoreCase);
}
internal class SeasonRootObject
{
public string Title { get; set; }
public string seriesID { get; set; }
public int? Season { get; set; }
public int? totalSeasons { get; set; }
public RootObject[] Episodes { get; set; }
public string Response { get; set; }
}
internal class RootObject
{
public string Title { get; set; }
public string Year { get; set; }
public string Rated { get; set; }
public string Released { get; set; }
public string Runtime { get; set; }
public string Genre { get; set; }
public string Director { get; set; }
public string Writer { get; set; }
public string Actors { get; set; }
public string Plot { get; set; }
public string Language { get; set; }
public string Country { get; set; }
public string Awards { get; set; }
public string Poster { get; set; }
public List<OmdbRating> Ratings { get; set; }
public string Metascore { get; set; }
public string imdbRating { get; set; }
public string imdbVotes { get; set; }
public string imdbID { get; set; }
public string Type { get; set; }
public string DVD { get; set; }
public string BoxOffice { get; set; }
public string Production { get; set; }
public string Website { get; set; }
public string Response { get; set; }
public int? Episode { get; set; }
public float? GetRottenTomatoScore()
{
if (Ratings is not null)
{
var rating = Ratings.FirstOrDefault(i => string.Equals(i.Source, "Rotten Tomatoes", StringComparison.OrdinalIgnoreCase));
if (rating?.Value is not null)
{
var value = rating.Value.TrimEnd('%');
if (float.TryParse(value, CultureInfo.InvariantCulture, out var score))
{
return score;
}
}
}
return null;
}
}
#pragma warning disable CA1034
/// <summary>Describes OMDB rating.</summary>
public class OmdbRating
{
/// <summary>Gets or sets rating source.</summary>
public string Source { get; set; }
/// <summary>Gets or sets rating value.</summary>
public string Value { get; set; }
}
}
}
@@ -0,0 +1,41 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace MediaBrowser.Providers.Plugins.Omdb
{
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
public static Plugin Instance { get; private set; }
public override Guid Id => new Guid("a628c0da-fac5-4c7e-9d1a-7134223f14c8");
public override string Name => "OMDb";
public override string Description => "Get metadata for movies and other video content from OMDb.";
// TODO remove when plugin removed from server.
public override string ConfigurationFileName => "Jellyfin.Plugin.Omdb.xml";
public IEnumerable<PluginPageInfo> GetPages()
{
yield return new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html"
};
}
}
}
@@ -0,0 +1,35 @@
using MediaBrowser.Model.Plugins;
namespace MediaBrowser.Providers.Plugins.StudioImages.Configuration
{
/// <summary>
/// Plugin configuration class for the studio image provider.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
private string _repository = Plugin.DefaultServer;
/// <summary>
/// Gets or sets the studio image repository URL.
/// </summary>
public string RepositoryUrl
{
get
{
if (string.IsNullOrEmpty(_repository))
{
_repository = Plugin.DefaultServer;
}
return _repository;
}
set
{
_repository = string.IsNullOrEmpty(value)
? Plugin.DefaultServer
: value.TrimEnd('/');
}
}
}
}
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html>
<head>
<title>Studio Images</title>
</head>
<body>
<div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<h1>Studio Images</h1>
<form class="configForm">
<div class="inputContainer">
<input is="emby-input" type="text" id="repository" label="Repository" />
<div class="fieldDescription">This can be any Jellyfin-compatible artwork repository. Leave blank to use default repository.</div>
</div>
<br />
<div>
<button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var PluginConfig = {
pluginId: "872a7849-1171-458d-a6fb-3de3d442ad30"
};
document.querySelector('.configPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) {
var repository = document.querySelector('#repository');
repository.value = config.RepositoryUrl;
repository.dispatchEvent(new Event('change', {
bubbles: true,
cancelable: false
}));
Dashboard.hideLoadingMsg();
});
});
document.querySelector('.configForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) {
config.RepositoryUrl = document.querySelector('#repository').value;
ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>
@@ -0,0 +1,63 @@
#nullable disable
using System;
using System.Collections.Generic;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Providers.Plugins.StudioImages.Configuration;
namespace MediaBrowser.Providers.Plugins.StudioImages
{
/// <summary>
/// Artwork Plugin class.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Artwork repository URL.
/// </summary>
public const string DefaultServer = "https://raw.githubusercontent.com/jellyfin/emby-artwork/master/studios";
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">application paths.</param>
/// <param name="xmlSerializer">xml serializer.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <summary>
/// Gets the instance of Artwork plugin.
/// </summary>
public static Plugin Instance { get; private set; }
/// <inheritdoc/>
public override Guid Id => new Guid("872a7849-1171-458d-a6fb-3de3d442ad30");
/// <inheritdoc/>
public override string Name => "Studio Images";
/// <inheritdoc/>
public override string Description => "Get artwork for studios from any Jellyfin-compatible repository.";
// TODO remove when plugin removed from server.
/// <inheritdoc/>
public override string ConfigurationFileName => "Jellyfin.Plugin.StudioImages.xml";
/// <inheritdoc/>
public IEnumerable<PluginPageInfo> GetPages()
{
yield return new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html"
};
}
}
}
@@ -0,0 +1,192 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.StudioImages
{
/// <summary>
/// Studio image provider.
/// </summary>
public class StudiosImageProvider : IRemoteImageProvider
{
private readonly IServerConfigurationManager _config;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IFileSystem _fileSystem;
/// <summary>
/// Initializes a new instance of the <see cref="StudiosImageProvider"/> class.
/// </summary>
/// <param name="config">The <see cref="IServerConfigurationManager"/>.</param>
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
/// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
public StudiosImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory, IFileSystem fileSystem)
{
_config = config;
_httpClientFactory = httpClientFactory;
_fileSystem = fileSystem;
}
/// <inheritdoc />
public string Name => "Artwork Repository";
/// <inheritdoc />
public bool Supports(BaseItem item)
{
return item is Studio;
}
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
return [ImageType.Thumb];
}
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var thumbsPath = Path.Combine(_config.ApplicationPaths.CachePath, "imagesbyname", "remotestudiothumbs.txt");
await EnsureThumbsList(thumbsPath, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var imageInfo = GetImage(item, thumbsPath, ImageType.Thumb, "thumb");
if (imageInfo is null)
{
return [];
}
return [imageInfo];
}
private RemoteImageInfo GetImage(BaseItem item, string filename, ImageType type, string remoteFilename)
{
var list = GetAvailableImages(filename);
var match = FindMatch(item, list);
if (!string.IsNullOrEmpty(match))
{
var url = GetUrl(match, remoteFilename);
return new RemoteImageInfo
{
ProviderName = Name,
Type = type,
Url = url
};
}
return null;
}
private string GetUrl(string image, string filename)
{
return string.Format(CultureInfo.InvariantCulture, "{0}/images/{1}/{2}.jpg", GetRepositoryUrl(), image, filename);
}
private async Task EnsureThumbsList(string file, CancellationToken cancellationToken)
{
string url = string.Format(CultureInfo.InvariantCulture, "{0}/thumbs.txt", GetRepositoryUrl());
await EnsureList(url, file, _fileSystem, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
return httpClient.GetAsync(url, cancellationToken);
}
/// <summary>
/// Ensures the existence of a file listing.
/// </summary>
/// <param name="url">The URL.</param>
/// <param name="file">The file.</param>
/// <param name="fileSystem">The file system.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A Task to ensure existence of a file listing.</returns>
public async Task EnsureList(string url, string file, IFileSystem fileSystem, CancellationToken cancellationToken)
{
var fileInfo = fileSystem.GetFileInfo(file);
if (!fileInfo.Exists || (DateTime.UtcNow - fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays > 1)
{
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
Directory.CreateDirectory(Path.GetDirectoryName(file));
var response = await httpClient.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);
await using (response.ConfigureAwait(false))
{
var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
await using (fileStream.ConfigureAwait(false))
{
await response.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
}
}
}
}
/// <summary>
/// Get matching image for an item.
/// </summary>
/// <param name="item">The <see cref="BaseItem"/>.</param>
/// <param name="images">The enumerable of image strings.</param>
/// <returns>The matching image string.</returns>
public string FindMatch(BaseItem item, IEnumerable<string> images)
{
var name = GetComparableName(item.Name);
return images.FirstOrDefault(i => string.Equals(name, GetComparableName(i), StringComparison.OrdinalIgnoreCase));
}
private string GetComparableName(string name)
{
return name.Replace(" ", string.Empty, StringComparison.Ordinal)
.Replace(".", string.Empty, StringComparison.Ordinal)
.Replace("&", string.Empty, StringComparison.Ordinal)
.Replace("!", string.Empty, StringComparison.Ordinal)
.Replace(",", string.Empty, StringComparison.Ordinal)
.Replace("/", string.Empty, StringComparison.Ordinal);
}
/// <summary>
/// Get available image strings for a file.
/// </summary>
/// <param name="file">The file.</param>
/// <returns>All images strings of a file.</returns>
public IEnumerable<string> GetAvailableImages(string file)
{
using var fileStream = File.OpenRead(file);
using var reader = new StreamReader(fileStream);
foreach (var line in reader.ReadAllLines())
{
if (!string.IsNullOrWhiteSpace(line))
{
yield return line;
}
}
}
private string GetRepositoryUrl()
=> Plugin.Instance.Configuration.RepositoryUrl;
}
}
@@ -0,0 +1,41 @@
using System.Net.Mime;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TMDbLib.Objects.General;
namespace MediaBrowser.Providers.Plugins.Tmdb.Api
{
/// <summary>
/// The TMDb API controller.
/// </summary>
[ApiController]
[Authorize]
[Route("[controller]")]
[Produces(MediaTypeNames.Application.Json)]
public class TmdbController : ControllerBase
{
private readonly TmdbClientManager _tmdbClientManager;
/// <summary>
/// Initializes a new instance of the <see cref="TmdbController"/> class.
/// </summary>
/// <param name="tmdbClientManager">The TMDb client manager.</param>
public TmdbController(TmdbClientManager tmdbClientManager)
{
_tmdbClientManager = tmdbClientManager;
}
/// <summary>
/// Gets the TMDb image configuration options.
/// </summary>
/// <returns>The image portion of the TMDb client configuration.</returns>
[HttpGet("ClientConfiguration")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ConfigImageTypes> TmdbClientConfiguration()
{
return (await _tmdbClientManager.GetClientConfiguration().ConfigureAwait(false)).Images;
}
}
}
@@ -0,0 +1,29 @@
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
{
/// <summary>
/// External id for a TMDb box set.
/// </summary>
public class TmdbBoxSetExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => TmdbUtils.ProviderName;
/// <inheritdoc />
public string Key => MetadataProvider.TmdbCollection.ToString();
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.BoxSet;
/// <inheritdoc />
public bool Supports(IHasProviderIds item)
{
return item is Movie || item is MusicVideo || item is Trailer;
}
}
}
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
{
/// <summary>
/// BoxSet image provider powered by TMDb.
/// </summary>
public class TmdbBoxSetImageProvider : IRemoteImageProvider, IHasOrder
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly TmdbClientManager _tmdbClientManager;
/// <summary>
/// Initializes a new instance of the <see cref="TmdbBoxSetImageProvider"/> class.
/// </summary>
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
/// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param>
public TmdbBoxSetImageProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager)
{
_httpClientFactory = httpClientFactory;
_tmdbClientManager = tmdbClientManager;
}
/// <inheritdoc />
public string Name => TmdbUtils.ProviderName;
/// <inheritdoc />
public int Order => 0;
/// <inheritdoc />
public bool Supports(BaseItem item)
{
return item is BoxSet;
}
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item) =>
[
ImageType.Primary,
ImageType.Backdrop,
ImageType.Thumb
];
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var tmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
if (tmdbId <= 0)
{
return Enumerable.Empty<RemoteImageInfo>();
}
var language = item.GetPreferredMetadataLanguage();
// TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here
var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, null, null, null, cancellationToken).ConfigureAwait(false);
if (collection?.Images is null)
{
return Enumerable.Empty<RemoteImageInfo>();
}
var posters = collection.Images.Posters;
var backdrops = collection.Images.Backdrops;
var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count);
remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language));
remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language));
return remoteImages;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
}
}
}
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
{
/// <summary>
/// BoxSet provider powered by TMDb.
/// </summary>
public class TmdbBoxSetProvider : IRemoteMetadataProvider<BoxSet, BoxSetInfo>
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly TmdbClientManager _tmdbClientManager;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="TmdbBoxSetProvider"/> class.
/// </summary>
/// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
/// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param>
public TmdbBoxSetProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager, ILibraryManager libraryManager)
{
_httpClientFactory = httpClientFactory;
_tmdbClientManager = tmdbClientManager;
_libraryManager = libraryManager;
}
/// <inheritdoc />
public string Name => TmdbUtils.ProviderName;
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken)
{
var tmdbId = Convert.ToInt32(searchInfo.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
var language = searchInfo.MetadataLanguage;
if (tmdbId > 0)
{
var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language, searchInfo.MetadataCountryCode), searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (collection is null)
{
return Enumerable.Empty<RemoteSearchResult>();
}
var result = new RemoteSearchResult
{
Name = collection.Name,
SearchProviderName = Name
};
if (collection.Images is not null)
{
result.ImageUrl = _tmdbClientManager.GetPosterUrl(collection.PosterPath);
}
result.SetProviderId(MetadataProvider.Tmdb, collection.Id.ToString(CultureInfo.InvariantCulture));
return new[] { result };
}
var collectionSearchResults = await _tmdbClientManager.SearchCollectionAsync(searchInfo.Name, language, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
var collections = new RemoteSearchResult[collectionSearchResults.Count];
for (var i = 0; i < collectionSearchResults.Count; i++)
{
var result = collectionSearchResults[i];
var collection = new RemoteSearchResult
{
Name = result.Name,
SearchProviderName = Name,
ImageUrl = _tmdbClientManager.GetPosterUrl(result.PosterPath)
};
collection.SetProviderId(MetadataProvider.Tmdb, result.Id.ToString(CultureInfo.InvariantCulture));
collections[i] = collection;
}
return collections;
}
/// <inheritdoc />
public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken)
{
var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
var language = info.MetadataLanguage;
// We don't already have an Id, need to fetch it
if (tmdbId <= 0)
{
// ParseName is required here.
// Caller provides the filename with extension stripped and NOT the parsed filename
var parsedName = _libraryManager.ParseName(info.Name);
var cleanedName = TmdbUtils.CleanName(parsedName.Name);
var searchResults = await _tmdbClientManager.SearchCollectionAsync(cleanedName, language, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (searchResults is not null && searchResults.Count > 0)
{
tmdbId = searchResults[0].Id;
}
}
var result = new MetadataResult<BoxSet>();
if (tmdbId > 0)
{
var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (collection is not null)
{
var item = new BoxSet
{
Name = collection.Name,
Overview = collection.Overview
};
item.SetProviderId(MetadataProvider.Tmdb, collection.Id.ToString(CultureInfo.InvariantCulture));
result.HasMetadata = true;
result.Item = item;
}
}
return result;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
}
}
}

Some files were not shown because too many files have changed in this diff Show More