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