Initial Upload
This commit is contained in:
4
Package.ini
Normal file
4
Package.ini
Normal file
@@ -0,0 +1,4 @@
|
||||
Name=CompleteMetadata
|
||||
Author=Frederik Baerentsen
|
||||
Version=1.0
|
||||
Description=General metadata completion and synchronization between ComicRack and CBZ/CBR files.
|
||||
231
Package.py
Normal file
231
Package.py
Normal file
@@ -0,0 +1,231 @@
|
||||
import clr
|
||||
clr.AddReference("System.Windows.Forms")
|
||||
clr.AddReference("System")
|
||||
clr.AddReference("System.Web.Extensions")
|
||||
from System.Windows.Forms import MessageBox
|
||||
from System.Diagnostics import Process, ProcessStartInfo
|
||||
from System.Web.Script.Serialization import JavaScriptSerializer
|
||||
from System.IO import Path, File
|
||||
from System.Text import UTF8Encoding
|
||||
|
||||
serializer = JavaScriptSerializer()
|
||||
serializer.MaxJsonLength = 2147483647 # Max int32 - handle large book selections
|
||||
SCRIPT_DIR = Path.GetDirectoryName(__file__)
|
||||
PYTHON_EXE = r"pythonw.exe"
|
||||
|
||||
# ==============================================================================
|
||||
# @Name Sync: Export to CBZ
|
||||
# @Hook Books
|
||||
# @Image export.png
|
||||
# ==============================================================================
|
||||
def StartExport(books):
|
||||
if not books:
|
||||
return
|
||||
|
||||
job = []
|
||||
for b in books:
|
||||
# Collect all fields for v2.0 Schema
|
||||
# Build pages list
|
||||
pages = []
|
||||
for p in b.Pages:
|
||||
pages.append({
|
||||
"Image": p.ImageIndex,
|
||||
"ImageWidth": p.ImageWidth,
|
||||
"ImageHeight": p.ImageHeight,
|
||||
"Type": str(p.PageType) if str(p.PageType) != "Story" else ""
|
||||
})
|
||||
|
||||
job.append({
|
||||
"ID": str(b.Id),
|
||||
"FilePath": b.FilePath,
|
||||
# v2.0 fields in schema order
|
||||
"Title": b.Title,
|
||||
"Series": b.Series,
|
||||
"Number": b.Number,
|
||||
"Count": b.Count,
|
||||
"Volume": b.Volume,
|
||||
"AlternateSeries": b.AlternateSeries,
|
||||
"AlternateNumber": b.AlternateNumber,
|
||||
"AlternateCount": b.AlternateCount if b.AlternateCount > 0 else None,
|
||||
"Summary": b.Summary,
|
||||
"Notes": b.Notes,
|
||||
"Year": b.Year,
|
||||
"Month": b.Month,
|
||||
"Day": b.Day,
|
||||
"Writer": b.Writer,
|
||||
"Penciller": b.Penciller,
|
||||
"Inker": b.Inker,
|
||||
"Colorist": b.Colorist,
|
||||
"Letterer": b.Letterer,
|
||||
"CoverArtist": b.CoverArtist,
|
||||
"Editor": b.Editor,
|
||||
"Publisher": b.Publisher,
|
||||
"Imprint": b.Imprint,
|
||||
"Genre": b.Genre,
|
||||
"Web": b.Web if hasattr(b, 'Web') else "",
|
||||
"PageCount": b.PageCount,
|
||||
"LanguageISO": b.LanguageISO,
|
||||
"Format": b.Format,
|
||||
"BlackAndWhite": str(b.BlackAndWhite) if str(b.BlackAndWhite) != "Unknown" else "",
|
||||
"Manga": str(b.Manga) if str(b.Manga) != "Unknown" else "",
|
||||
"Characters": b.Characters,
|
||||
"Teams": b.Teams if hasattr(b, 'Teams') else "",
|
||||
"Locations": b.Locations,
|
||||
"ScanInformation": b.ScanInformation if hasattr(b, 'ScanInformation') else "",
|
||||
"StoryArc": b.StoryArc,
|
||||
"SeriesGroup": b.SeriesGroup,
|
||||
"AgeRating": b.AgeRating,
|
||||
"CommunityRating": b.CommunityRating if b.CommunityRating > 0 else None,
|
||||
"Rating": b.Rating if hasattr(b, 'Rating') and b.Rating > 0 else None,
|
||||
"MainCharacterOrTeam": b.MainCharacterOrTeam,
|
||||
"Review": b.Review if hasattr(b, 'Review') else "",
|
||||
"Pages": pages,
|
||||
# CR-specific data
|
||||
"HasBeenRead": b.HasBeenRead,
|
||||
"CustomValuesStore": b.CustomValuesStore if b.CustomValuesStore else ""
|
||||
})
|
||||
|
||||
# Save Job to temp file (use .NET for UTF-8 encoding)
|
||||
temp = Path.Combine(Path.GetTempPath(), 'cr_sync_job.json')
|
||||
File.WriteAllText(temp, serializer.Serialize(job), UTF8Encoding(False))
|
||||
|
||||
# Execute the External Python 3 Worker
|
||||
script = Path.Combine(SCRIPT_DIR, "cr_sync_worker.py")
|
||||
# Use .NET Process to launch (IronPython has no subprocess module)
|
||||
psi = ProcessStartInfo()
|
||||
psi.FileName = PYTHON_EXE
|
||||
psi.Arguments = '"%s" export "%s"' % (script, temp)
|
||||
psi.UseShellExecute = False
|
||||
psi.CreateNoWindow = True
|
||||
Process.Start(psi)
|
||||
|
||||
# ==============================================================================
|
||||
# @Name Sync: Read from CBZ
|
||||
# @Hook Books
|
||||
# @Image import.png
|
||||
# ==============================================================================
|
||||
def StartImport(books):
|
||||
if not books:
|
||||
return
|
||||
|
||||
job = [{"ID": str(b.Id), "FilePath": b.FilePath} for b in books]
|
||||
temp = Path.Combine(Path.GetTempPath(), 'cr_read_job.json')
|
||||
File.WriteAllText(temp, serializer.Serialize(job), UTF8Encoding(False))
|
||||
|
||||
script = Path.Combine(SCRIPT_DIR, "cr_sync_worker.py")
|
||||
psi = ProcessStartInfo()
|
||||
psi.FileName = PYTHON_EXE
|
||||
psi.Arguments = '"%s" import "%s"' % (script, temp)
|
||||
psi.UseShellExecute = False
|
||||
psi.CreateNoWindow = True
|
||||
Process.Start(psi)
|
||||
|
||||
# ==============================================================================
|
||||
# @Name Sync: Apply Status
|
||||
# @Hook Books
|
||||
# @Image apply.png
|
||||
# ==============================================================================
|
||||
def ApplySyncResults(books):
|
||||
results_path = Path.Combine(SCRIPT_DIR, "sync_results.json")
|
||||
lock_path = Path.Combine(SCRIPT_DIR, "sync.lock")
|
||||
|
||||
# Check if worker is still running
|
||||
if File.Exists(lock_path):
|
||||
MessageBox.Show("Sync is still in progress. Please wait for it to finish.")
|
||||
return
|
||||
|
||||
if not File.Exists(results_path):
|
||||
MessageBox.Show("No sync results found. Run a sync process first!")
|
||||
return
|
||||
|
||||
updates = serializer.DeserializeObject(File.ReadAllText(results_path, UTF8Encoding(False)))
|
||||
|
||||
synced_count = 0
|
||||
skipped_count = 0
|
||||
failed_count = 0
|
||||
imported_count = 0
|
||||
|
||||
for b in books:
|
||||
bid = str(b.Id)
|
||||
if bid not in updates:
|
||||
continue
|
||||
|
||||
data = updates[bid]
|
||||
|
||||
# Check export status - only mark as synced if successful
|
||||
if "status" in data:
|
||||
status = data["status"]
|
||||
if status == "success":
|
||||
b.SetCustomValue("ExtSyncStatus", "Synced")
|
||||
b.ComicInfoIsDirty = False # Clear "Modified Info" flag
|
||||
synced_count += 1
|
||||
elif status == "skipped":
|
||||
# Skipped means content was identical - already synced
|
||||
b.SetCustomValue("ExtSyncStatus", "Synced")
|
||||
b.ComicInfoIsDirty = False # Clear "Modified Info" flag
|
||||
skipped_count += 1
|
||||
else:
|
||||
# Failed - don't mark as synced
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# If we are in IMPORT mode, the data dict will have metadata tags
|
||||
elif "Series" in data:
|
||||
# Apply all standard fields (.NET dict uses ContainsKey, not .get())
|
||||
if "Title" in data and data["Title"]: b.Title = data["Title"]
|
||||
if "Series" in data and data["Series"]: b.Series = data["Series"]
|
||||
if "Number" in data and data["Number"]: b.Number = data["Number"]
|
||||
if "Volume" in data and data["Volume"]: b.Volume = int(data["Volume"])
|
||||
if "Summary" in data and data["Summary"]: b.Summary = data["Summary"]
|
||||
if "Notes" in data and data["Notes"]: b.Notes = data["Notes"]
|
||||
if "Year" in data and data["Year"]: b.Year = int(data["Year"])
|
||||
if "Month" in data and data["Month"]: b.Month = int(data["Month"])
|
||||
if "Day" in data and data["Day"]: b.Day = int(data["Day"])
|
||||
if "Writer" in data and data["Writer"]: b.Writer = data["Writer"]
|
||||
if "Penciller" in data and data["Penciller"]: b.Penciller = data["Penciller"]
|
||||
if "Inker" in data and data["Inker"]: b.Inker = data["Inker"]
|
||||
if "Colorist" in data and data["Colorist"]: b.Colorist = data["Colorist"]
|
||||
if "Letterer" in data and data["Letterer"]: b.Letterer = data["Letterer"]
|
||||
if "CoverArtist" in data and data["CoverArtist"]: b.CoverArtist = data["CoverArtist"]
|
||||
if "Editor" in data and data["Editor"]: b.Editor = data["Editor"]
|
||||
if "Publisher" in data and data["Publisher"]: b.Publisher = data["Publisher"]
|
||||
if "Genre" in data and data["Genre"]: b.Genre = data["Genre"]
|
||||
if "Format" in data and data["Format"]: b.Format = data["Format"]
|
||||
if "Characters" in data and data["Characters"]: b.Characters = data["Characters"]
|
||||
if "Locations" in data and data["Locations"]: b.Locations = data["Locations"]
|
||||
if "StoryArc" in data and data["StoryArc"]: b.StoryArc = data["StoryArc"]
|
||||
if "SeriesGroup" in data and data["SeriesGroup"]: b.SeriesGroup = data["SeriesGroup"]
|
||||
if "AgeRating" in data and data["AgeRating"]: b.AgeRating = data["AgeRating"]
|
||||
if "MainCharacterOrTeam" in data and data["MainCharacterOrTeam"]: b.MainCharacterOrTeam = data["MainCharacterOrTeam"]
|
||||
|
||||
# Apply HasBeenRead (now a standard element, already converted to boolean)
|
||||
if "HasBeenRead" in data:
|
||||
b.HasBeenRead = data["HasBeenRead"]
|
||||
|
||||
# Apply other custom values
|
||||
if "CustomValues" in data:
|
||||
custom_vals = data["CustomValues"]
|
||||
for key in custom_vals.Keys:
|
||||
b.SetCustomValue(key, custom_vals[key])
|
||||
|
||||
b.SetCustomValue("ExtSyncStatus", "Synced")
|
||||
imported_count += 1
|
||||
|
||||
# Cleanup results file after applying
|
||||
try:
|
||||
File.Delete(results_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Build summary message
|
||||
msg_parts = []
|
||||
if synced_count > 0:
|
||||
msg_parts.append(str(synced_count) + " exported")
|
||||
if skipped_count > 0:
|
||||
msg_parts.append(str(skipped_count) + " unchanged")
|
||||
if imported_count > 0:
|
||||
msg_parts.append(str(imported_count) + " imported")
|
||||
if failed_count > 0:
|
||||
msg_parts.append(str(failed_count) + " FAILED (check sync_errors.log)")
|
||||
|
||||
MessageBox.Show(", ".join(msg_parts) if msg_parts else "No matching books found.")
|
||||
106
README.md
Normal file
106
README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# ComicRack Metadata Sync
|
||||
|
||||
A high-performance metadata sync plugin for ComicRack Community Edition that writes ComicInfo.xml directly to your CBZ files.
|
||||
|
||||
## Why This Exists
|
||||
|
||||
ComicRack stores metadata in its internal database (`ComicDB.xml`) and writes some of it to `ComicInfo.xml` inside CBZ files. However, CR doesn't export everything (like custom fields), and its built-in export is slow.
|
||||
|
||||
This plugin solves both problems:
|
||||
1. Exports all metadata including custom values using an extended ComicInfo.xml schema
|
||||
2. Uses multi-threaded processing that's significantly faster than CR's native export
|
||||
|
||||
**CR's built-in "Write Info to File":**
|
||||
- Processes files sequentially (one at a time)
|
||||
- Makes CR sluggish while running
|
||||
- Does not write all info to file
|
||||
|
||||
**This plugin:**
|
||||
- Multi-threaded processing (4 parallel workers)
|
||||
- Non-blocking - ComicRack stays responsive
|
||||
- Progress bar with elapsed timer
|
||||
- Skips unchanged files automatically
|
||||
- Writes to SSD temp first, then copies to destination (faster for USB/HDD storage)
|
||||
|
||||
## Features
|
||||
|
||||
- **Export** - Write CR metadata to CBZ files as ComicInfo.xml
|
||||
- **Import** - Read ComicInfo.xml from CBZ files into CR (for migrating from other apps)
|
||||
- **Smart Skip** - Automatically skips files where ComicInfo.xml is already identical
|
||||
- **Streaming I/O** - Memory-efficient, handles large CBZ files without loading into RAM
|
||||
- **Cross-drive Support** - Builds new CBZ on local temp (SSD), then copies to storage drive (HDD/USB)
|
||||
- **Status Tracking** - Clears CR's "Modified Info" flag after successful sync
|
||||
|
||||
## Requirements
|
||||
|
||||
- ComicRack Community Edition
|
||||
- Python 3.10+
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the Release files from the repository.
|
||||
2. Install into ComicRack's script folder:
|
||||
`%APPDATA%\cYo\ComicRack Community Edition\Scripts\CompleteMetadata\`
|
||||
3. Ensure Python 3.10+ is installed and accessible via `pythonw.exe` in your PATH.
|
||||
4. Restart ComicRack
|
||||
5. The toolbar buttons should appear in the ComicRack UI.
|
||||
|
||||
## Usage
|
||||
|
||||
### Exporting (CR to CBZ)
|
||||
|
||||

|
||||
|
||||
1. Select the books you want to export
|
||||
2. Click **"Sync: Export to CBZ"** (or right-click menu)
|
||||
3. A progress window appears - click **Start**
|
||||
4. Wait for completion
|
||||
5. Back in CR, with the same books selected, click **"Sync: Apply Status"**
|
||||
- This marks books as synced and clears the "Modified Info" flag
|
||||
|
||||
### Importing (CBZ to CR)
|
||||
|
||||

|
||||
|
||||
1. Select the books you want to import metadata for
|
||||
2. Click **"Sync: Read from CBZ"**
|
||||
3. Wait for completion
|
||||
4. Click **"Sync: Apply Status"** to write the metadata into CR's database
|
||||
|
||||
### Apply
|
||||
|
||||

|
||||
|
||||
**Sync: Apply Status** looks for `sync_results.json` to import into CR. This file will have information about which comics had their information exported or which comics has information to import.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `Package.py` | ComicRack plugin (IronPython) - handles UI and CR integration |
|
||||
| `cr_sync_worker.py` | Python 3 worker - does the actual CBZ processing |
|
||||
| `sync_results.json` | Temporary results file (auto-deleted after apply) |
|
||||
| `sync_errors.log` | Error log for failed files |
|
||||
| `sync.lock` | Lock file to prevent applying while sync is running |
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Uses `shutil.copyfileobj()` instead of `zin.read()` which would load entire images into RAM
|
||||
- Writes to `tempfile.NamedTemporaryFile()` (usually on SSD), then `shutil.move()` to destination. Huge help for slow USB/HDD storage.
|
||||
- Uses uncompressed storage since CBZ images are already JPEG/PNG compressed - no point recompressing
|
||||
- Generates the XML first, then compares with existing `ComicInfo.xml`. If identical, skips the entire archive rewrite.
|
||||
- Tkinter progress window runs on the main thread while workers run in background.
|
||||
- Uses `shutil.move()` which handles cross-drive moves.
|
||||
|
||||
### ComicInfo.xml Schema
|
||||
|
||||
Based on the Anansi v2.0 schema with extensions:
|
||||
- All standard metadata fields (Series, Title, Writer, etc.)
|
||||
- Page information with dimensions and types
|
||||
- `<CustomValues>` section - Exports CR's custom fields
|
||||
- `<HasBeenRead>` - Read status as a standard element
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Sync is still in progress"**
|
||||
The worker is still running. Wait for it to finish, or delete `sync.lock` if it crashed.
|
||||
278
cr_sync_worker.py
Normal file
278
cr_sync_worker.py
Normal file
@@ -0,0 +1,278 @@
|
||||
import json, sys, os, zipfile, shutil, tkinter as tk, tempfile, threading, queue, time
|
||||
from tkinter import ttk
|
||||
import xml.etree.ElementTree as ET
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
class SyncEngine:
|
||||
def __init__(self, mode, job_file):
|
||||
self.mode = mode
|
||||
with open(job_file, 'r', encoding='utf-8-sig') as f: self.books = json.load(f)
|
||||
|
||||
self.root = tk.Tk()
|
||||
self.root.title(f"CompleteMetadata {mode.upper()}")
|
||||
self.root.geometry("450x150")
|
||||
|
||||
self.progress = ttk.Progressbar(self.root, length=400, mode='determinate')
|
||||
self.progress.pack(pady=20)
|
||||
|
||||
self.lbl = tk.Label(self.root, text=f"Ready to {mode} {len(self.books)} books...")
|
||||
self.lbl.pack()
|
||||
|
||||
self.timer_lbl = tk.Label(self.root, text="Elapsed: 0:00")
|
||||
self.timer_lbl.pack()
|
||||
|
||||
self.start_btn = tk.Button(self.root, text="Start", command=self.run)
|
||||
self.start_btn.pack(pady=10)
|
||||
|
||||
self.start_time = None
|
||||
self.timer_running = False
|
||||
|
||||
self.results_file = os.path.join(os.path.dirname(__file__), "sync_results.json")
|
||||
self.log_file = os.path.join(os.path.dirname(__file__), "sync_errors.log")
|
||||
self.lock_file = os.path.join(os.path.dirname(__file__), "sync.lock")
|
||||
|
||||
# Queue for thread-safe UI updates
|
||||
self.update_queue = queue.Queue()
|
||||
self.results = {}
|
||||
self.errors = []
|
||||
|
||||
self.root.mainloop()
|
||||
|
||||
def run(self):
|
||||
self.start_btn.config(state="disabled")
|
||||
self.lbl.config(text="Starting...")
|
||||
self.results = {}
|
||||
self.errors = []
|
||||
|
||||
# Create lock file to signal we're working
|
||||
with open(self.lock_file, 'w') as f:
|
||||
f.write(str(os.getpid()))
|
||||
|
||||
# Start timer
|
||||
self.start_time = time.time()
|
||||
self.timer_running = True
|
||||
self._update_timer()
|
||||
|
||||
# Start background worker thread
|
||||
worker = threading.Thread(target=self._worker_thread, daemon=True)
|
||||
worker.start()
|
||||
|
||||
# Start polling for UI updates
|
||||
self._poll_updates()
|
||||
|
||||
def _update_timer(self):
|
||||
"""Update elapsed time display"""
|
||||
if not self.timer_running or self.start_time is None:
|
||||
return
|
||||
elapsed = int(time.time() - self.start_time)
|
||||
mins, secs = divmod(elapsed, 60)
|
||||
self.timer_lbl.config(text=f"Elapsed: {mins}:{secs:02d}")
|
||||
self.root.after(1000, self._update_timer)
|
||||
|
||||
def _worker_thread(self):
|
||||
"""Runs in background thread - does all the heavy lifting"""
|
||||
total = len(self.books)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
if self.mode == "export":
|
||||
futures = {executor.submit(self.do_export, b): b for b in self.books}
|
||||
else:
|
||||
futures = {executor.submit(self.do_import, b): b for b in self.books}
|
||||
|
||||
completed = 0
|
||||
for future in as_completed(futures):
|
||||
book = futures[future]
|
||||
try:
|
||||
res = future.result()
|
||||
self.results[book['ID']] = res
|
||||
except Exception as e:
|
||||
self.errors.append(f"Error on {book['FilePath']}: {str(e)}")
|
||||
|
||||
completed += 1
|
||||
# Send progress update to main thread via queue
|
||||
self.update_queue.put(("progress", completed, total))
|
||||
|
||||
# Signal completion
|
||||
self.update_queue.put(("done", None, None))
|
||||
|
||||
def _poll_updates(self):
|
||||
"""Polls the queue for updates from worker thread - runs on main thread"""
|
||||
try:
|
||||
while True:
|
||||
msg_type, val1, val2 = self.update_queue.get_nowait()
|
||||
|
||||
if msg_type == "progress":
|
||||
completed, total = val1, val2
|
||||
self.progress['value'] = (completed / total) * 100
|
||||
self.lbl.config(text=f"Processing {completed}/{total}")
|
||||
|
||||
elif msg_type == "done":
|
||||
self._finish()
|
||||
return
|
||||
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Keep polling every 50ms - keeps UI responsive
|
||||
self.root.after(50, self._poll_updates)
|
||||
|
||||
def _finish(self):
|
||||
"""Called when all work is complete"""
|
||||
self.timer_running = False
|
||||
|
||||
with open(self.results_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.results, f)
|
||||
|
||||
# Remove lock file to signal completion
|
||||
if os.path.exists(self.lock_file):
|
||||
os.unlink(self.lock_file)
|
||||
|
||||
if self.errors:
|
||||
with open(self.log_file, 'w', encoding='utf-8') as f:
|
||||
f.write("\n".join(self.errors))
|
||||
self.lbl.config(text=f"Done with {len(self.errors)} errors. Check log.")
|
||||
else:
|
||||
self.lbl.config(text="Success! All files processed.")
|
||||
|
||||
def do_export(self, b):
|
||||
# v2.0 Schema element order (Rating added, HasBeenRead as standard element)
|
||||
SCHEMA_ORDER = [
|
||||
"Title", "Series", "Number", "Count", "Volume",
|
||||
"AlternateSeries", "AlternateNumber", "AlternateCount",
|
||||
"Summary", "Notes", "Year", "Month", "Day",
|
||||
"Writer", "Penciller", "Inker", "Colorist", "Letterer", "CoverArtist", "Editor",
|
||||
"Publisher", "Imprint", "Genre", "Web", "PageCount", "LanguageISO", "Format",
|
||||
"BlackAndWhite", "Manga", "Characters", "Teams", "Locations", "ScanInformation",
|
||||
"StoryArc", "SeriesGroup", "AgeRating", "HasBeenRead", "Pages", "CommunityRating", "Rating",
|
||||
"MainCharacterOrTeam", "Review"
|
||||
]
|
||||
|
||||
root = ET.Element("ComicInfo", {
|
||||
"xmlns:xsd": "http://www.w3.org/2001/XMLSchema",
|
||||
"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance"
|
||||
})
|
||||
|
||||
# Skip internal fields
|
||||
skip = {"ID", "FilePath", "Pages", "CustomValuesStore"}
|
||||
|
||||
# Add elements in schema order
|
||||
for field in SCHEMA_ORDER:
|
||||
if field == "Pages":
|
||||
# Handle Pages separately
|
||||
pages_data = b.get("Pages", [])
|
||||
if pages_data:
|
||||
pages_el = ET.SubElement(root, "Pages")
|
||||
for p in pages_data:
|
||||
attrs = {"Image": str(p["Image"])}
|
||||
if p.get("ImageWidth"): attrs["ImageWidth"] = str(p["ImageWidth"])
|
||||
if p.get("ImageHeight"): attrs["ImageHeight"] = str(p["ImageHeight"])
|
||||
if p.get("Type"): attrs["Type"] = p["Type"]
|
||||
ET.SubElement(pages_el, "Page", attrs)
|
||||
elif field not in skip:
|
||||
val = b.get(field)
|
||||
if val is not None and val != "" and val != 0:
|
||||
ET.SubElement(root, field).text = str(val)
|
||||
|
||||
# Add CustomValues section for CR custom fields
|
||||
custom_store = b.get("CustomValuesStore", "")
|
||||
if custom_store:
|
||||
custom_el = ET.SubElement(root, "CustomValues")
|
||||
# Parse the comma-separated key=value pairs from CR's CustomValuesStore
|
||||
for pair in custom_store.split(","):
|
||||
if "=" in pair:
|
||||
key, val = pair.split("=", 1)
|
||||
if key and val:
|
||||
ET.SubElement(custom_el, "CustomValue", {"name": key, "value": val})
|
||||
|
||||
# Pretty print XML
|
||||
self._indent_xml(root)
|
||||
xml_bytes = ET.tostring(root, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
# Check if existing ComicInfo.xml is identical - skip rewrite if so
|
||||
try:
|
||||
with zipfile.ZipFile(b['FilePath'], 'r') as z:
|
||||
if 'ComicInfo.xml' in z.namelist():
|
||||
existing = z.read('ComicInfo.xml')
|
||||
if existing == xml_bytes:
|
||||
return {"status": "skipped", "reason": "unchanged"}
|
||||
except:
|
||||
pass # If we can't read it, proceed with the write
|
||||
|
||||
# Inject into CBZ using optimized streaming approach
|
||||
# Use SSD temp directory for faster writes, then atomic replace
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.cbz') as tmp:
|
||||
temp_path = tmp.name
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(b['FilePath'], 'r') as zin:
|
||||
with zipfile.ZipFile(temp_path, 'w', compression=zipfile.ZIP_STORED) as zout:
|
||||
for item in zin.infolist():
|
||||
if item.filename.lower() != 'comicinfo.xml':
|
||||
# Stream data instead of loading entire files into RAM
|
||||
new_info = zipfile.ZipInfo(item.filename, date_time=item.date_time)
|
||||
new_info.compress_type = zipfile.ZIP_STORED
|
||||
with zin.open(item, 'r') as src:
|
||||
with zout.open(new_info, 'w') as dst:
|
||||
shutil.copyfileobj(src, dst, length=4 * 1024 * 1024)
|
||||
zout.writestr('ComicInfo.xml', xml_bytes)
|
||||
|
||||
# Move temp to destination (handles cross-drive automatically)
|
||||
shutil.move(temp_path, b['FilePath'])
|
||||
except:
|
||||
# Clean up temp file on failure
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
raise
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
def _indent_xml(self, elem, level=0):
|
||||
"""Add pretty-print indentation to XML"""
|
||||
indent = "\n" + " " * level
|
||||
if len(elem):
|
||||
if not elem.text or not elem.text.strip():
|
||||
elem.text = indent + " "
|
||||
if not elem.tail or not elem.tail.strip():
|
||||
elem.tail = indent
|
||||
last_child = None
|
||||
for child in elem:
|
||||
self._indent_xml(child, level + 1)
|
||||
last_child = child
|
||||
if last_child is not None and (not last_child.tail or not last_child.tail.strip()):
|
||||
last_child.tail = indent
|
||||
else:
|
||||
if level and (not elem.tail or not elem.tail.strip()):
|
||||
elem.tail = indent
|
||||
|
||||
def do_import(self, b):
|
||||
path = b['FilePath']
|
||||
with zipfile.ZipFile(path, 'r') as z:
|
||||
if 'ComicInfo.xml' in z.namelist():
|
||||
root = ET.fromstring(z.read('ComicInfo.xml'))
|
||||
data = {}
|
||||
|
||||
for child in root:
|
||||
if child.tag == "Pages":
|
||||
# Skip pages for now (complex to import back)
|
||||
pass
|
||||
elif child.tag == "CustomValues":
|
||||
# Parse custom values into a dict
|
||||
custom_values = {}
|
||||
for cv in child.findall("CustomValue"):
|
||||
name = cv.get("name")
|
||||
value = cv.get("value")
|
||||
if name and value:
|
||||
custom_values[name] = value
|
||||
data["CustomValues"] = custom_values
|
||||
else:
|
||||
data[child.tag] = child.text
|
||||
|
||||
# Convert HasBeenRead from string to boolean
|
||||
if "HasBeenRead" in data and data["HasBeenRead"]:
|
||||
data["HasBeenRead"] = data["HasBeenRead"].lower() == "true"
|
||||
|
||||
return data
|
||||
return {}
|
||||
|
||||
if __name__ == "__main__":
|
||||
SyncEngine(sys.argv[1], sys.argv[2])
|
||||
BIN
export.png
Normal file
BIN
export.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
import.png
Normal file
BIN
import.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Reference in New Issue
Block a user