repo creation with initial code after cloning public repo
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+28
@@ -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
Reference in New Issue
Block a user