commit 2e58712030e9ede2ad130bc744126767837a29b9 Author: Frederik Baerentsen Date: Thu Jan 29 17:23:01 2026 +0100 Initial Upload diff --git a/Package.ini b/Package.ini new file mode 100644 index 0000000..53ccd60 --- /dev/null +++ b/Package.ini @@ -0,0 +1,4 @@ +Name=CompleteMetadata +Author=Frederik Baerentsen +Version=1.0 +Description=General metadata completion and synchronization between ComicRack and CBZ/CBR files. \ No newline at end of file diff --git a/Package.py b/Package.py new file mode 100644 index 0000000..cbe1c48 --- /dev/null +++ b/Package.py @@ -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.") \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ada571 --- /dev/null +++ b/README.md @@ -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 +- `` section - Exports CR's custom fields +- `` - 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. \ No newline at end of file diff --git a/apply.png b/apply.png new file mode 100644 index 0000000..8d6352c Binary files /dev/null and b/apply.png differ diff --git a/cr_sync_worker.py b/cr_sync_worker.py new file mode 100644 index 0000000..892e30c --- /dev/null +++ b/cr_sync_worker.py @@ -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]) \ No newline at end of file diff --git a/export.png b/export.png new file mode 100644 index 0000000..b5ecf75 Binary files /dev/null and b/export.png differ diff --git a/import.png b/import.png new file mode 100644 index 0000000..cbe4bee Binary files /dev/null and b/import.png differ