# #CVIssueCount.py # #Author: Frederik Baerentsen # #Description: Complete Story Arc with the Comic Vine info # #Versions: # V1 0.1 First version # V2 0.2 Added Progressbar, start and cancel buttons # V2 0.3 Added issue count # V3 0.4 Added local database support # V3 0.5 Added settings dialog (API key, local DB options, API delay) # V3 1.0 Performance improvements and stability fixes # # $ cd "..\..\Program Files\ComicRack\ # $ ComicRack.exe -ssc from __future__ import unicode_literals import clr, re, sys, os, urlparse, time from System.Diagnostics import Process clr.AddReference("System.xml") import System from System import * from System.IO import * from System.Collections import * from System.Threading import * from System.Net import * from System.Text import * clr.AddReference('System') clr.AddReference("System.Windows.Forms") clr.AddReference("System.Drawing") import System.Drawing import System.Windows.Forms from System.Drawing import * from System.Windows.Forms import * clr.AddReference('System.Drawing') from System.Drawing import Point, Size, ContentAlignment, Color, SystemColors, Icon clr.AddReference('System.Threading') from System.Threading import * from datetime import datetime import ssl, urllib from System.ComponentModel import BackgroundWorker stop=False # ============== Settings Management ============== SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) SETTINGS_FILE = os.path.join(SCRIPT_DIR, "CVIssueCount.ini") class Settings: def __init__(self): self.api_key = "" self.skip_api_if_not_in_db = False self.always_use_api = False self.api_delay = 10 # seconds between API calls, 0 to disable self.load_from_file() def load_from_file(self): """Load settings from INI file.""" if os.path.exists(SETTINGS_FILE): try: with open(SETTINGS_FILE, 'r') as f: for line in f: line = line.strip() if '=' in line: key, value = line.split('=', 1) key = key.strip() value = value.strip() if key == 'api_key': self.api_key = value elif key == 'skip_api_if_not_in_db': self.skip_api_if_not_in_db = value.lower() == 'true' elif key == 'always_use_api': self.always_use_api = value.lower() == 'true' elif key == 'api_delay': try: self.api_delay = int(value) except: self.api_delay = 10 except Exception as e: print "Error loading settings: " + str(e) def save_to_file(self): """Save settings to INI file.""" try: with open(SETTINGS_FILE, 'w') as f: f.write("api_key=" + self.api_key + "\n") f.write("skip_api_if_not_in_db=" + str(self.skip_api_if_not_in_db).lower() + "\n") f.write("always_use_api=" + str(self.always_use_api).lower() + "\n") f.write("api_delay=" + str(self.api_delay) + "\n") except Exception as e: print "Error saving settings: " + str(e) # Global settings instance settings = Settings() # ============== SQLite Initialization ============== _sqlite_initialized = False _sqlite_available = False _SQLiteConnection = None def _init_sqlite(): """Initialize SQLite once at module load. Returns True if successful.""" global _sqlite_initialized, _sqlite_available, _SQLiteConnection if _sqlite_initialized: return _sqlite_available _sqlite_initialized = True try: script_dir = os.path.dirname(os.path.abspath(__file__)) sqlite_dll = Path.Combine(script_dir, 'System.Data.SQLite.dll') if not File.Exists(sqlite_dll): print "System.Data.SQLite.dll not found at: " + sqlite_dll return False # Set DLL search path to find e_sqlite3.dll (do this once) if System.IntPtr.Size == 8: native_path = Path.Combine(script_dir, 'x64') else: native_path = Path.Combine(script_dir, 'x86') current_path = System.Environment.GetEnvironmentVariable("PATH") if native_path not in current_path: System.Environment.SetEnvironmentVariable("PATH", native_path + ";" + current_path) clr.AddReferenceToFileAndPath(sqlite_dll) from System.Data.SQLite import SQLiteConnection _SQLiteConnection = SQLiteConnection _sqlite_available = True print "SQLite initialized successfully" return True except Exception as e: print "SQLite initialization error: " + str(e) return False # Initialize SQLite at module load _init_sqlite() # ============== Settings Dialog ============== class SettingsForm(Form): def __init__(self): self.InitializeComponent() self.LoadSettings() def InitializeComponent(self): self.SuspendLayout() # API Key Label self._apiKeyLabel = Label() self._apiKeyLabel.Text = "ComicVine API Key:" self._apiKeyLabel.Location = Point(12, 15) self._apiKeyLabel.Size = Size(120, 20) # API Key TextBox self._apiKeyTextBox = TextBox() self._apiKeyTextBox.Location = Point(140, 12) self._apiKeyTextBox.Size = Size(280, 20) self._apiKeyTextBox.UseSystemPasswordChar = True # Show/Hide API Key button self._showApiKey = Button() self._showApiKey.Text = "Show" self._showApiKey.Location = Point(425, 10) self._showApiKey.Size = Size(50, 24) self._showApiKey.Click += self.ToggleApiKeyVisibility # Skip API checkbox self._skipApiCheckBox = CheckBox() self._skipApiCheckBox.Text = "Skip API if not found in local database (local DB only)" self._skipApiCheckBox.Location = Point(12, 50) self._skipApiCheckBox.Size = Size(400, 24) self._skipApiCheckBox.CheckedChanged += self.OnSkipApiChanged # Always use API checkbox self._alwaysApiCheckBox = CheckBox() self._alwaysApiCheckBox.Text = "Always use API (skip local database)" self._alwaysApiCheckBox.Location = Point(12, 80) self._alwaysApiCheckBox.Size = Size(400, 24) self._alwaysApiCheckBox.CheckedChanged += self.OnAlwaysApiChanged # API Delay Label self._apiDelayLabel = Label() self._apiDelayLabel.Text = "API delay (seconds):" self._apiDelayLabel.Location = Point(12, 115) self._apiDelayLabel.Size = Size(120, 20) # API Delay TextBox self._apiDelayTextBox = TextBox() self._apiDelayTextBox.Location = Point(140, 112) self._apiDelayTextBox.Size = Size(50, 20) # API Delay hint self._apiDelayHint = Label() self._apiDelayHint.Text = "(0 = no delay)" self._apiDelayHint.Location = Point(195, 115) self._apiDelayHint.Size = Size(100, 20) self._apiDelayHint.ForeColor = Color.Gray # Info label self._infoLabel = Label() self._infoLabel.Text = "Note: Local database (localcv.db) must be in the plugin folder." self._infoLabel.Location = Point(12, 145) self._infoLabel.Size = Size(460, 20) self._infoLabel.ForeColor = Color.Gray # Save Button self._saveButton = Button() self._saveButton.Text = "Save" self._saveButton.Location = Point(310, 175) self._saveButton.Size = Size(80, 28) self._saveButton.Click += self.SaveClicked # Cancel Button self._cancelButton = Button() self._cancelButton.Text = "Cancel" self._cancelButton.Location = Point(395, 175) self._cancelButton.Size = Size(80, 28) self._cancelButton.Click += self.CancelClicked # Form settings self.ClientSize = Size(490, 215) self.Text = "CVIssueCount Settings" self.FormBorderStyle = FormBorderStyle.FixedDialog self.MaximizeBox = False self.MinimizeBox = False self.StartPosition = FormStartPosition.CenterParent self.AcceptButton = self._saveButton self.CancelButton = self._cancelButton # Add controls self.Controls.Add(self._apiKeyLabel) self.Controls.Add(self._apiKeyTextBox) self.Controls.Add(self._showApiKey) self.Controls.Add(self._skipApiCheckBox) self.Controls.Add(self._alwaysApiCheckBox) self.Controls.Add(self._apiDelayLabel) self.Controls.Add(self._apiDelayTextBox) self.Controls.Add(self._apiDelayHint) self.Controls.Add(self._infoLabel) self.Controls.Add(self._saveButton) self.Controls.Add(self._cancelButton) self.ResumeLayout(False) def LoadSettings(self): self._apiKeyTextBox.Text = settings.api_key self._skipApiCheckBox.Checked = settings.skip_api_if_not_in_db self._alwaysApiCheckBox.Checked = settings.always_use_api self._apiDelayTextBox.Text = str(settings.api_delay) def ToggleApiKeyVisibility(self, sender, e): self._apiKeyTextBox.UseSystemPasswordChar = not self._apiKeyTextBox.UseSystemPasswordChar self._showApiKey.Text = "Hide" if not self._apiKeyTextBox.UseSystemPasswordChar else "Show" def OnSkipApiChanged(self, sender, e): if self._skipApiCheckBox.Checked: self._alwaysApiCheckBox.Checked = False def OnAlwaysApiChanged(self, sender, e): if self._alwaysApiCheckBox.Checked: self._skipApiCheckBox.Checked = False def SaveClicked(self, sender, e): settings.api_key = self._apiKeyTextBox.Text settings.skip_api_if_not_in_db = self._skipApiCheckBox.Checked settings.always_use_api = self._alwaysApiCheckBox.Checked try: settings.api_delay = int(self._apiDelayTextBox.Text) if settings.api_delay < 0: settings.api_delay = 0 except: settings.api_delay = 10 settings.save_to_file() self.DialogResult = DialogResult.OK self.Close() def CancelClicked(self, sender, e): self.DialogResult = DialogResult.Cancel self.Close() # ============== Main Entry Points ============== #@Name CVIssueCount #@Hook Books #@Image CVIssueCount.png #@Key CVIssueCount def CVIssueCount(books): """Main entry point - called from context menu on Books.""" if books: f = CVIssueCountForm(books) r = f.ShowDialog() else: MessageBox.Show("No books selected") return #@Key CVIssueCount #@Hook ConfigScript def CVIssueCountSettings(): """Entry point for ConfigScript hook - shows settings from File menu.""" f = SettingsForm() f.ShowDialog() # ============== Main Form ============== class CVIssueCountForm(Form): def __init__(self, books): self.InitializeComponent() self.books = books self.percentage = 1.0/len(books)*100 self.progresspercent = 0.0 self.stop = False def InitializeComponent(self): self._label1 = System.Windows.Forms.Label() self._Okay = System.Windows.Forms.Button() self._Cancel = System.Windows.Forms.Button() self._Settings = System.Windows.Forms.Button() self._progress = System.Windows.Forms.ProgressBar() self.worker = BackgroundWorker() self.SuspendLayout() self.worker.WorkerSupportsCancellation = True self.worker.WorkerReportsProgress = True self.worker.DoWork += self.DoWork self.worker.ProgressChanged += self.ReportProgress self.worker.RunWorkerCompleted += self.WorkerCompleted self._progress.Location = System.Drawing.Point(12, 114) self._progress.Size = System.Drawing.Size(456, 17) self._progress.TabIndex = 4 # # label1 # self._label1.AutoSize = True self._label1.Location = System.Drawing.Point(12, 53) self._label1.Name = "label1" self._label1.Size = System.Drawing.Size(16, 13) self._label1.TabIndex = 3 self._label1.Text = "Start searching Comicvine for issue counts using the 'Start' button" # # Settings Button # self._Settings.Location = System.Drawing.Point(12, 137) self._Settings.Name = "Settings" self._Settings.Size = System.Drawing.Size(75, 23) self._Settings.TabIndex = 7 self._Settings.Text = "Settings" self._Settings.UseVisualStyleBackColor = True self._Settings.Click += self.SettingsClicked # # Okay # self._Okay.Location = System.Drawing.Point(312, 137) self._Okay.Name = "Okay" self._Okay.Size = System.Drawing.Size(75, 23) self._Okay.TabIndex = 5 self._Okay.Text = "Start" self._Okay.UseVisualStyleBackColor = True self._Okay.Click += self.OkayClicked # # Cancel # self._Cancel.Location = System.Drawing.Point(393, 137) self._Cancel.Name = "Cancel" self._Cancel.Size = System.Drawing.Size(75, 23) self._Cancel.TabIndex = 6 self._Cancel.Text = "Cancel" self._Cancel.Enabled = False self._Cancel.UseVisualStyleBackColor = True self._Cancel.Click += self.CancelClicked self.ClientSize = System.Drawing.Size(483, 170) self.Controls.Add(self._Settings) self.Controls.Add(self._Cancel) self.Controls.Add(self._Okay) self.Controls.Add(self._progress) self.Controls.Add(self._label1) self.Name = "CVIssueCount" self.Text = "Get issue Count from CV" self.MinimizeBox = False self.MaximizeBox = False self.AcceptButton = self._Okay self.FormBorderStyle = FormBorderStyle.FixedDialog self.StartPosition = FormStartPosition.CenterParent self.ResumeLayout(False) self.PerformLayout() self.FormClosing += self.CheckClosing def CheckClosing(self, sender, e): if self.worker.IsBusy: self.worker.CancelAsync() e.Cancel = True #print "close" def SettingsClicked(self, sender, e): f = SettingsForm() f.ShowDialog() def OkayClicked(self, sender, e): if self._Okay.Text == "Start": if self.worker.IsBusy == False: self.worker.RunWorkerAsync() self._Cancel.Enabled = True self._Okay.Enabled = False self._Settings.Enabled = False #else: #self.DialogResult = DialogResult.OK def CancelClicked(self, sender, e): if self.worker.IsBusy: self.worker.CancelAsync() self._Cancel.Enabled = False def DoWork(self, sender, e): e.Result = getIssueCount(sender,self.books) def ReportProgress(self, sender, e): self.progresspercent = self.percentage*e.ProgressPercentage self._progress.Value = int(round(self.progresspercent)) self._label1.Text = "Searching for " + e.UserState.ToString() def WorkerCompleted(self, sender, e): self._Okay.Text = "Done" self.Close() self.Dispose() # ============== Database Functions ============== def getIssueCountFromDB(volume_id, db_path): """Try to get issue count from local database. Returns None if not found.""" if not _sqlite_available: return None conn = None try: conn = _SQLiteConnection("Data Source=" + db_path + ";Version=3;Read Only=True;BusyTimeout=5000;") conn.Open() cmd = conn.CreateCommand() cmd.CommandText = "SELECT count_of_issues FROM cv_volume WHERE id = " + str(int(volume_id)) result = cmd.ExecuteScalar() if result is not None: return int(result) except Exception as e: print "DB lookup error: " + str(e) finally: if conn is not None: try: conn.Close() except: pass return None # ============== Main Logic ============== def getIssueCount(worker,books): # Get API key from settings API_KEY = settings.api_key if settings.api_key else "" # Check for local database script_dir = os.path.dirname(os.path.abspath(__file__)) db_path = os.path.join(script_dir, "localcv.db") db_exists = os.path.exists(db_path) # Determine what sources to use based on settings use_local_db = db_exists and not settings.always_use_api use_api = not settings.skip_api_if_not_in_db if settings.always_use_api: print "Settings: Always use API (skipping local DB)" elif settings.skip_api_if_not_in_db: print "Settings: Skip API if not in local DB" if not db_exists: print "Warning: Local database not found!" elif use_local_db: print "Using local database: " + db_path else: print "Local database not found, using API" all_books_original = ComicRack.App.GetLibraryBooks() # Build a lookup dictionary: volume_id -> list of books (do this ONCE) print "Building volume lookup index..." volume_to_books = {} for book in all_books_original: vol = book.GetCustomValue("comicvine_volume") if vol: if vol not in volume_to_books: volume_to_books[vol] = [] volume_to_books[vol].append(book) print "Index built: " + str(len(volume_to_books)) + " volumes" stop=False CheckedVolumes=list() IssueCountList=list() IssueCount=0 failed = 0 count = 0 report = System.Text.StringBuilder() for book in books: if worker.CancellationPending: return failed,report.ToString() volume = book.GetCustomValue("comicvine_volume") seriesVolume = volume bookinfo = "#" + str(book.Number) + " " + book.Series + " (" + str(book.Volume) + ")" count += 1 worker.ReportProgress(count,bookinfo) now = datetime.now() print str(now.strftime("%m/%d/%Y, %H:%M:%S")) if volume not in CheckedVolumes: IssueCount = None # Try local database first (unless always_use_api is set) if use_local_db: IssueCount = getIssueCountFromDB(volume, db_path) if IssueCount is not None: print str(volume) + "'s count is " + str(IssueCount) + " (from local DB)" report.Append(str(IssueCount)) # Fall back to API if not found in local DB (unless skip_api_if_not_in_db is set) if IssueCount is None and use_api: QUERY = "https://comicvine.gamespot.com/api/volume/4050-"+ volume +"/?api_key=" + API_KEY + "&format=xml&field_list=count_of_issues" print QUERY data = _read_url(QUERY.encode('utf-8')) if settings.api_delay > 0: time.sleep(settings.api_delay) doc = System.Xml.XmlDocument() doc.LoadXml(data) elemList = doc.GetElementsByTagName("count_of_issues") for i in elemList: IssueCount = int(i.InnerXml) if i.InnerText == "": failed += 1 report.Append(i.InnerText) else: report.Append(i.InnerText) print str(volume) + "'s count is " + str(IssueCount) + " (from API)" elif IssueCount is None: print str(volume) + " not found in local DB (API skipped per settings)" failed += 1 CheckedVolumes.append(volume) IssueCountList.append(IssueCount if IssueCount else 0) # Update all books with this volume using the pre-built index (fast O(1) lookup) if seriesVolume in volume_to_books: for b in volume_to_books[seriesVolume]: if b.Number.isnumeric: b.Count = IssueCount if IssueCount else 0 b.SetCustomValue("comicvine_issue_count",str(IssueCount if IssueCount else 0)) return failed,report.ToString() def _read_url(url): page = '' requestUri = url ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Ssl3; Req = HttpWebRequest.Create(requestUri) Req.Timeout = 60000 Req.UserAgent = "CVIssueCount/" + " (https://gitea.baerentsen.space/FrederikBaerentsen/ComicRack_Scripts/src/branch/master/CVIssueCount)" Req.AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip #Req.Referer = requestUri Req.Accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8' Req.Headers.Add('Accept-Language','en-US,en;q=0.9,it;q=0.8,fr;q=0.7,de-DE;q=0.6,de;q=0.5') Req.KeepAlive = True webresponse = Req.GetResponse() a = webresponse.Cookies inStream = webresponse.GetResponseStream() encode = Encoding.GetEncoding("utf-8") ReadStream = StreamReader(inStream, encode) page = ReadStream.ReadToEnd() try: inStream.Close() webresponse.Close() except: pass return page