Files
ComicRack_CVIssueCount/CVIssueCount.py
2026-01-29 19:22:54 +01:00

619 lines
21 KiB
Python

#
#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 "<API_KEY>"
# 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