commit f771d0cb8eb8d74710d11b1e0def5604f97acad0 Author: user01 Date: Sun Jun 28 17:22:25 2026 +0900 initialize repository diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c439bff --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +venv_mangainfo/ +Data/ +Temp/ +__pycache__/ +.venv*/ +.git/ +metadata_1.json +metadata_mi.json +main.spec diff --git a/ComicInfo.xsd b/ComicInfo.xsd new file mode 100644 index 0000000..ff50f3d --- /dev/null +++ b/ComicInfo.xsd @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UtilPack.py b/UtilPack.py new file mode 100644 index 0000000..bfc162d --- /dev/null +++ b/UtilPack.py @@ -0,0 +1,419 @@ +import os +import re +import time +import uuid +import zipfile +import shutil +import difflib +import subprocess +import hashlib + +try: + import rarfile +except ImportError: + rarfile = None + +from pathlib import Path + +m_dbgLevel = 0 +listDbgStr: list[str] = [] +verbose_mode = False # 전역 변수: 로그 출력 여부 + + +# +def IsEmptyStr(string: str) -> bool: + if None == string: + return True + temp = f"{string}" + return 0 == len(temp.strip()) + +# +def IsExistsPath(path: str) -> bool: + if True == IsEmptyStr(path): + return False + + return os.path.exists(path) + +# +def GetCurrentTime() -> str: + # 현재 시간을 구하고 구조체로 변환 + current_time_struct = time.localtime() + + # 구조체에서 연, 월, 일, 시간, 분, 초를 추출 + year = current_time_struct.tm_year + month = current_time_struct.tm_mon + day = current_time_struct.tm_mday + hour = current_time_struct.tm_hour + minute = current_time_struct.tm_min + second = current_time_struct.tm_sec + + strRet = (f"{year}-{month}-{day}_{hour}:{minute}:{second}") + + return strRet + + +# +def SetVerbose(mode: bool): + """로그 출력 모드 설정""" + global verbose_mode + verbose_mode = mode + + +# +def DbgOut(strInput:str, bPrint:bool = False): + strMsg = (f"{GetCurrentTime()} : {strInput}") + listDbgStr.append(strMsg) + + # bPrint가 True이거나 verbose_mode가 True이면 출력 + if bPrint or verbose_mode: + print(strMsg) + + +# +def printDbgMessages(): + for line in listDbgStr: + print(line) + + +# +def SaveDbgMessages(Path: str): + try: + with open(Path, 'w') as file: + for line in listDbgStr: + file.write(line + "\n") + except IOError: + DbgOut(f"Error: Could not write to the file at {Path}.", True) + + +# 입력된 경로의 자식 폴더를 찾아 반환한다. +# 반환하는 리스트는 리커시브 - 손자, 증손자 폴더까지 전부 포함한다 +def ListSubDirectories(root_dir:str)-> list[str]: + subdirectories: list[str] = [] + + # root_dir에서 하위 디렉토리 및 파일 목록을 얻음 + for dirpath, dirnames, filenames in os.walk(root_dir): + # 하위 디렉토리 목록을 반복하며 하위 디렉토리만 추출 + for dirname in dirnames: + path = os.path.join(dirpath, dirname) + + if True == IsFinalFolder(path): + subdirectories.append(path) + + return subdirectories + +# 자식 폴더를 구해온다. 직계 자식만 +def ListChildDirectories(pathDir:str, bVisibleOnly: bool = True) -> list[str]: + listRet:list[str] = [] + listTemp = os.listdir(pathDir) + for name in listTemp: + pathChild = os.path.join(pathDir, name) + if os.path.isdir(pathChild): + if True == bVisibleOnly and name.startswith('.'): + continue + + listRet.append(name) + + return listRet + +# PathDir 에 지정된 폴더의 파일목록만 구해온다. 자식 폴더에 있는건 무시. +def ListContainFiles(pathDir:str, bVisibleOnly:bool = True)-> list[str]: + listRet:list[str] = [] + listTemp = os.listdir(pathDir) + for name in listTemp: + pathChild = os.path.join(pathDir, name) + if os.path.isfile(pathChild): + if True == bVisibleOnly and name.startswith('.'): + continue + + listRet.append(name) + + return listRet + +# 입력된 경로에서 부모 폴더를 찾는다. 0 은 자기자신 1은 부모, 숫자대로 위로. +def GetParentDirName(FullPath : str, nUp : int)->str: + parts = FullPath.split(os.sep) + + nTrgIdx = 0 + if nUp < len(parts): + nTrgIdx = len(parts) -nUp -1 + elif nUp < 0: + nTrgIdx = len(parts) - 1 + else: + nTrgIdx = 0 + + return parts[nTrgIdx] + +# 입력된 경로가 자식 폴더를 가지고 있는지 판단한다.- 최종 폴더인지 여부 +# 자식이 없으면 True, 자식이 있으면 False +def IsFinalFolder(path : str) -> bool: + bRet = True + + contents = os.listdir(path) + for item in contents: + if True == os.path.isdir(os.path.join(path, item)): + bRet = False + break + + return bRet; + +# 리스트에 담긴 확장자와 같은 확장자를 가진 파일을 찾아서 리스트로 반환 +def ListFileExtRcr(pathTrg: str, listExt: list[str]) -> list[str]: + listRet:list[str] = [] + + # listExt에 .이 있으면 제거하고 소문자로 변환 + listExtLower = [] + for ext in listExt: + if ext.startswith('.'): + listExtLower.append(ext[1:].lower()) + else: + listExtLower.append(ext.lower()) + + # pathTrg의 하위 디렉토리 및 파일 목록을 얻음 + for dirpath, dirnames, filenames in os.walk(pathTrg): + for file in filenames: + extTmp = GetExtStr(file, bDot=False) # . 없이 확장자만 얻기 + if extTmp.lower() in listExtLower and not file.startswith('.'): + listRet.append(os.path.join(dirpath, file)) + + return listRet + +# 어떤 경로 안에서 특정 확장자의 파일을 뽑아내어 그 리스트를 반환한다. +def FindFileFromExt(path: str, ext: str)-> list[str]: + bDot = False + if 0 <= ext.find('.'): + bDot = True + + listRet:list[str] = [] + if False == os.path.exists(path): + return listRet + + contents = os.listdir(path) + for item in contents: + if True == os.path.isdir(item): + continue + + extItem = GetExtStr(item, bDot) + if extItem.lower() == ext.lower(): + listRet.append(item) + + return listRet + +# 파일 이름에서 확장자를 뽑아낸다. True : '.' 을 포함한다. +def GetExtStr(file_path: str, bDot: bool = True)-> str: + retStr = "" + # 파일 경로에서 마지막 점을 찾아 확장자를 추출 + last_dot_index = file_path.rfind('.') + if last_dot_index == -1: + retStr = "" # 점이 없는 경우 확장자가 없음 + else: + if True == bDot: + retStr = file_path[last_dot_index:] + else: + retStr = file_path[last_dot_index+1:] + + return retStr + +# 문자열에 포함된 단어를 지운다. +def RmvSubString(mainString: str, subString: str)-> str: + # 문자열에서 부분 문자열의 인덱스를 찾습니다. + strIdx = mainString.find(subString) + if strIdx == -1: # 부분 문자열이 존재하지 않으면 그대로 반환합니다. + return mainString + + endIdx = strIdx + len(subString) + + # 부분 문자열을 제거하고 새로운 문자열을 반환합니다. + return mainString[:strIdx] + mainString[endIdx:] + +# +def ExtractZIP(zip_file: str, extract_to: str): + with zipfile.ZipFile(zip_file, 'r') as zf: + zf.extractall(extract_to) + +# +def CreateZIP(output_zip: str, files: list[str]) -> bool: + with zipfile.ZipFile(output_zip, 'w') as zf: + for file in files: + pathTemp = os.path.join('root', os.path.basename(file)) + zf.write(file, pathTemp) + + bRet = False + if os.path.exists(output_zip): + bRet = True + + return bRet + +# 파일 리스트에 들어있는 파일만 골라서 압축을 합니다. 상대경로를 제거하는게 기본값. +def CreateZIPShell(zipName: str, files: list[str], bRmvRPath: bool = True) -> bool: + command = "zip " + + if True == bRmvRPath: + command += "-j " + + command += f"\"{zipName}\" " + + # 이중 리스트인 이유를 모르겠다. + for file in files: + command += f"\"{file}\" " + + result = subprocess.run(command, shell=True, capture_output=True, text=True) + + bRet = False + if 0 == result.returncode: + bRet = True + + return bRet + +# 특정 확장자만 쉘을 이용해서 압축한다 +def CreateZIPShExt(zipName: str, TrgExt: str)-> bool: + command = f"zip -j {zipName} *.{TrgExt}" + + result = subprocess.run(command, shell=True, capture_output=True, text=True) + + bRet = False + if 0 == result.returncode: + bRet = True + + return bRet + +# 압축 파일 내의 모든 파일 및 디렉토리 목록 가져오기 +def GetZipContentList(path: str) -> list[str]: + if True == IsEmptyStr(path) or not os.path.isfile(path): + return [] + + listRet = [] + with zipfile.ZipFile( path , 'r') as zip_file: + listRet = zip_file.namelist() + + return listRet + +# +def GetZippedFileByte(pathZip: str, FileName: str) -> bytes: + retBytes:bytes = bytes() + if True == os.path.isfile(pathZip): + with zipfile.ZipFile( pathZip , 'r') as zip_file: + # 압축 파일 내의 특정 파일을 읽기 + with zip_file.open(FileName) as file: + retBytes = file.read() + + return retBytes + +# ZIP 파일에 파일을 추가하거나 업데이트합니다. +def AddFileToZip(zip_path: str, file_name: str, file_data: bytes) -> bool: + """ + ZIP 파일에 파일을 추가하거나 업데이트합니다. + + Args: + zip_path: ZIP 파일 경로 + file_name: 추가할 파일 이름 + file_data: 파일 데이터 (바이트) + + Returns: + 성공 여부 + """ + try: + import tempfile + import os + import shutil + + # 임시 파일 생성 (원본과 같은 디렉토리에) + temp_zip = zip_path + '.tmp' + + with zipfile.ZipFile(zip_path, 'r') as zin: + with zipfile.ZipFile(temp_zip, 'w') as zout: + # 기존 파일들 복사 (업데이트할 파일 제외) + for item in zin.namelist(): + if item != file_name: + data = zin.read(item) + zout.writestr(item, data) + + # 새 파일 추가 + zout.writestr(file_name, file_data) + + # 원본 파일 교체 (shutil.move 사용) + shutil.move(temp_zip, zip_path) + + return True + + except Exception as e: + DbgOut(f"ZIP 파일 업데이트 오류: {e}", True) + # 임시 파일 삭제 시도 + if os.path.exists(temp_zip): + os.remove(temp_zip) + return False + +# JSON 을 트리 구조로 출력한다. +def PrintJSONTree(data, indent: int=0 ) -> None: + if isinstance(data, dict): + for key, value in data.items(): + print(' ' * indent + str(key)) + PrintJSONTree(value, indent + 1) + elif isinstance(data, list): + for item in data: + PrintJSONTree(item, indent) + else: + print(' ' * indent + str(data)) + +# +def IsPathWithin(base_path: str, target_path: str) -> bool: + base = Path(base_path).resolve() + target = Path(target_path).resolve() + return target.is_relative_to(base) + +# 랜덤 UUID 생성 +def UUIDGenRandom(): + random_uuid = uuid.uuid4() + return random_uuid + +# 이름을 기반으로 UUID 생성 +def UUIDGenName(SeedName:str): + namespace_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, SeedName) + return namespace_uuid + +# +def GetTextInBrakets(text:str)-> list[str]: + return re.findall(r'\[(.*?)\]', text) + +#파일의 해시를 계산하는 함수. +#Args: +# strFilePath (str): 파일 경로 +# method (str): 사용할 해시 알고리즘 ('md5', 'sha1', 'sha256' 등) +#Returns: +# str: 계산된 해시값 (16진수 문자열) +def CalculateFileHash(strFilePath: str, method: str="sha256")-> str: + funcHash = getattr(hashlib, method)() + + with open(strFilePath, "rb") as f: + while True: + chunk = f.read(4096) + if not chunk: + break + + funcHash.update(chunk) + + return funcHash.hexdigest() + +#파일의 해시를 비교하여 무결성 검증. +#Args: +# file_path (str): 파일 경로 +# expected_hash (str): 기대하는 해시값 +# method (str): 사용할 해시 알고리즘 +#Returns: +# bool: 파일이 정상인지 여부 +def VerifyIsValidFile(strPath: str, strCompHash: str, strMethod: str="sha256")->bool: + Hash_Calcd = CalculateFileHash(strPath, strMethod) + + return strCompHash.lower() == Hash_Calcd.lower() + +""" +# 사용 예시 +if __name__ == "__main__": + file_to_check = "example.txt" + known_good_hash = "5d41402abc4b2a76b9719d911017c592" # 예시 (MD5) + + is_valid = verify_file(file_to_check, known_good_hash, method='md5') + if is_valid: + print("파일이 정상입니다!") + else: + print("파일이 손상되었거나 다릅니다!") +""" \ No newline at end of file diff --git a/comicinfoxml.py b/comicinfoxml.py new file mode 100644 index 0000000..1617cb6 --- /dev/null +++ b/comicinfoxml.py @@ -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 diff --git a/main.py b/main.py new file mode 100755 index 0000000..add149b --- /dev/null +++ b/main.py @@ -0,0 +1,767 @@ +#!/usr/bin/env python3 +import os +import sys +import time +import argparse +import xml.etree.ElementTree as ET +from io import BytesIO +import re +import json + +import UtilPack as util + +# ComicInfo.xsd에서 파싱한 default 값 딕셔너리 +DEFAULT_VALUES = { + 'Count': '-1', + 'Volume': '', + 'Number': '', + 'Series': '', + 'Title': '', + 'Writer': '', + 'LanguageISO': '', + 'Manga': 'Yes', + 'Pages': None, # 특수 처리 +} + + +# +def main(argc, argv): + parser = argparse.ArgumentParser(description="ComicInfo.XML 생성기") + parser.add_argument('--root', dest='root_dir', required=False, help='입력 폴더 지정') + parser.add_argument('--in', dest='arc_file', required=False, help='대상 파일 지정') + parser.add_argument('--title', dest='info_title', required=False, help='제목') + parser.add_argument('--author', dest='info_writer', required=False, help='작가') + parser.add_argument('--series', dest='info_series', required=False, help='시리즈') + parser.add_argument('--tags', dest='info_tags', required=False, help='태그 (쉼표로 구분)') + parser.add_argument('--volume', dest='info_volume', required=False, help='권/화 묶음 번호 (예: 3)') + parser.add_argument('--number', dest='info_number', required=False, help='개별 권/화 번호 (예: 1)') + parser.add_argument('--count', dest='info_count', required=False, help='시리즈 총 권수/화수 (기본값: -1)') + parser.add_argument('--print', dest='bPrint', action='store_true', help='comicinfo.xml 출력') + parser.add_argument('--metadata', dest='bMetadata', action='store_true', help='.metadata 파일로부터 ComicInfo.xml 생성 및 CBZ에 추가') + parser.add_argument('--verbose', dest='bVerbose', action='store_true', help='상세 로그 출력') + parser.add_argument('-v', '--version', action='version', version='ComicInfoXMLConv 1.0') + + args = parser.parse_args(argv[1:]) # argv[0]은 스크립트 이름 제외 + + # verbose 모드 설정 + util.SetVerbose(args.bVerbose) + + if True == args.bVerbose: + util.DbgOut("환영합니다! ComicInfo.XML 생성기를 시작합니다.", True) + + # 태그 파싱 + if args.info_tags is not None and len(args.info_tags) > 0: + util.DbgOut(f"입력된 태그: {args.info_tags}", True) + listTags = args.info_tags.split(',') + util.DbgOut(f"태그 리스트: {listTags}", True) + + # 단일 파일 처리 (--in 옵션) + if True == util.IsExistsPath(args.arc_file): + util.DbgOut(f"대상 파일: {args.arc_file}", True) + + # Path 변수 확장 + arc_title = ExpandPathVars(args.info_title, args.arc_file) + arc_writer = ExpandPathVars(args.info_writer, args.arc_file) + arc_series = ExpandPathVars(args.info_series, args.arc_file) + arc_volume = ExpandPathVars(args.info_volume, args.arc_file) + arc_number = ExpandPathVars(args.info_number, args.arc_file) + arc_tags = args.info_tags.split(',') if args.info_tags else [] + + # .metadata 옵션이 활성화된 경우 + if True == args.bMetadata: + ProcessMetadataToCBZ(args.arc_file, args.bPrint, + arc_volume, arc_number, args.info_count, + arc_title, arc_writer, arc_series, + arc_tags if arc_tags else None) + return + + listArcContents = util.GetZipContentList(args.arc_file) + + if True == any(item.lower() == "comicinfo.xml" for item in listArcContents): + util.DbgOut(f"comicinfo.xml 발견됨: {args.arc_file}", True) + + comicinfo_xml_bytes = util.GetZippedFileByte(args.arc_file, "ComicInfo.xml") + if None != comicinfo_xml_bytes: + modified = ModComicInfoXML(comicinfo_xml_bytes, arc_title, arc_writer, arc_series, arc_tags, + info_number=arc_number, info_volume=arc_volume, info_count=args.info_count) + + if True == args.bPrint: + if modified is not None: + print(modified.decode('utf-8')) + else: + print(comicinfo_xml_bytes.decode('utf-8')) + elif modified is not None: + util.AddFileToZip(args.arc_file, "ComicInfo.xml", modified) + util.DbgOut(f"ComicInfo.xml 업데이트됨: {args.arc_file}", True) + + else: + util.DbgOut(f"comicinfo.xml 없음: {args.arc_file}", True) + if arc_volume or arc_number or arc_title or arc_writer or arc_series or arc_tags or args.info_count: + new_xml = CreateComicInfoXML( + info_title=arc_title if arc_title else "", + info_writer=arc_writer if arc_writer else "", + info_series=arc_series if arc_series else "", + tags=arc_tags if arc_tags else [], + pages=[], + volume=arc_volume if arc_volume else "", + number=arc_number if arc_number else "", + count=int(args.info_count) if args.info_count is not None else -1 + ) + if new_xml: + if True == args.bPrint: + print(new_xml) + else: + util.AddFileToZip(args.arc_file, "ComicInfo.xml", new_xml.encode('utf-8')) + util.DbgOut(f"ComicInfo.xml 생성됨: {args.arc_file}", True) + + return + + # 폴더 처리 (--root 옵션) - 하위 폴더 포함, CBZ 파일만 처리 + if True == util.IsExistsPath(args.root_dir): + util.DbgOut(f"입력 폴더: {args.root_dir}", True) + # CBZ 확장자 파일만 리커시브로 검색 + listArcFiles = util.ListFileExtRcr(args.root_dir, ['cbz']) + + for strFullPath in listArcFiles: + if False == os.path.exists(strFullPath): + continue + + util.DbgOut(f"파일 발견: {strFullPath}", True) + + # Path 변수 확장 (파일마다 달라짐) + arc_title = ExpandPathVars(args.info_title, strFullPath) + arc_writer = ExpandPathVars(args.info_writer, strFullPath) + arc_series = ExpandPathVars(args.info_series, strFullPath) + arc_volume = ExpandPathVars(args.info_volume, strFullPath) + arc_number = ExpandPathVars(args.info_number, strFullPath) + arc_tags = args.info_tags.split(',') if args.info_tags else [] + + # .metadata 옵션이 활성화된 경우 + if True == args.bMetadata: + ProcessMetadataToCBZ(strFullPath, args.bPrint, + arc_volume, arc_number, args.info_count, + arc_title, arc_writer, arc_series, + arc_tags if arc_tags else None) + continue + + listArcContents = util.GetZipContentList(strFullPath) + + if True == any(item.lower() == "comicinfo.xml" for item in listArcContents): + util.DbgOut(f"comicinfo.xml 발견됨: {strFullPath}", True) + + comicinfo_xml_bytes = util.GetZippedFileByte(strFullPath, "ComicInfo.xml") + if None != comicinfo_xml_bytes: + modified = ModComicInfoXML(comicinfo_xml_bytes, arc_title, arc_writer, arc_series, arc_tags, + info_number=arc_number, info_volume=arc_volume, info_count=args.info_count) + + if True == args.bPrint: + if modified is not None: + print(modified.decode('utf-8')) + else: + print(comicinfo_xml_bytes.decode('utf-8')) + elif modified is not None: + util.AddFileToZip(strFullPath, "ComicInfo.xml", modified) + util.DbgOut(f"ComicInfo.xml 업데이트됨: {strFullPath}", True) + + else: + util.DbgOut(f"comicinfo.xml 없음: {strFullPath}", True) + if arc_volume or arc_number or arc_title or arc_writer or arc_series or arc_tags or args.info_count: + new_xml = CreateComicInfoXML( + info_title=arc_title if arc_title else "", + info_writer=arc_writer if arc_writer else "", + info_series=arc_series if arc_series else "", + tags=arc_tags if arc_tags else [], + pages=[], + volume=arc_volume if arc_volume else "", + number=arc_number if arc_number else "", + count=int(args.info_count) if args.info_count is not None else -1 + ) + if new_xml: + if True == args.bPrint: + print(new_xml) + else: + util.AddFileToZip(strFullPath, "ComicInfo.xml", new_xml.encode('utf-8')) + util.DbgOut(f"ComicInfo.xml 생성됨: {strFullPath}", True) + + + return + +# +def ExpandPathVars(text: str | None, filepath: str) -> str | None: + """Replace path template variables (, , etc.) with actual values. + After variable expansion, if the result has a :s/pattern/replacement/ suffix, + the regex is applied to the expanded text. + """ + if not text: + return text + dirpath = os.path.dirname(filepath) + cur_folder = os.path.basename(dirpath) if dirpath else "" + par_folder = os.path.basename(os.path.dirname(dirpath)) if dirpath and os.path.dirname(dirpath) else "" + filename_no_ext = os.path.splitext(os.path.basename(filepath))[0] + ext = os.path.splitext(filepath)[1] + + var_map = { + "filename": filename_no_ext, + "parentfolder": cur_folder, + "currentfolder": cur_folder, + "parentfolder": par_folder, + "fullpath": filepath, + "ext": ext, + } + + result = text + + # Plain variable replacement + for key, value in var_map.items(): + result = result.replace(f"<{key}>", value) + + # After variable expansion, check for :s/pattern/replacement/ suffix + m = re.match(r'^(.*):s(.)(.+?)\2(.*?)(?:\2([ig]*))?$', result, re.DOTALL) + if m: + source = m.group(1) + delim = m.group(2) + pattern = m.group(3) + replacement = m.group(4) + flags_str = m.group(5) or '' + replacement_safe = re.sub(r'\$(\d+)', r'\\\1', replacement) + flags = 0 + if 'i' in flags_str: + flags |= re.IGNORECASE + count = 0 if 'g' in flags_str else 1 + try: + result = re.sub(pattern, replacement_safe, source, count=count, flags=flags) + except re.error: + pass + + return result + + +def ResolveRegexTransform(text: str | None, current_value: str | None) -> str | None: + """Apply regex substitution if text uses s/pattern/replacement/ syntax. + Returns the transformed value, or the original text if no regex pattern detected.""" + if not text or current_value is None: + return text + m = re.match(r'^s(.)(.+?)\1(.*?)(?:\1([ig]*))?$', text, re.DOTALL) + if m: + pattern = m.group(2) + replacement = m.group(3) + replacement = re.sub(r'\$(\d+)', r'\\\1', replacement) + flags_str = m.group(4) or '' + flags = 0 + if 'i' in flags_str: + flags |= re.IGNORECASE + count = 0 if 'g' in flags_str else 1 + try: + return re.sub(pattern, replacement, current_value, count=count, flags=flags) + except re.error: + return text + return text + + +def ParsePathForComicInfo(strFullPath: str) -> dict: + """ + 파일 경로에서 작가, 제목, 번호를 추출합니다. + 예: /some/path/[Kim] Some Comic/blablabla_01.cbz + 반환: {'author': 'Kim', 'title': 'Some Comic', 'number': '01'} + + Args: + strFullPath: 파일 전체 경로 + + Returns: + {'author': str, 'title': str, 'number': str} 딕셔너리 + """ + result = { + 'author': None, + 'title': None, + 'number': None + } + + try: + # 경로를 / 로 분리 + path_parts = strFullPath.replace('\\', '/').split('/') + + # [] 가 포함된 부분 찾기 + folder_with_bracket = None + for part in path_parts: + if '[' in part and ']' in part: + folder_with_bracket = part + break + + # [] 로 분리해서 작가와 제목 추출 + if folder_with_bracket: + # [작가] 제목 형식으로 분리 + match = re.match(r'\[([^\]]+)\]\s*(.+)', folder_with_bracket) + if match: + result['author'] = match.group(1).strip() + result['title'] = match.group(2).strip() + + # 파일명에서 번호 추출 (마지막 _ 다음 숫자) + filename = path_parts[-1] # 파일명 (blablabla_01.cbz) + + # _숫자 형식 찾기 + number_match = re.search(r'_(\d+)', filename) + if number_match: + result['number'] = number_match.group(1) + + return result + + except Exception as e: + util.DbgOut(f"경로 파싱 오류: {e}", True) + return result + +# +def GetComicInfoXMLfromARC(strArcPath) -> bytes: + # zip 파일에서 comicinfo.xml을 추출하는 로직을 여기에 구현 + # 예시로, zip 파일에서 comicinfo.xml을 읽어와서 bytes 형태로 반환하는 과정을 생각할 수 있습니다. + listContents = util.GetZipContentList(strArcPath) + + for item in listContents: + if item.lower() == "comicinfo.xml": + return util.GetZippedFileByte(strArcPath, item) + + return None + +# +def ModComicInfoXMLFile(strXMLPath, info_title, info_writer, info_series, tags: list) -> bool: + if False == util.IsExistsPath(strXMLPath): + util.DbgOut(f"comicinfo.xml 파일이 존재하지 않습니다: {strXMLPath}", True) + return False + + with open(strXMLPath, "rb") as f: + xml_bytes = f.read() + + modified = ModComicInfoXML(xml_bytes, info_title, info_writer, info_series, tags) + if modified is not None: + with open(strXMLPath, "wb") as f: + f.write(modified) + return True + return False + +# +def ModComicInfoXML(pBytes, info_title, info_writer, info_series, tags: list, + info_number=None, info_volume=None, info_count=None) -> bytes | None: + try: + root = ET.fromstring(pBytes) + + cur_title = root.findtext('Title') if root.find('Title') is not None else "" + cur_writer = root.findtext('Writer') if root.find('Writer') is not None else "" + cur_series = root.findtext('Series') if root.find('Series') is not None else "" + + info_title = ResolveRegexTransform(info_title, cur_title) + info_writer = ResolveRegexTransform(info_writer, cur_writer) + info_series = ResolveRegexTransform(info_series, cur_series) + + if info_title is not None and info_title != "": + title_elem = root.find('Title') + if title_elem is None: + title_elem = ET.SubElement(root, 'Title') + title_elem.text = info_title + + if info_writer is not None and info_writer != "": + writer_elem = root.find('Writer') + if writer_elem is None: + writer_elem = ET.SubElement(root, 'Writer') + writer_elem.text = info_writer + + if info_series is not None and info_series != "": + series_elem = root.find('Series') + if series_elem is None: + series_elem = ET.SubElement(root, 'Series') + series_elem.text = info_series + + if tags is not None and len(tags) > 0: + tags_elem = root.find('Tags') + if tags_elem is not None: + root.remove(tags_elem) + tags_elem = ET.SubElement(root, 'Tags') + tags_elem.text = ','.join(tags) + + if info_number is not None and info_number != "" and info_number != "-1": + number_elem = root.find('Number') + if number_elem is None: + number_elem = ET.SubElement(root, 'Number') + number_elem.text = str(info_number) + + if info_volume is not None and info_volume != "" and info_volume != "-1": + volume_elem = root.find('Volume') + if volume_elem is None: + volume_elem = ET.SubElement(root, 'Volume') + volume_elem.text = str(info_volume) + + if info_count is not None and info_count != -1: + count_elem = root.find('Count') + if count_elem is None: + count_elem = ET.SubElement(root, 'Count') + count_elem.text = str(info_count) + + output = BytesIO() + tree = ET.ElementTree(root) + tree.write(output, encoding='utf-8', xml_declaration=True) + + modified = output.getvalue() + if modified != pBytes: + return modified + return None + + except ET.ParseError as e: + util.DbgOut(f"XML 파싱 오류: {e}", True) + return None + except Exception as e: + util.DbgOut(f"ComicInfo.xml 수정 중 오류 발생: {e}", True) + return None + +# +def ConvertLanguageToISO(lang_name: str) -> str: + """ + 언어 이름을 ISO 639-1 코드로 변환합니다. + + Args: + lang_name: 언어 이름 (예: "korean", "japanese", "english") + + Returns: + ISO 639-1 코드 (예: "ko", "ja", "en") + """ + lang_map = { + 'korean': 'ko', + 'japanese': 'ja', + 'english': 'en', + 'chinese': 'zh', + 'french': 'fr', + 'german': 'de', + 'spanish': 'es', + 'italian': 'it', + 'russian': 'ru', + 'arabic': 'ar', + 'portuguese': 'pt', + 'korea': 'ko', # 별칭 + 'japan': 'ja', # 별칭 + } + + if lang_name: + return lang_map.get(lang_name.lower(), lang_name) + return "" + +# +def ProcessMetadataToCBZ(strArcPath: str, bPrint: bool = False, + volume: str = "-1", number: str = "", count: int = -1, + info_title: str = None, info_writer: str = None, + info_series: str = None, tags: list[str] = None) -> bool: + """ + CBZ 파일의 .metadata를 읽어 ComicInfo.xml을 생성하고 CBZ에 추가합니다. + info_title/info_writer/info_series에 s/pattern/replacement/ 문법을 사용하면 + .metadata의 원본 값에 정규식 치환을 적용합니다. + + Args: + strArcPath: CBZ 파일 경로 + bPrint: ComicInfo.xml 내용을 출력할지 여부 + volume: 권/화 묶음 번호 (예: 3) + number: 개별 권/화 번호 (예: 1) + count: 시리즈 총 권수/화수 + info_title: 제목 오버라이드 (또는 s/// 정규식) + info_writer: 작가 오버라이드 (또는 s/// 정규식) + info_series: 시리즈 오버라이드 (또는 s/// 정규식) + tags: 태그 리스트 오버라이드 + + Returns: + 성공 여부 + """ + try: + util.DbgOut(f".metadata 처리 중: {strArcPath}", True) + + xml_content = MetadataToComicInfoXML(strArcPath, volume=volume, number=number, count=count) + + if not xml_content: + util.DbgOut(f"ComicInfo.xml 생성 실패: {strArcPath}", True) + return False + + # XML 파싱해서 추가 필드 삽입 + try: + root = ET.fromstring(xml_content) + + # 현재 값 읽기 (regex 치환용) + cur_title = root.findtext('Title') if root.find('Title') is not None else "" + cur_writer = root.findtext('Writer') if root.find('Writer') is not None else "" + cur_series = root.findtext('Series') if root.find('Series') is not None else "" + + # Regex 치환 적용 + info_title = ResolveRegexTransform(info_title, cur_title) + info_writer = ResolveRegexTransform(info_writer, cur_writer) + info_series = ResolveRegexTransform(info_series, cur_series) + + # Title 오버라이드 + if info_title is not None and info_title != "": + title_elem = root.find('Title') + if title_elem is None: + title_elem = ET.SubElement(root, 'Title') + title_elem.text = info_title + + # Writer 오버라이드 + if info_writer is not None and info_writer != "": + writer_elem = root.find('Writer') + if writer_elem is None: + writer_elem = ET.SubElement(root, 'Writer') + writer_elem.text = info_writer + + # Series 오버라이드 + if info_series is not None and info_series != "": + series_elem = root.find('Series') + if series_elem is None: + series_elem = ET.SubElement(root, 'Series') + series_elem.text = info_series + + # Tags 오버라이드 + if tags is not None and len(tags) > 0: + tags_elem = root.find('Tags') + if tags_elem is not None: + root.remove(tags_elem) + tags_elem = ET.SubElement(root, 'Tags') + tags_elem.text = ','.join(tags) + + # Volume 추가 + if volume is not None and volume != "-1" and volume != "": + volume_elem = root.find('Volume') + if volume_elem is None: + volume_elem = ET.SubElement(root, 'Volume') + volume_elem.text = str(volume) + + # Number 추가 + if number is not None and number != "" and number != "-1": + number_elem = root.find('Number') + if number_elem is None: + number_elem = ET.SubElement(root, 'Number') + number_elem.text = str(number) + + # Count 추가 + if count is not None and count != -1: + count_elem = root.find('Count') + if count_elem is None: + count_elem = ET.SubElement(root, 'Count') + count_elem.text = str(count) + + # XML을 문자열로 변환 + tree = ET.ElementTree(root) + output = BytesIO() + tree.write(output, encoding='utf-8', xml_declaration=True) + xml_content = output.getvalue().decode('utf-8') + except Exception as e: + util.DbgOut(f"XML 수정 중 오류: {e}", True) + + # 출력 옵션이 켜져 있으면 내용 출력 + if True == bPrint: + print(xml_content) + + # XML을 바이트로 변환하여 CBZ에 추가 + xml_bytes = xml_content.encode('utf-8') + + if True == util.AddFileToZip(strArcPath, "ComicInfo.xml", xml_bytes): + util.DbgOut(f"ComicInfo.xml이 CBZ에 추가됨: {strArcPath}", True) + return True + else: + util.DbgOut(f"CBZ에 ComicInfo.xml 추가 실패: {strArcPath}", True) + return False + + except Exception as e: + util.DbgOut(f".metadata 처리 중 오류 발생: {e}", True) + return False + +# +def MetadataToComicInfoXML(strArcPath: str, volume: str = "", number: str = "", count: int = -1) -> str: + try: + listContents = util.GetZipContentList(strArcPath) + metadata_file = None + + for item in listContents: + if item.lower() == ".metadata": + metadata_file = item + break + + if not metadata_file: + util.DbgOut(f".metadata 파일을 찾을 수 없습니다: {strArcPath}", True) + return "" + + metadata_bytes = util.GetZippedFileByte(strArcPath, metadata_file) + if not metadata_bytes: + util.DbgOut(f".metadata 파일을 읽을 수 없습니다: {strArcPath}", True) + return "" + + metadata_json = json.loads(metadata_bytes.decode('utf-8')) + + info_title = "" + info_writer = "" + info_series = "" + info_language = "" + tags = [] + characters = "" + teams = "" + pages = [] + + gallery_block = metadata_json.get('galleryBlock', {}) + info_title = gallery_block.get('title', '') + + artists = gallery_block.get('artists', []) + if artists: + info_writer = ', '.join(artists) + + series = gallery_block.get('series', []) + if series: + info_series = series[0] + + lang_name = gallery_block.get('language', '') + info_language = ConvertLanguageToISO(lang_name) + + related_tags = gallery_block.get('relatedTags', []) + if related_tags: + tags = related_tags + + groups_block = gallery_block.get('groups', []) + if groups_block and isinstance(groups_block, list): + teams = ','.join([str(g) for g in groups_block if g]) + + gallery_info = metadata_json.get('galleryInfo', {}) + + chars_info = gallery_info.get('characters', []) + if chars_info and isinstance(chars_info, list): + char_names = [c.get('character', '') for c in chars_info if isinstance(c, dict)] + characters = ','.join([c for c in char_names if c]) + + groups_info = gallery_info.get('groups', []) + if groups_info and isinstance(groups_info, list): + group_names = [g.get('group', '') for g in groups_info if isinstance(g, dict)] + teams = ','.join([g for g in group_names if g]) + + files = gallery_info.get('files', []) + for image_index, file_info in enumerate(files): + page_data = { + 'Image': image_index + } + pages.append(page_data) + + return CreateComicInfoXML(info_title, info_writer, info_series, tags, pages, info_language, characters, teams, volume, number, count) + + except json.JSONDecodeError as e: + util.DbgOut(f"JSON 파싱 오류: {e}", True) + return "" + except Exception as e: + util.DbgOut(f".metadata 변환 중 오류 발생: {e}", True) + return "" + +# +def CreateComicInfoXML(info_title, info_writer, info_series, tags: list, pages: list, language: str = "", characters: str = "", teams: str = "", volume: str = "", number: str = "", count: int = -1) -> str: + """ + 새로운 ComicInfo.xml을 생성합니다. + + Args: + info_title: 제목 + info_writer: 작가 + info_series: 시리즈 + tags: 태그 리스트 + pages: 페이지 정보 리스트 (dict 또는 객체) + language: 언어 정보 (LanguageISO) + + Returns: + ComicInfo.xml의 XML 문자열 + """ + try: + # 루트 엘리먼트 생성 + root = ET.Element('ComicInfo') + + # Title 추가 + if info_title is not None and info_title != "": + title_elem = ET.SubElement(root, 'Title') + title_elem.text = info_title + + # Series 추가 + if info_series is not None and info_series != "": + series_elem = ET.SubElement(root, 'Series') + series_elem.text = info_series + + # Writer 추가 + if info_writer is not None and info_writer != "": + writer_elem = ET.SubElement(root, 'Writer') + writer_elem.text = info_writer + + # Tags 추가 (쉼표로 구분) + if tags is not None and len(tags) > 0: + # 빈 문자열 제거 + filtered_tags = [tag.strip() for tag in tags if tag and tag.strip()] + if filtered_tags: + tags_elem = ET.SubElement(root, 'Tags') + tags_elem.text = ','.join(filtered_tags) + + # Characters 추가 + if characters is not None and characters != "": + char_elem = ET.SubElement(root, 'Characters') + char_elem.text = characters + + # Teams 추가 (groups -> Teams) + if teams is not None and teams != "": + teams_elem = ET.SubElement(root, 'Teams') + teams_elem.text = teams + + # LanguageISO 추가 + if language is not None and language != "": + language_elem = ET.SubElement(root, 'LanguageISO') + language_elem.text = language + + # Manga 항목 추가 (만화 유형 - Yes/No/YesAndRightToLeft) + manga_elem = ET.SubElement(root, 'Manga') + manga_elem.text = "Yes" + + # Volume 추가 (기본값 -1) + if volume is not None and volume != "": + volume_elem = ET.SubElement(root, 'Volume') + volume_elem.text = str(volume) + + # Number 추가 (개별 권/화 번호) + if number is not None and number != "" and number != "-1": + number_elem = ET.SubElement(root, 'Number') + number_elem.text = str(number) + + # Count 추가 (기본값 -1) + if count is not None: + count_elem = ET.SubElement(root, 'Count') + count_elem.text = str(count) + + # Pages 추가 + if pages is not None and len(pages) > 0: + pages_elem = ET.SubElement(root, 'Pages') + for page in pages: + page_elem = ET.SubElement(pages_elem, 'Page') + + # Type 속성 추가 (고정값: Story) + page_elem.set('Type', 'Story') + + # Image 속성 추가 + if isinstance(page, dict): + if 'Image' in page and page['Image'] is not None: + page_elem.set('Image', str(page['Image'])) + else: + # page가 객체인 경우, Image 속성만 추출 + if hasattr(page, 'Image'): + page_elem.set('Image', str(page.Image)) + + # XML을 문자열로 변환 + tree = ET.ElementTree(root) + output = BytesIO() + tree.write(output, encoding='utf-8', xml_declaration=True) + + return output.getvalue().decode('utf-8') + + except Exception as e: + util.DbgOut(f"ComicInfo.xml 생성 중 오류 발생: {e}", True) + return "" + + + +# For Main Loop +if __name__ == '__main__': + argc = len(sys.argv) + argv = sys.argv + main(argc, argv) + +# +def printHowto(): + print("Usage: python main.py [options]") + print("Options:") + print(" -h, --help Show help message.") + print(" -v, --version Show version.") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f5c5f0a --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +rarfile>=4.0