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