From 2e58712030e9ede2ad130bc744126767837a29b9 Mon Sep 17 00:00:00 2001 From: Frederik Baerentsen Date: Thu, 29 Jan 2026 17:23:01 +0100 Subject: [PATCH] Initial Upload --- Package.ini | 4 + Package.py | 231 ++++++++++++++++++++++++++++++++++++++ README.md | 106 ++++++++++++++++++ apply.png | Bin 0 -> 1291 bytes cr_sync_worker.py | 278 ++++++++++++++++++++++++++++++++++++++++++++++ export.png | Bin 0 -> 1261 bytes import.png | Bin 0 -> 1533 bytes 7 files changed, 619 insertions(+) create mode 100644 Package.ini create mode 100644 Package.py create mode 100644 README.md create mode 100644 apply.png create mode 100644 cr_sync_worker.py create mode 100644 export.png create mode 100644 import.png 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 0000000000000000000000000000000000000000..8d6352c6482bb787406b3452e623bb8572ba4bfa GIT binary patch literal 1291 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 literal 0 HcmV?d00001 diff --git a/import.png b/import.png new file mode 100644 index 0000000000000000000000000000000000000000..cbe4beebdaddba94af1240b4d401e14e672c47f8 GIT binary patch literal 1533 zcmZ{jX;70_6ozkPkxj5{5rh^g0)k`@%Lm9(A_OEr*cE{!gd~uVK*A1f5G@pBa2sTA zk$@~B0s)n!v@VfFKt~13VigsTrD_#Ws?v{6e^6)odnoacS-y>sXO;7f7erlPL` z0N6(MaHGN*vk^-2@cK~|s|F`|mW#Iw04;^8A7~12ZN%`PdIOMV3V`@10BdkdJO@A` zES!%704fFmDJbvsbASh1=w9w_U_Gbsb}w8h3p_%F00FIR1OjRahT!H_5!suxb!LmU zvXXI^KK3mDa%E&Umw=G#OJm;LGlANQk6E=&+Iwr9?MXG)w{uR~?v06E}9wgJmKAnHi>60y=Ff%2X7)n?0ILObZ@T_CUqMG)T z_YRa6Etd_-!U)`JbS5D4zP!h49Mbnf8P9eOu9YwMw6KfmvB@`nO1-Kg>vrLD&`zlp zhu5r7WsxqUI@RyvNu$?Vi%B1o>Mcor1#*u%pm+Ob8)(@meIjqt^--^DBM}EV((|_k zDe8sh^vpKbEhm>L1qpxSOw_2Jy3VbUE~Oop@2b3Gs5ANaz&`eEa>c3Fp5IF-W(CjN zM%YRk6Kpria5<9UbC{hz)Zeey(z3nlmC&2bv}iA_>p5SbsBPGM;+*=eupw5V=UKTc zhn|@=n*XUO9?!jT^!V5!eui4Y7_Vx&Z+s0Txab58^rdXEnIbGCop>M#&x{J!6URrS zh2`id{G7I|@8#Pb`7k-j^CqHr_2SbGnReHOh-JIR5euK2)w!gxw`&7wheR)0Wpbs2 zLx)YqdI#5T`WlT@vCJC!k|SjW3xoav&yV@k*pPU>W-i z7B7Ys+AYmK%2RmX{6sE`~q~}ue`oQe4tl6C!c9J+dzhfXjl{H}f)mizJ(X!G! zkf1Q-+vY!?amAs@g?|TWXn7aeK9BzEr7b@*Bqsh%x`k^#^;-+oSJg$Bj??NW&hKYq z?smu9`Pj!SY36DEq47wuExge9>aeP!r-L&pg-mrFFnw|4Wc>Ytj77=En&#=G9506| z)G>o;$&_jK3yq@QTrKN|Pcr4AiZPgu!rSzIFc*@c2=j z=+yrg7?#ax!vdp?9fbU7Q8F!o3D_c$KnS7H93G9ugoDF~Me!3@-6Mt-FuP-e4PYno z=n&+_XC(5NaUuxvg;62Eo?q`mf5wnETp-AU$zicYfQZEv7Wiku404mtzf3SEO2pnk z3^L>y98V6FzAezHv!q-Qk69rs8ElP+cTBBoQS)rU*DjJWsu|_jf s1pEY11pFj4I!(xo;4t`c5jZT?jz~bk2a|U-n1F2onM85B=o)$CZ|