initialize repository

This commit is contained in:
2026-06-28 17:22:25 +09:00
commit f771d0cb8e
6 changed files with 1731 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
venv_mangainfo/
Data/
Temp/
__pycache__/
.venv*/
.git/
metadata_1.json
metadata_mi.json
main.spec

127
ComicInfo.xsd Normal file
View File

@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="ComicInfo" nillable="true" type="ComicInfo"/>
<xs:complexType name="ComicInfo">
<xs:sequence>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Title" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Series" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Number" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Count" type="xs:int"/>
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Volume" type="xs:int"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="AlternateSeries" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="AlternateNumber" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="AlternateCount" type="xs:int"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Summary" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Notes" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Year" type="xs:int"/>
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Month" type="xs:int"/>
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Day" type="xs:int"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Writer" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Penciller" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Inker" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Colorist" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Letterer" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="CoverArtist" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Editor" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Translator" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Publisher" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Imprint" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Genre" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Tags" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Web" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="0" name="PageCount" type="xs:int"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="LanguageISO" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Format" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="Unknown" name="BlackAndWhite" type="YesNo"/>
<xs:element minOccurs="0" maxOccurs="1" default="Unknown" name="Manga" type="Manga"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Characters" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Teams" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Locations" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="ScanInformation" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="StoryArc" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="StoryArcNumber" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="SeriesGroup" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="Unknown" name="AgeRating" type="AgeRating"/>
<xs:element minOccurs="0" maxOccurs="1" name="Pages" type="ArrayOfComicPageInfo"/>
<xs:element minOccurs="0" maxOccurs="1" name="CommunityRating" type="Rating"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="MainCharacterOrTeam" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Review" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" default="" name="GTIN" type="xs:string"/>
</xs:sequence>
</xs:complexType>
<xs:simpleType name="YesNo">
<xs:restriction base="xs:string">
<xs:enumeration value="Unknown"/>
<xs:enumeration value="No"/>
<xs:enumeration value="Yes"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Manga">
<xs:restriction base="xs:string">
<xs:enumeration value="Unknown"/>
<xs:enumeration value="No"/>
<xs:enumeration value="Yes"/>
<xs:enumeration value="YesAndRightToLeft"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Rating">
<xs:restriction base="xs:decimal">
<xs:minInclusive value="0"/>
<xs:maxInclusive value="5"/>
<xs:fractionDigits value="1"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AgeRating">
<xs:restriction base="xs:string">
<xs:enumeration value="Unknown"/>
<xs:enumeration value="Adults Only 18+"/>
<xs:enumeration value="Early Childhood"/>
<xs:enumeration value="Everyone"/>
<xs:enumeration value="Everyone 10+"/>
<xs:enumeration value="G"/>
<xs:enumeration value="Kids to Adults"/>
<xs:enumeration value="M"/>
<xs:enumeration value="MA15+"/>
<xs:enumeration value="Mature 17+"/>
<xs:enumeration value="PG"/>
<xs:enumeration value="R18+"/>
<xs:enumeration value="Rating Pending"/>
<xs:enumeration value="Teen"/>
<xs:enumeration value="X18+"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="ArrayOfComicPageInfo">
<xs:sequence>
<xs:element minOccurs="0" maxOccurs="unbounded" name="Page" nillable="true" type="ComicPageInfo"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ComicPageInfo">
<xs:attribute name="Image" type="xs:int" use="required"/>
<xs:attribute default="Story" name="Type" type="ComicPageType"/>
<xs:attribute default="false" name="DoublePage" type="xs:boolean"/>
<xs:attribute default="0" name="ImageSize" type="xs:long"/>
<xs:attribute default="" name="Key" type="xs:string"/>
<xs:attribute default="" name="Bookmark" type="xs:string"/>
<xs:attribute default="-1" name="ImageWidth" type="xs:int"/>
<xs:attribute default="-1" name="ImageHeight" type="xs:int"/>
</xs:complexType>
<xs:simpleType name="ComicPageType">
<xs:list>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="FrontCover"/>
<xs:enumeration value="InnerCover"/>
<xs:enumeration value="Roundup"/>
<xs:enumeration value="Story"/>
<xs:enumeration value="Advertisement"/>
<xs:enumeration value="Editorial"/>
<xs:enumeration value="Letters"/>
<xs:enumeration value="Preview"/>
<xs:enumeration value="BackCover"/>
<xs:enumeration value="Other"/>
<xs:enumeration value="Deleted"/>
</xs:restriction>
</xs:simpleType>
</xs:list>
</xs:simpleType>
</xs:schema>

419
UtilPack.py Normal file
View File

@@ -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("파일이 손상되었거나 다릅니다!")
"""

408
comicinfoxml.py Normal file
View 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

767
main.py Executable file
View File

@@ -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 (<filename>, <parentfolder>, 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.")

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
rarfile>=4.0