initialize repository
This commit is contained in:
408
comicinfoxml.py
Normal file
408
comicinfoxml.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""A class to encapsulate ComicRack's ComicInfo.xml data."""
|
||||
# Copyright 2012-2014 ComicTagger Authors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from comicapi import utils
|
||||
from comicapi.genericmetadata import FileHash
|
||||
from comicapi.genericmetadata import GenericMetadata
|
||||
from comicapi.genericmetadata import PageMetadata
|
||||
from comicapi.tags import Tag
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from comicapi.archivers import Archiver
|
||||
|
||||
logger = logging.getLogger(f'comicapi.metadata.{__name__}')
|
||||
|
||||
|
||||
class ComicInfoXml(Tag):
|
||||
enabled = True
|
||||
|
||||
id = 'cix'
|
||||
|
||||
def __init__(self, version: str) -> None:
|
||||
super().__init__(version)
|
||||
|
||||
self.file = 'ComicInfo.xml'
|
||||
self.supported_attributes = {
|
||||
'original_hash',
|
||||
'series',
|
||||
'issue',
|
||||
'issue_count',
|
||||
'title',
|
||||
'gtin',
|
||||
'volume',
|
||||
'genres',
|
||||
'description',
|
||||
'notes',
|
||||
'alternate_series',
|
||||
'alternate_number',
|
||||
'alternate_count',
|
||||
'story_arcs',
|
||||
'series_groups',
|
||||
'publisher',
|
||||
'imprint',
|
||||
'day',
|
||||
'month',
|
||||
'year',
|
||||
'language',
|
||||
'web_links',
|
||||
'format',
|
||||
'manga',
|
||||
'black_and_white',
|
||||
'maturity_rating',
|
||||
'critical_rating',
|
||||
'scan_info',
|
||||
'tags',
|
||||
'pages',
|
||||
'pages.bookmark',
|
||||
'pages.double_page',
|
||||
'pages.height',
|
||||
'pages.image_index',
|
||||
'pages.size',
|
||||
'pages.type',
|
||||
'pages.width',
|
||||
'page_count',
|
||||
'characters',
|
||||
'teams',
|
||||
'locations',
|
||||
'credits',
|
||||
'credits.person',
|
||||
'credits.role',
|
||||
}
|
||||
|
||||
def supports_credit_role(self, role: str) -> bool:
|
||||
return role.casefold() in self._get_parseable_credits()
|
||||
|
||||
def supports_tags(self, archive: Archiver) -> bool:
|
||||
return archive.supports_files()
|
||||
|
||||
def has_tags(self, archive: Archiver) -> bool:
|
||||
return (
|
||||
self.supports_tags(archive)
|
||||
and self.file in archive.get_filename_list()
|
||||
and self._validate_bytes(archive.read_file(self.file))
|
||||
)
|
||||
|
||||
def remove_tags(self, archive: Archiver) -> bool:
|
||||
return self.has_tags(archive) and archive.remove_file(self.file)
|
||||
|
||||
def read_tags(self, archive: Archiver) -> GenericMetadata:
|
||||
if self.has_tags(archive):
|
||||
metadata = archive.read_file(self.file) or b''
|
||||
if self._validate_bytes(metadata):
|
||||
return self._metadata_from_bytes(metadata)
|
||||
return GenericMetadata()
|
||||
|
||||
def read_raw_tags(self, archive: Archiver) -> str:
|
||||
if self.has_tags(archive):
|
||||
return ET.tostring(ET.fromstring(archive.read_file(self.file)), encoding='unicode', xml_declaration=True)
|
||||
return ''
|
||||
|
||||
def write_tags(self, metadata: GenericMetadata, archive: Archiver) -> bool:
|
||||
if self.supports_tags(archive):
|
||||
xml = b''
|
||||
if self.has_tags(archive):
|
||||
xml = archive.read_file(self.file)
|
||||
return archive.write_file(self.file, self._bytes_from_metadata(metadata, xml))
|
||||
logger.warning('Archive (%s) does not support %s metadata', archive.name(), self.name())
|
||||
return False
|
||||
|
||||
def name(self) -> str:
|
||||
return 'Comic Info XML'
|
||||
|
||||
@classmethod
|
||||
def _get_parseable_credits(cls) -> list[str]:
|
||||
parsable_credits: list[str] = []
|
||||
parsable_credits.extend(GenericMetadata.writer_synonyms)
|
||||
parsable_credits.extend(GenericMetadata.penciller_synonyms)
|
||||
parsable_credits.extend(GenericMetadata.inker_synonyms)
|
||||
parsable_credits.extend(GenericMetadata.colorist_synonyms)
|
||||
parsable_credits.extend(GenericMetadata.letterer_synonyms)
|
||||
parsable_credits.extend(GenericMetadata.cover_synonyms)
|
||||
parsable_credits.extend(GenericMetadata.editor_synonyms)
|
||||
parsable_credits.extend(GenericMetadata.translator_synonyms)
|
||||
return parsable_credits
|
||||
|
||||
def _metadata_from_bytes(self, string: bytes) -> GenericMetadata:
|
||||
root = ET.fromstring(string)
|
||||
return self._convert_xml_to_metadata(root)
|
||||
|
||||
def _bytes_from_metadata(self, metadata: GenericMetadata, xml: bytes = b'') -> bytes:
|
||||
root = self._convert_metadata_to_xml(metadata, xml)
|
||||
return ET.tostring(root, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
def _convert_metadata_to_xml(self, metadata: GenericMetadata, xml: bytes = b'') -> ET.Element:
|
||||
# shorthand for the metadata
|
||||
md = metadata
|
||||
|
||||
if xml:
|
||||
root = ET.fromstring(xml)
|
||||
else:
|
||||
# build a tree structure
|
||||
root = ET.Element('ComicInfo')
|
||||
root.attrib['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance'
|
||||
root.attrib['xmlns:xsd'] = 'http://www.w3.org/2001/XMLSchema'
|
||||
# helper func
|
||||
|
||||
def assign(cr_entry: str, md_entry: Any) -> None:
|
||||
if md_entry:
|
||||
text = str(md_entry)
|
||||
if isinstance(md_entry, (list, set)):
|
||||
text = ','.join(md_entry)
|
||||
et_entry = root.find(cr_entry)
|
||||
if et_entry is not None:
|
||||
et_entry.text = text
|
||||
else:
|
||||
ET.SubElement(root, cr_entry).text = text
|
||||
else:
|
||||
et_entry = root.find(cr_entry)
|
||||
if et_entry is not None:
|
||||
root.remove(et_entry)
|
||||
|
||||
# need to specially process the credits, since they are structured
|
||||
# differently than CIX
|
||||
credit_writer_list = []
|
||||
credit_penciller_list = []
|
||||
credit_inker_list = []
|
||||
credit_colorist_list = []
|
||||
credit_letterer_list = []
|
||||
credit_cover_list = []
|
||||
credit_editor_list = []
|
||||
credit_translator_list = []
|
||||
|
||||
# first, loop thru credits, and build a list for each role that CIX
|
||||
# supports
|
||||
for credit in metadata.credits:
|
||||
if credit.role.casefold() in GenericMetadata.writer_synonyms:
|
||||
credit_writer_list.append(credit.person.replace(',', ''))
|
||||
|
||||
if credit.role.casefold() in GenericMetadata.penciller_synonyms:
|
||||
credit_penciller_list.append(credit.person.replace(',', ''))
|
||||
|
||||
if credit.role.casefold() in GenericMetadata.inker_synonyms:
|
||||
credit_inker_list.append(credit.person.replace(',', ''))
|
||||
|
||||
if credit.role.casefold() in GenericMetadata.colorist_synonyms:
|
||||
credit_colorist_list.append(credit.person.replace(',', ''))
|
||||
|
||||
if credit.role.casefold() in GenericMetadata.letterer_synonyms:
|
||||
credit_letterer_list.append(credit.person.replace(',', ''))
|
||||
|
||||
if credit.role.casefold() in GenericMetadata.cover_synonyms:
|
||||
credit_cover_list.append(credit.person.replace(',', ''))
|
||||
|
||||
if credit.role.casefold() in GenericMetadata.editor_synonyms:
|
||||
credit_editor_list.append(credit.person.replace(',', ''))
|
||||
|
||||
if credit.role.casefold() in GenericMetadata.translator_synonyms:
|
||||
credit_translator_list.append(credit.person.replace(',', ''))
|
||||
|
||||
assign('Series', md.series)
|
||||
assign('Number', md.issue)
|
||||
assign('Count', md.issue_count)
|
||||
assign('Title', md.title)
|
||||
assign('GTIN', md.gtin)
|
||||
assign('Volume', md.volume)
|
||||
assign('Genre', md.genres)
|
||||
assign('Summary', md.description)
|
||||
assign('Notes', md.notes)
|
||||
|
||||
assign('AlternateSeries', md.alternate_series)
|
||||
assign('AlternateNumber', md.alternate_number)
|
||||
assign('AlternateCount', md.alternate_count)
|
||||
assign('StoryArc', md.story_arcs)
|
||||
assign('SeriesGroup', md.series_groups)
|
||||
|
||||
assign('Publisher', md.publisher)
|
||||
assign('Imprint', md.imprint)
|
||||
assign('Day', md.day)
|
||||
assign('Month', md.month)
|
||||
assign('Year', md.year)
|
||||
assign('LanguageISO', md.language)
|
||||
assign('Web', ' '.join(u.url for u in md.web_links))
|
||||
assign('Format', md.format)
|
||||
assign('Manga', md.manga)
|
||||
assign('BlackAndWhite', 'Yes' if md.black_and_white else None)
|
||||
assign('AgeRating', md.maturity_rating)
|
||||
assign('CommunityRating', md.critical_rating)
|
||||
|
||||
scan_info = md.scan_info or ''
|
||||
if md.original_hash:
|
||||
scan_info += f" sum:{md.original_hash}"
|
||||
assign('ScanInformation', scan_info)
|
||||
|
||||
assign('Tags', md.tags)
|
||||
assign('PageCount', md.page_count)
|
||||
|
||||
assign('Characters', md.characters)
|
||||
assign('Teams', md.teams)
|
||||
assign('Locations', md.locations)
|
||||
assign('Writer', credit_writer_list)
|
||||
assign('Penciller', credit_penciller_list)
|
||||
assign('Inker', credit_inker_list)
|
||||
assign('Colorist', credit_colorist_list)
|
||||
assign('Letterer', credit_letterer_list)
|
||||
assign('CoverArtist', credit_cover_list)
|
||||
assign('Editor', credit_editor_list)
|
||||
assign('Translator', credit_translator_list)
|
||||
|
||||
# loop and add the page entries under pages node
|
||||
pages_node = root.find('Pages')
|
||||
if pages_node is not None:
|
||||
pages_node.clear()
|
||||
else:
|
||||
pages_node = ET.SubElement(root, 'Pages')
|
||||
|
||||
for page in md.pages:
|
||||
page_node = ET.SubElement(pages_node, 'Page')
|
||||
page_node.attrib = {'Image': str(page.display_index)}
|
||||
if page.bookmark:
|
||||
page_node.attrib['Bookmark'] = page.bookmark
|
||||
if page.type:
|
||||
page_node.attrib['Type'] = page.type
|
||||
|
||||
if page.double_page is not None:
|
||||
page_node.attrib['DoublePage'] = str(page.double_page)
|
||||
if page.height is not None:
|
||||
page_node.attrib['ImageHeight'] = str(page.height)
|
||||
if page.byte_size is not None:
|
||||
page_node.attrib['ImageSize'] = str(page.byte_size)
|
||||
if page.width is not None:
|
||||
page_node.attrib['ImageWidth'] = str(page.width)
|
||||
page_node.attrib = dict(sorted(page_node.attrib.items()))
|
||||
|
||||
ET.indent(root)
|
||||
|
||||
return root
|
||||
|
||||
def _convert_xml_to_metadata(self, root: ET.Element) -> GenericMetadata:
|
||||
if root.tag != 'ComicInfo':
|
||||
raise Exception('Not a ComicInfo file')
|
||||
|
||||
def get(name: str) -> str | None:
|
||||
tag = root.find(name)
|
||||
if tag is None:
|
||||
return None
|
||||
return tag.text
|
||||
|
||||
md = GenericMetadata()
|
||||
|
||||
md.series = utils.xlate(get('Series'))
|
||||
md.issue = utils.xlate(get('Number'))
|
||||
md.issue_count = utils.xlate_int(get('Count'))
|
||||
md.title = utils.xlate(get('Title'))
|
||||
md.gtin = utils.xlate(get('GTIN'))
|
||||
md.volume = utils.xlate_int(get('Volume'))
|
||||
md.genres = set(utils.split(get('Genre'), ','))
|
||||
md.description = utils.xlate(get('Summary'))
|
||||
md.notes = utils.xlate(get('Notes'))
|
||||
|
||||
md.alternate_series = utils.xlate(get('AlternateSeries'))
|
||||
md.alternate_number = utils.xlate(get('AlternateNumber'))
|
||||
md.alternate_count = utils.xlate_int(get('AlternateCount'))
|
||||
md.story_arcs = utils.split(get('StoryArc'), ',')
|
||||
md.series_groups = utils.split(get('SeriesGroup'), ',')
|
||||
|
||||
md.publisher = utils.xlate(get('Publisher'))
|
||||
md.imprint = utils.xlate(get('Imprint'))
|
||||
md.day = utils.xlate_int(get('Day'))
|
||||
md.month = utils.xlate_int(get('Month'))
|
||||
md.year = utils.xlate_int(get('Year'))
|
||||
md.language = utils.xlate(get('LanguageISO'))
|
||||
md.web_links = utils.split_urls(utils.xlate(get('Web')))
|
||||
md.format = utils.xlate(get('Format'))
|
||||
md.manga = utils.xlate(get('Manga'))
|
||||
md.maturity_rating = utils.xlate(get('AgeRating'))
|
||||
md.critical_rating = utils.xlate_float(get('CommunityRating'))
|
||||
|
||||
scan_info_list = (utils.xlate(get('ScanInformation')) or '').split()
|
||||
for word in scan_info_list.copy():
|
||||
if not word.startswith('sum:'):
|
||||
|
||||
continue
|
||||
|
||||
original_hash = FileHash.parse(word[4:])
|
||||
if original_hash:
|
||||
md.original_hash = original_hash
|
||||
scan_info_list.remove(word)
|
||||
break
|
||||
if scan_info_list:
|
||||
md.scan_info = ' '.join(scan_info_list)
|
||||
|
||||
md.tags = set(utils.split(get('Tags'), ','))
|
||||
md.page_count = utils.xlate_int(get('PageCount'))
|
||||
|
||||
md.characters = set(utils.split(get('Characters'), ','))
|
||||
md.teams = set(utils.split(get('Teams'), ','))
|
||||
md.locations = set(utils.split(get('Locations'), ','))
|
||||
|
||||
tmp = utils.xlate(get('BlackAndWhite'))
|
||||
if tmp is not None:
|
||||
md.black_and_white = tmp.casefold() in {'yes', 'true', '1'}
|
||||
|
||||
# Now extract the credit info
|
||||
for n in root:
|
||||
if n.tag in {'Writer', 'Penciller', 'Inker', 'Colorist', 'Letterer', 'Editor'} and n.text is not None:
|
||||
for name in utils.split(n.text, ','):
|
||||
md.add_credit(name.strip(), n.tag)
|
||||
|
||||
if n.tag == 'CoverArtist' and n.text is not None:
|
||||
for name in utils.split(n.text, ','):
|
||||
md.add_credit(name.strip(), 'Cover')
|
||||
|
||||
# parse page data now
|
||||
pages_node = root.find('Pages')
|
||||
if pages_node is not None:
|
||||
for i, page in enumerate(pages_node):
|
||||
p: dict[str, Any] = page.attrib
|
||||
md_page = PageMetadata(
|
||||
filename='', # cix doesn't record the filename it just assumes it's always ordered the same
|
||||
display_index=int(p.get('Image', i)),
|
||||
archive_index=i,
|
||||
bookmark=p.get('Bookmark', ''),
|
||||
type='',
|
||||
)
|
||||
md_page.set_type(p.get('Type', ''))
|
||||
|
||||
if isinstance(p.get('DoublePage', None), str):
|
||||
md_page.double_page = p['DoublePage'].casefold() in ('yes', 'true', '1')
|
||||
if p.get('ImageHeight', '').isnumeric():
|
||||
md_page.height = int(float(p['ImageHeight']))
|
||||
if p.get('ImageWidth', '').isnumeric():
|
||||
md_page.width = int(float(p['ImageWidth']))
|
||||
if p.get('ImageSize', '').isnumeric():
|
||||
md_page.byte_size = int(float(p['ImageSize']))
|
||||
|
||||
md.pages.append(md_page)
|
||||
|
||||
md.is_empty = False
|
||||
|
||||
return md
|
||||
|
||||
def _validate_bytes(self, string: bytes) -> bool:
|
||||
"""Verify that the string actually contains CIX data in XML format."""
|
||||
try:
|
||||
root = ET.fromstring(string)
|
||||
if root.tag != 'ComicInfo':
|
||||
return False
|
||||
except ET.ParseError:
|
||||
return False
|
||||
|
||||
return True
|
||||
Reference in New Issue
Block a user