Initial Upload

This commit is contained in:
2026-01-29 17:23:01 +01:00
commit 2e58712030
7 changed files with 619 additions and 0 deletions

4
Package.ini Normal file
View 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
View 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
View 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)
![](export.png)
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)
![](import.png)
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
![](apply.png)
**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.

BIN
apply.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

278
cr_sync_worker.py Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
import.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB