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