< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.Books.OpenPackagingFormat.OpfReader<T>
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 132
Coverable lines: 132
Total lines: 329
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 160
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 1/29/2026 - 12:13:32 AM Line coverage: 0% (0/132) Branch coverage: 0% (0/160) Total lines: 329 1/29/2026 - 12:13:32 AM Line coverage: 0% (0/132) Branch coverage: 0% (0/160) Total lines: 329

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
ReadCoverPath(...)0%1056320%
ReadOpfData(...)100%210%
CreateBookFromOpf()0%420200%
FindMainTitle()0%156120%
FindSortTitle()0%210140%
FindAuthors(...)0%4260%
GetRole(...)0%2162460%
ReadStringInto(...)0%2040%
ReadInt32AttributeInto(...)0%7280%
ReadEpubCoverInto(...)0%620%
ReadManifestItem(...)0%210140%
IsValidImage(...)0%620%

File(s)

/srv/git/jellyfin/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs

#LineLine coverage
 1using System;
 2using System.Globalization;
 3using System.IO;
 4using System.Linq;
 5using System.Threading;
 6using System.Xml;
 7using Jellyfin.Data.Enums;
 8using MediaBrowser.Controller.Entities;
 9using MediaBrowser.Controller.Providers;
 10using MediaBrowser.Model.Entities;
 11using MediaBrowser.Model.Net;
 12using Microsoft.Extensions.Logging;
 13
 14namespace MediaBrowser.Providers.Books.OpenPackagingFormat
 15{
 16    /// <summary>
 17    /// Methods used to pull metadata and other information from Open Packaging Format in XML objects.
 18    /// </summary>
 19    /// <typeparam name="TCategoryName">The type of category.</typeparam>
 20    public class OpfReader<TCategoryName>
 21    {
 22        private const string DcNamespace = @"http://purl.org/dc/elements/1.1/";
 23        private const string OpfNamespace = @"http://www.idpf.org/2007/opf";
 24
 25        private readonly XmlNamespaceManager _namespaceManager;
 26        private readonly XmlDocument _document;
 27
 28        private readonly ILogger<TCategoryName> _logger;
 29
 30        /// <summary>
 31        /// Initializes a new instance of the <see cref="OpfReader{TCategoryName}"/> class.
 32        /// </summary>
 33        /// <param name="document">The XML document to parse.</param>
 34        /// <param name="logger">Instance of the <see cref="ILogger{TCategoryName}"/> interface.</param>
 35        public OpfReader(XmlDocument document, ILogger<TCategoryName> logger)
 36        {
 037            _document = document;
 038            _logger = logger;
 039            _namespaceManager = new XmlNamespaceManager(_document.NameTable);
 40
 041            _namespaceManager.AddNamespace("dc", DcNamespace);
 042            _namespaceManager.AddNamespace("opf", OpfNamespace);
 043        }
 44
 45        /// <summary>
 46        /// Checks for the existence of a cover image.
 47        /// </summary>
 48        /// <param name="opfRootDirectory">The root directory in which the OPF file is located.</param>
 49        /// <returns>Returns the found cover and its type or null.</returns>
 50        public (string MimeType, string Path)? ReadCoverPath(string opfRootDirectory)
 51        {
 052            var coverImage = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@properties='cover-image']");
 053            if (coverImage is not null)
 54            {
 055                return coverImage;
 56            }
 57
 058            var coverId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='cover' and @media-type='image/*']");
 059            if (coverId is not null)
 60            {
 061                return coverId;
 62            }
 63
 064            var coverImageId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='*cover-image']");
 065            if (coverImageId is not null)
 66            {
 067                return coverImageId;
 68            }
 69
 070            var metaCoverImage = _document.SelectSingleNode("//opf:meta[@name='cover']", _namespaceManager);
 071            var content = metaCoverImage?.Attributes?["content"]?.Value;
 072            if (string.IsNullOrEmpty(content) || metaCoverImage is null)
 73            {
 074                return null;
 75            }
 76
 077            var coverPath = Path.Combine("Images", content);
 078            var coverFileManifest = _document.SelectSingleNode($"//opf:item[@href='{coverPath}']", _namespaceManager);
 079            var mediaType = coverFileManifest?.Attributes?["media-type"]?.Value;
 080            if (coverFileManifest?.Attributes is not null && !string.IsNullOrEmpty(mediaType) && IsValidImage(mediaType)
 81            {
 082                return (mediaType, Path.Combine(opfRootDirectory, coverPath));
 83            }
 84
 085            var coverFileIdManifest = _document.SelectSingleNode($"//opf:item[@id='{content}']", _namespaceManager);
 086            if (coverFileIdManifest is not null)
 87            {
 088                return ReadManifestItem(coverFileIdManifest, opfRootDirectory);
 89            }
 90
 091            return null;
 92        }
 93
 94        /// <summary>
 95        /// Read all supported OPF data from the file.
 96        /// </summary>
 97        /// <param name="cancellationToken">The cancellation token.</param>
 98        /// <returns>The metadata result to update.</returns>
 99        public MetadataResult<Book> ReadOpfData(CancellationToken cancellationToken)
 100        {
 0101            cancellationToken.ThrowIfCancellationRequested();
 102
 0103            var book = CreateBookFromOpf();
 0104            var result = new MetadataResult<Book> { Item = book, HasMetadata = true };
 105
 0106            FindAuthors(result);
 0107            ReadStringInto("//dc:language", language => result.ResultLanguage = language);
 108
 0109            return result;
 110        }
 111
 112        private Book CreateBookFromOpf()
 113        {
 0114            var book = new Book
 0115            {
 0116                Name = FindMainTitle(),
 0117                ForcedSortName = FindSortTitle(),
 0118            };
 119
 0120            ReadStringInto("//dc:description", summary => book.Overview = summary);
 0121            ReadStringInto("//dc:publisher", publisher => book.AddStudio(publisher));
 0122            ReadStringInto("//dc:identifier[@opf:scheme='AMAZON']", amazon => book.SetProviderId("Amazon", amazon));
 0123            ReadStringInto("//dc:identifier[@opf:scheme='GOOGLE']", google => book.SetProviderId("GoogleBooks", google))
 0124            ReadStringInto("//dc:identifier[@opf:scheme='ISBN']", isbn => book.SetProviderId("ISBN", isbn));
 125
 0126            ReadStringInto("//dc:date", date =>
 0127            {
 0128                if (DateTime.TryParse(date, out var dateValue))
 0129                {
 0130                    book.PremiereDate = dateValue.Date;
 0131                    book.ProductionYear = dateValue.Date.Year;
 0132                }
 0133            });
 134
 0135            var genreNodes = _document.SelectNodes("//dc:subject", _namespaceManager);
 136
 0137            if (genreNodes?.Count > 0)
 138            {
 0139                foreach (var node in genreNodes.Cast<XmlNode>().Where(node => !string.IsNullOrEmpty(node.InnerText) && !
 140                {
 141                    // specification has no rules about content and some books combine every genre into a single element
 0142                    foreach (var item in node.InnerText.Split(["/", "&", ",", ";", " - "], StringSplitOptions.RemoveEmpt
 143                    {
 0144                        book.AddGenre(item);
 145                    }
 146                }
 147            }
 148
 0149            ReadInt32AttributeInto("//opf:meta[@name='calibre:series_index']", index => book.IndexNumber = index);
 0150            ReadInt32AttributeInto("//opf:meta[@name='calibre:rating']", rating => book.CommunityRating = rating);
 151
 0152            var seriesNameNode = _document.SelectSingleNode("//opf:meta[@name='calibre:series']", _namespaceManager);
 153
 0154            if (!string.IsNullOrEmpty(seriesNameNode?.Attributes?["content"]?.Value))
 155            {
 156                try
 157                {
 0158                    book.SeriesName = seriesNameNode.Attributes["content"]?.Value;
 0159                }
 0160                catch (Exception)
 161                {
 0162                    _logger.LogError("error parsing Calibre series name");
 0163                }
 164            }
 165
 0166            return book;
 167        }
 168
 169        private string FindMainTitle()
 170        {
 0171            var title = string.Empty;
 0172            var titleTypes = _document.SelectNodes("//opf:meta[@property='title-type']", _namespaceManager);
 173
 0174            if (titleTypes is not null && titleTypes.Count > 0)
 175            {
 0176                foreach (XmlElement titleNode in titleTypes)
 177                {
 0178                    string refines = titleNode.GetAttribute("refines").TrimStart('#');
 0179                    string titleType = titleNode.InnerText;
 180
 0181                    var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager);
 0182                    if (titleElement is not null && string.Equals(titleType, "main", StringComparison.OrdinalIgnoreCase)
 183                    {
 0184                        title = titleElement.InnerText;
 185                    }
 186                }
 187            }
 188
 189            // fallback in case there is no main title definition
 0190            if (string.IsNullOrEmpty(title))
 191            {
 0192                ReadStringInto("//dc:title", titleString => title = titleString);
 193            }
 194
 0195            return title;
 196        }
 197
 198        private string? FindSortTitle()
 199        {
 0200            var titleTypes = _document.SelectNodes("//opf:meta[@property='file-as']", _namespaceManager);
 201
 0202            if (titleTypes is not null && titleTypes.Count > 0)
 203            {
 0204                foreach (XmlElement titleNode in titleTypes)
 205                {
 0206                    string refines = titleNode.GetAttribute("refines").TrimStart('#');
 0207                    string sortTitle = titleNode.InnerText;
 208
 0209                    var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager);
 0210                    if (titleElement is not null)
 211                    {
 0212                        return sortTitle;
 213                    }
 214                }
 215            }
 216
 217            // search for OPF 2.0 style title_sort node
 0218            var resultElement = _document.SelectSingleNode("//opf:meta[@name='calibre:title_sort']", _namespaceManager);
 0219            var titleSort = resultElement?.Attributes?["content"]?.Value;
 220
 0221            return titleSort;
 0222        }
 223
 224        private void FindAuthors(MetadataResult<Book> book)
 225        {
 0226            var resultElement = _document.SelectNodes("//dc:creator", _namespaceManager);
 227
 0228            if (resultElement != null && resultElement.Count > 0)
 229            {
 0230                foreach (XmlElement creator in resultElement)
 231                {
 0232                    var creatorName = creator.InnerText;
 0233                    var role = creator.GetAttribute("opf:role");
 0234                    var person = new PersonInfo { Name = creatorName, Type = GetRole(role) };
 235
 0236                    book.AddPerson(person);
 237                }
 238            }
 0239        }
 240
 241        private PersonKind GetRole(string? role)
 242        {
 243            switch (role)
 244            {
 245                case "arr":
 0246                    return PersonKind.Arranger;
 247                case "art":
 0248                    return PersonKind.Artist;
 249                case "aut":
 250                case "aqt":
 251                case "aft":
 252                case "aui":
 253                default:
 0254                    return PersonKind.Author;
 255                case "edt":
 0256                    return PersonKind.Editor;
 257                case "ill":
 0258                    return PersonKind.Illustrator;
 259                case "lyr":
 0260                    return PersonKind.Lyricist;
 261                case "mus":
 0262                    return PersonKind.AlbumArtist;
 263                case "oth":
 0264                    return PersonKind.Unknown;
 265                case "trl":
 0266                    return PersonKind.Translator;
 267            }
 268        }
 269
 270        private void ReadStringInto(string xmlPath, Action<string> commitResult)
 271        {
 0272            var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
 0273            if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.InnerText))
 274            {
 0275                commitResult(resultElement.InnerText);
 276            }
 0277        }
 278
 279        private void ReadInt32AttributeInto(string xmlPath, Action<int> commitResult)
 280        {
 0281            var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
 0282            var resultValue = resultElement?.Attributes?["content"]?.Value;
 283
 0284            if (!string.IsNullOrEmpty(resultValue))
 285            {
 286                try
 287                {
 0288                    commitResult(Convert.ToInt32(Convert.ToDouble(resultValue, CultureInfo.InvariantCulture)));
 0289                }
 0290                catch (Exception e)
 291                {
 0292                    _logger.LogError(e, "error converting to Int32");
 0293                }
 294            }
 0295        }
 296
 297        private (string MimeType, string Path)? ReadEpubCoverInto(string opfRootDirectory, string xmlPath)
 298        {
 0299            var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
 300
 0301            if (resultElement is not null)
 302            {
 0303                return ReadManifestItem(resultElement, opfRootDirectory);
 304            }
 305
 0306            return null;
 307        }
 308
 309        private (string MimeType, string Path)? ReadManifestItem(XmlNode manifestNode, string opfRootDirectory)
 310        {
 0311            var href = manifestNode.Attributes?["href"]?.Value;
 0312            var mediaType = manifestNode.Attributes?["media-type"]?.Value;
 313
 0314            if (string.IsNullOrEmpty(href) || string.IsNullOrEmpty(mediaType) || !IsValidImage(mediaType))
 315            {
 0316                return null;
 317            }
 318
 0319            var coverPath = Path.Combine(opfRootDirectory, href);
 320
 0321            return (MimeType: mediaType, Path: coverPath);
 322        }
 323
 324        private static bool IsValidImage(string? mimeType)
 325        {
 0326            return !string.IsNullOrEmpty(mimeType) && !string.IsNullOrWhiteSpace(MimeTypes.ToExtension(mimeType));
 327        }
 328    }
 329}