< 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: 143
Coverable lines: 143
Total lines: 347
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 178
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 4/15/2026 - 12:14:34 AM Line coverage: 0% (0/132) Branch coverage: 0% (0/160) Total lines: 3295/4/2026 - 12:15:16 AM Line coverage: 0% (0/133) Branch coverage: 0% (0/174) Total lines: 3317/21/2026 - 12:16:33 AM Line coverage: 0% (0/143) Branch coverage: 0% (0/178) Total lines: 347 4/15/2026 - 12:14:34 AM Line coverage: 0% (0/132) Branch coverage: 0% (0/160) Total lines: 3295/4/2026 - 12:15:16 AM Line coverage: 0% (0/133) Branch coverage: 0% (0/174) Total lines: 3317/21/2026 - 12:16:33 AM Line coverage: 0% (0/143) Branch coverage: 0% (0/178) Total lines: 347

Coverage delta

Coverage delta 1 -1

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%110100%
GetRole(...)0%3660600%
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.Text.RegularExpressions;
 6using System.Threading;
 7using System.Xml;
 8using Jellyfin.Data.Enums;
 9using MediaBrowser.Controller.Entities;
 10using MediaBrowser.Controller.Providers;
 11using MediaBrowser.Model.Entities;
 12using MediaBrowser.Model.Net;
 13using Microsoft.Extensions.Logging;
 14
 15namespace MediaBrowser.Providers.Books.OpenPackagingFormat
 16{
 17    /// <summary>
 18    /// Methods used to pull metadata and other information from Open Packaging Format in XML objects.
 19    /// </summary>
 20    /// <typeparam name="TCategoryName">The type of category.</typeparam>
 21    public partial class OpfReader<TCategoryName>
 22    {
 23        private const string DcNamespace = @"http://purl.org/dc/elements/1.1/";
 24        private const string OpfNamespace = @"http://www.idpf.org/2007/opf";
 25
 26        private readonly XmlNamespaceManager _namespaceManager;
 27        private readonly XmlDocument _document;
 28
 29        private readonly ILogger<TCategoryName> _logger;
 30
 31        /// <summary>
 32        /// Initializes a new instance of the <see cref="OpfReader{TCategoryName}"/> class.
 33        /// </summary>
 34        /// <param name="document">The XML document to parse.</param>
 35        /// <param name="logger">Instance of the <see cref="ILogger{TCategoryName}"/> interface.</param>
 36        public OpfReader(XmlDocument document, ILogger<TCategoryName> logger)
 37        {
 038            _document = document;
 039            _logger = logger;
 040            _namespaceManager = new XmlNamespaceManager(_document.NameTable);
 41
 042            _namespaceManager.AddNamespace("dc", DcNamespace);
 043            _namespaceManager.AddNamespace("opf", OpfNamespace);
 044        }
 45
 46        [GeneratedRegex(@"(?<=\p{L})\.(?!\s|$)")]
 47        private static partial Regex InitialsRegex();
 48
 49        /// <summary>
 50        /// Checks for the existence of a cover image.
 51        /// </summary>
 52        /// <param name="opfRootDirectory">The root directory in which the OPF file is located.</param>
 53        /// <returns>Returns the found cover and its type or null.</returns>
 54        public (string MimeType, string Path)? ReadCoverPath(string opfRootDirectory)
 55        {
 056            var coverImage = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@properties='cover-image']");
 057            if (coverImage is not null)
 58            {
 059                return coverImage;
 60            }
 61
 062            var coverId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='cover' and @media-type='image/*']");
 063            if (coverId is not null)
 64            {
 065                return coverId;
 66            }
 67
 068            var coverImageId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='*cover-image']");
 069            if (coverImageId is not null)
 70            {
 071                return coverImageId;
 72            }
 73
 074            var metaCoverImage = _document.SelectSingleNode("//opf:meta[@name='cover']", _namespaceManager);
 075            var content = metaCoverImage?.Attributes?["content"]?.Value;
 076            if (string.IsNullOrEmpty(content) || metaCoverImage is null)
 77            {
 078                return null;
 79            }
 80
 081            var coverPath = Path.Combine("Images", content);
 082            var coverFileManifest = _document.SelectSingleNode($"//opf:item[@href='{coverPath}']", _namespaceManager);
 083            var mediaType = coverFileManifest?.Attributes?["media-type"]?.Value;
 084            if (coverFileManifest?.Attributes is not null && !string.IsNullOrEmpty(mediaType) && IsValidImage(mediaType)
 85            {
 086                return (mediaType, Path.Combine(opfRootDirectory, coverPath));
 87            }
 88
 089            var coverFileIdManifest = _document.SelectSingleNode($"//opf:item[@id='{content}']", _namespaceManager);
 090            if (coverFileIdManifest is not null)
 91            {
 092                return ReadManifestItem(coverFileIdManifest, opfRootDirectory);
 93            }
 94
 095            return null;
 96        }
 97
 98        /// <summary>
 99        /// Read all supported OPF data from the file.
 100        /// </summary>
 101        /// <param name="cancellationToken">The cancellation token.</param>
 102        /// <returns>The metadata result to update.</returns>
 103        public MetadataResult<Book> ReadOpfData(CancellationToken cancellationToken)
 104        {
 0105            cancellationToken.ThrowIfCancellationRequested();
 106
 0107            var book = CreateBookFromOpf();
 0108            var result = new MetadataResult<Book> { Item = book, HasMetadata = true };
 109
 0110            FindAuthors(result);
 0111            ReadStringInto("//dc:language", language => result.ResultLanguage = language);
 112
 0113            return result;
 114        }
 115
 116        private Book CreateBookFromOpf()
 117        {
 0118            var book = new Book
 0119            {
 0120                Name = FindMainTitle(),
 0121                ForcedSortName = FindSortTitle(),
 0122            };
 123
 0124            ReadStringInto("//dc:description", summary => book.Overview = summary);
 0125            ReadStringInto("//dc:publisher", publisher => book.AddStudio(publisher));
 0126            ReadStringInto("//dc:identifier[@opf:scheme='AMAZON']", amazon => book.SetProviderId("Amazon", amazon));
 0127            ReadStringInto("//dc:identifier[@opf:scheme='GOOGLE']", google => book.SetProviderId("GoogleBooks", google))
 0128            ReadStringInto("//dc:identifier[@opf:scheme='ISBN']", isbn => book.SetProviderId("ISBN", isbn));
 129
 0130            ReadStringInto("//dc:date", date =>
 0131            {
 0132                if (DateTime.TryParse(date, CultureInfo.InvariantCulture, out var dateValue))
 0133                {
 0134                    book.PremiereDate = dateValue.Date;
 0135                    book.ProductionYear = dateValue.Date.Year;
 0136                }
 0137            });
 138
 0139            var genreNodes = _document.SelectNodes("//dc:subject", _namespaceManager);
 140
 0141            if (genreNodes?.Count > 0)
 142            {
 0143                foreach (var node in genreNodes.Cast<XmlNode>().Where(node => !string.IsNullOrEmpty(node.InnerText) && !
 144                {
 145                    // specification has no rules about content and some books combine every genre into a single element
 0146                    foreach (var item in node.InnerText.Split(["/", "&", ",", ";", " - "], StringSplitOptions.RemoveEmpt
 147                    {
 0148                        book.AddGenre(item);
 149                    }
 150                }
 151            }
 152
 0153            ReadInt32AttributeInto("//opf:meta[@name='calibre:series_index']", index => book.IndexNumber = index);
 0154            ReadInt32AttributeInto("//opf:meta[@name='calibre:rating']", rating => book.CommunityRating = rating);
 155
 0156            var seriesNameNode = _document.SelectSingleNode("//opf:meta[@name='calibre:series']", _namespaceManager);
 157
 0158            if (!string.IsNullOrEmpty(seriesNameNode?.Attributes?["content"]?.Value))
 159            {
 160                try
 161                {
 0162                    book.SeriesName = seriesNameNode.Attributes["content"]?.Value;
 0163                }
 0164                catch (Exception)
 165                {
 0166                    _logger.LogError("error parsing Calibre series name");
 0167                }
 168            }
 169
 0170            return book;
 171        }
 172
 173        private string FindMainTitle()
 174        {
 0175            var title = string.Empty;
 0176            var titleTypes = _document.SelectNodes("//opf:meta[@property='title-type']", _namespaceManager);
 177
 0178            if (titleTypes is not null && titleTypes.Count > 0)
 179            {
 0180                foreach (XmlElement titleNode in titleTypes)
 181                {
 0182                    string refines = titleNode.GetAttribute("refines").TrimStart('#');
 0183                    string titleType = titleNode.InnerText;
 184
 0185                    var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager);
 0186                    if (titleElement is not null && string.Equals(titleType, "main", StringComparison.OrdinalIgnoreCase)
 187                    {
 0188                        title = titleElement.InnerText;
 189                    }
 190                }
 191            }
 192
 193            // fallback in case there is no main title definition
 0194            if (string.IsNullOrEmpty(title))
 195            {
 0196                ReadStringInto("//dc:title", titleString => title = titleString);
 197            }
 198
 0199            return title;
 200        }
 201
 202        private string? FindSortTitle()
 203        {
 0204            var titleTypes = _document.SelectNodes("//opf:meta[@property='file-as']", _namespaceManager);
 205
 0206            if (titleTypes is not null && titleTypes.Count > 0)
 207            {
 0208                foreach (XmlElement titleNode in titleTypes)
 209                {
 0210                    string refines = titleNode.GetAttribute("refines").TrimStart('#');
 0211                    string sortTitle = titleNode.InnerText;
 212
 0213                    var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager);
 0214                    if (titleElement is not null)
 215                    {
 0216                        return sortTitle;
 217                    }
 218                }
 219            }
 220
 221            // search for OPF 2.0 style title_sort node
 0222            var resultElement = _document.SelectSingleNode("//opf:meta[@name='calibre:title_sort']", _namespaceManager);
 0223            var titleSort = resultElement?.Attributes?["content"]?.Value;
 224
 0225            return titleSort;
 0226        }
 227
 228        private void FindAuthors(MetadataResult<Book> book)
 229        {
 0230            var resultElement = _document.SelectNodes("//dc:creator", _namespaceManager);
 231
 0232            if (resultElement != null && resultElement.Count > 0)
 233            {
 0234                foreach (XmlElement creator in resultElement)
 235                {
 0236                    var role = creator.GetAttribute("opf:role");
 0237                    var normalizedCreators = creator.InnerText
 0238                        .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 0239                        .Select(fullName =>
 0240                        {
 0241                            if (fullName.Split(',', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEn
 0242                            {
 0243                                fullName = $"{firstName} {lastName}";
 0244                            }
 0245
 0246                            return InitialsRegex().Replace(fullName, ". ");
 0247                        });
 248
 0249                    foreach (var fullName in normalizedCreators)
 250                    {
 0251                        book.AddPerson(new PersonInfo { Name = fullName, Type = GetRole(role) });
 252                    }
 253                }
 254            }
 0255        }
 256
 257        private PersonKind GetRole(string? role)
 258        {
 259            switch (role)
 260            {
 261                case "arr":
 0262                    return PersonKind.Arranger;
 263                case "art":
 0264                    return PersonKind.Artist;
 265                case "aut":
 266                case "aqt":
 267                case "aft":
 268                case "aui":
 269                default:
 0270                    return PersonKind.Author;
 271                case "edt":
 0272                    return PersonKind.Editor;
 273                case "ill":
 0274                    return PersonKind.Illustrator;
 275                case "lyr":
 0276                    return PersonKind.Lyricist;
 277                case "mus":
 0278                    return PersonKind.AlbumArtist;
 279                case "nrt":
 0280                    return PersonKind.Narrator;
 281                case "oth":
 0282                    return PersonKind.Unknown;
 283                case "trl":
 0284                    return PersonKind.Translator;
 285            }
 286        }
 287
 288        private void ReadStringInto(string xmlPath, Action<string> commitResult)
 289        {
 0290            var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
 0291            if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.InnerText))
 292            {
 0293                commitResult(resultElement.InnerText);
 294            }
 0295        }
 296
 297        private void ReadInt32AttributeInto(string xmlPath, Action<int> commitResult)
 298        {
 0299            var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
 0300            var resultValue = resultElement?.Attributes?["content"]?.Value;
 301
 0302            if (!string.IsNullOrEmpty(resultValue))
 303            {
 304                try
 305                {
 0306                    commitResult(Convert.ToInt32(Convert.ToDouble(resultValue, CultureInfo.InvariantCulture)));
 0307                }
 0308                catch (Exception e)
 309                {
 0310                    _logger.LogError(e, "error converting to Int32");
 0311                }
 312            }
 0313        }
 314
 315        private (string MimeType, string Path)? ReadEpubCoverInto(string opfRootDirectory, string xmlPath)
 316        {
 0317            var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
 318
 0319            if (resultElement is not null)
 320            {
 0321                return ReadManifestItem(resultElement, opfRootDirectory);
 322            }
 323
 0324            return null;
 325        }
 326
 327        private (string MimeType, string Path)? ReadManifestItem(XmlNode manifestNode, string opfRootDirectory)
 328        {
 0329            var href = manifestNode.Attributes?["href"]?.Value;
 0330            var mediaType = manifestNode.Attributes?["media-type"]?.Value;
 331
 0332            if (string.IsNullOrEmpty(href) || string.IsNullOrEmpty(mediaType) || !IsValidImage(mediaType))
 333            {
 0334                return null;
 335            }
 336
 0337            var coverPath = Path.Combine(opfRootDirectory, href);
 338
 0339            return (MimeType: mediaType, Path: coverPath);
 340        }
 341
 342        private static bool IsValidImage(string? mimeType)
 343        {
 0344            return !string.IsNullOrEmpty(mimeType) && !string.IsNullOrWhiteSpace(MimeTypes.ToExtension(mimeType));
 345        }
 346    }
 347}