Compare commits

...

4 Commits

Author SHA1 Message Date
NoDRM 10b6caf9f5 Enable autorelease into 2nd repo 2023-08-03 21:53:16 +02:00
NoDRM 53996cf49c More Python2 fixes 2023-08-03 20:45:06 +02:00
NoDRM d388ae72fd More Py2 fixes 2023-08-03 20:14:33 +02:00
NoDRM bc089ee46d More Python2 bugfixes 2023-08-03 20:01:38 +02:00
20 changed files with 160 additions and 103 deletions

View File

@ -20,46 +20,33 @@ jobs:
path: | path: |
DeDRM_tools_*.zip DeDRM_tools_*.zip
DeDRM_tools.zip DeDRM_tools.zip
- name: Prepare release
run: cp DeDRM_tools.zip DeDRM_alpha_${{ github.sha }}.zip
# - name: Delete old release
# uses: cb80/delrel@latest - uses: dev-drprasad/delete-older-releases@v0.2.1
# with: with:
# tag: autorelease repo: noDRM/DeDRM_tools_autorelease
# token: ${{ github.token }} keep_latest: 0
# delete_tags: true
# - name: Delete old tag env:
# uses: dev-drprasad/delete-tag-and-release@v1.0 GITHUB_TOKEN: ${{ secrets.AUTORELEASE_KEY }}
# with:
# tag_name: autorelease - name: Auto-release
# github_token: ${{ github.token }} id: autorelease
# delete_release: true uses: softprops/action-gh-release@v1
# with:
# - name: Prepare release tag_name: autorelease_${{ github.sha }}
# run: cp DeDRM_tools.zip DeDRM_alpha_${{ github.sha }}.zip repository: noDRM/DeDRM_tools_autorelease
# token: ${{ secrets.AUTORELEASE_KEY }}
# - name: Auto-release name: Automatic alpha release with latest changes
# id: autorelease body: |
# uses: softprops/action-gh-release@v1 This release is automatically generated by Github for each commit.
# with:
# tag_name: autorelease This means, every time a change is made to the repo, a release with an untested copy of the plugin at that stage will be created. This will contain the most up-to-date code, but it's not tested at all and may be broken.
# token: ${{ github.token }}
# name: Automatic alpha release with latest changes Last update based on Git commit [${{ github.sha }}](https://github.com/noDRM/DeDRM_tools/commit/${{ github.sha }}).
# body: | prerelease: true
# This release is automatically generated by Github for each commit. draft: false
# files: DeDRM_alpha_${{ github.sha }}.zip
# This means, every time a change is made to this repo, this release will be updated to contain an untested copy of the plugin at that stage. This will contain the most up-to-date code, but it's not tested at all and may be broken.
#
# Last update based on Git commit ${{ github.sha }}.
# prerelease: true
# draft: true
# files: DeDRM_alpha_${{ github.sha }}.zip
#
# - name: Make release public
# uses: irongut/EditRelease@v1.2.0
# with:
# token: ${{ github.token }}
# id: ${{ steps.autorelease.outputs.id }}
# draft: false
# prerelease: true
#
#

View File

@ -101,4 +101,5 @@ This is v10.0.9, a release candidate for v10.1.0. I don't expect there to be maj
- Fix a bug where decrypting a 40-bit RC4 pdf with R=2 didn't work. - Fix a bug where decrypting a 40-bit RC4 pdf with R=2 didn't work.
- Fix a bug where decrypting a 256-bit AES pdf with V=5 didn't work. - Fix a bug where decrypting a 256-bit AES pdf with V=5 didn't work.
- Fix bugs in kgenpids.py and kindlekey.py that caused it to fail on Python 2 (#380). - Fix bugs in kgenpids.py, alfcrypto.py, mobidedrm.py and kindlekey.py that caused it to fail on Python 2 (#380).
- Fix some bugs (Python 2 and Python 3) in erdr2pml.py (untested).

View File

@ -15,6 +15,6 @@ if "calibre" in sys.modules and sys.version_info[0] == 2:
sys.path.insert(0, os.path.join(config_dir, "plugins", "DeDRM.zip")) sys.path.insert(0, os.path.join(config_dir, "plugins", "DeDRM.zip"))
# Explicitly set the package identifier so we are allowed to import stuff ... # Explicitly set the package identifier so we are allowed to import stuff ...
#__package__ = "DeDRM_plugin" __package__ = "calibre_plugins.dedrm"
#@@CALIBRE_COMPAT_CODE_END@@ #@@CALIBRE_COMPAT_CODE_END@@

View File

@ -96,7 +96,10 @@ import traceback
#@@CALIBRE_COMPAT_CODE@@ #@@CALIBRE_COMPAT_CODE@@
try: try:
import __version try:
from . import __version
except:
import __version
except: except:
print("#############################") print("#############################")
print("Failed to load the DeDRM plugin") print("Failed to load the DeDRM plugin")
@ -134,8 +137,10 @@ try:
except: except:
config_dir = "" config_dir = ""
try:
import utilities from . import utilities
except:
import utilities
PLUGIN_NAME = __version.PLUGIN_NAME PLUGIN_NAME = __version.PLUGIN_NAME
@ -915,6 +920,9 @@ class DeDRM(FileTypePlugin):
# perhaps we need to get a new default Kindle for Mac/PC key # perhaps we need to get a new default Kindle for Mac/PC key
defaultkeys = [] defaultkeys = []
print("{0} v{1}: Failed to decrypt with error: {2}".format(PLUGIN_NAME, PLUGIN_VERSION,e.args[0])) print("{0} v{1}: Failed to decrypt with error: {2}".format(PLUGIN_NAME, PLUGIN_VERSION,e.args[0]))
traceback.print_exc()
print("{0} v{1}: Looking for new default Kindle Key after {2:.1f} seconds".format(PLUGIN_NAME, PLUGIN_VERSION, time.time()-self.starttime)) print("{0} v{1}: Looking for new default Kindle Key after {2:.1f} seconds".format(PLUGIN_NAME, PLUGIN_VERSION, time.time()-self.starttime))
try: try:

View File

@ -44,10 +44,11 @@ __version__ = '7.4'
import sys, os, struct, getopt import sys, os, struct, getopt
from base64 import b64decode from base64 import b64decode
#@@CALIBRE_COMPAT_CODE@@
from utilities import SafeUnbuffered from .utilities import SafeUnbuffered
from argv_utils import unicode_argv from .argv_utils import unicode_argv
try: try:

View File

@ -8,6 +8,7 @@
# pbkdf2.py Copyright © 2009 Daniel Holth <dholth@fastmail.fm> # pbkdf2.py Copyright © 2009 Daniel Holth <dholth@fastmail.fm>
# pbkdf2.py This code may be freely used and modified for any purpose. # pbkdf2.py This code may be freely used and modified for any purpose.
import sys
import hmac import hmac
from struct import pack from struct import pack
import hashlib import hashlib
@ -25,7 +26,10 @@ class Pukall_Cipher(object):
raise Exception("PC1: Bad key length") raise Exception("PC1: Bad key length")
wkey = [] wkey = []
for i in range(8): for i in range(8):
wkey.append(key[i*2]<<8 | key[i*2+1]) if sys.version_info[0] == 2:
wkey.append(ord(key[i*2])<<8 | ord(key[i*2+1]))
else:
wkey.append(key[i*2]<<8 | key[i*2+1])
dst = bytearray(len(src)) dst = bytearray(len(src))
for i in range(len(src)): for i in range(len(src)):
temp1 = 0; temp1 = 0;
@ -37,7 +41,12 @@ class Pukall_Cipher(object):
sum2 = (sum2+sum1)&0xFFFF sum2 = (sum2+sum1)&0xFFFF
temp1 = (temp1*20021+1)&0xFFFF temp1 = (temp1*20021+1)&0xFFFF
byteXorVal ^= temp1 ^ sum2 byteXorVal ^= temp1 ^ sum2
curByte = src[i]
if sys.version_info[0] == 2:
curByte = ord(src[i])
else:
curByte = src[i]
if not decryption: if not decryption:
keyXorVal = curByte * 257; keyXorVal = curByte * 257;
curByte = ((curByte ^ (byteXorVal >> 8)) ^ byteXorVal) & 0xFF curByte = ((curByte ^ (byteXorVal >> 8)) ^ byteXorVal) & 0xFF
@ -45,7 +54,12 @@ class Pukall_Cipher(object):
keyXorVal = curByte * 257; keyXorVal = curByte * 257;
for j in range(8): for j in range(8):
wkey[j] ^= keyXorVal; wkey[j] ^= keyXorVal;
dst[i] = curByte
if sys.version_info[0] == 2:
dst[i] = chr(curByte)
else:
dst[i] = curByte
return bytes(dst) return bytes(dst)
class Topaz_Cipher(object): class Topaz_Cipher(object):
@ -103,7 +117,7 @@ class KeyIVGen(object):
def xorbytes( a, b ): def xorbytes( a, b ):
if len(a) != len(b): if len(a) != len(b):
raise Exception("xorbytes(): lengths differ") raise Exception("xorbytes(): lengths differ")
return bytes([x ^ y for x, y in zip(a, b)]) return bytes(bytearray([x ^ y for x, y in zip(a, b)]))
def prf( h, data ): def prf( h, data ):
hm = h.copy() hm = h.copy()

View File

@ -29,7 +29,7 @@ from calibre.constants import iswindows, isosx
from __init__ import PLUGIN_NAME, PLUGIN_VERSION from __init__ import PLUGIN_NAME, PLUGIN_VERSION
from __version import RESOURCE_NAME as help_file_name from __version import RESOURCE_NAME as help_file_name
from utilities import uStrCmp from .utilities import uStrCmp
import prefs import prefs
import androidkindlekey import androidkindlekey

View File

@ -53,12 +53,12 @@ import sys, struct, os, traceback
import zlib import zlib
import zipfile import zipfile
import xml.etree.ElementTree as etree import xml.etree.ElementTree as etree
from argv_utils import unicode_argv from .argv_utils import unicode_argv
NSMAP = {'adept': 'http://ns.adobe.com/adept', NSMAP = {'adept': 'http://ns.adobe.com/adept',
'enc': 'http://www.w3.org/2001/04/xmlenc#'} 'enc': 'http://www.w3.org/2001/04/xmlenc#'}
from utilities import SafeUnbuffered from .utilities import SafeUnbuffered
_FILENAME_LEN_OFFSET = 26 _FILENAME_LEN_OFFSET = 26

View File

@ -79,12 +79,13 @@ except ImportError:
#@@CALIBRE_COMPAT_CODE@@ #@@CALIBRE_COMPAT_CODE@@
from utilities import SafeUnbuffered from .utilities import SafeUnbuffered
from .argv_utils import unicode_argv
iswindows = sys.platform.startswith('win') iswindows = sys.platform.startswith('win')
isosx = sys.platform.startswith('darwin') isosx = sys.platform.startswith('darwin')
from argv_utils import unicode_argv
import cgi import cgi
import logging import logging
@ -141,14 +142,20 @@ def sanitizeFileName(name):
def fixKey(key): def fixKey(key):
def fixByte(b): def fixByte(b):
if sys.version_info[0] == 2:
b = ord(b)
return b ^ ((b ^ (b<<1) ^ (b<<2) ^ (b<<3) ^ (b<<4) ^ (b<<5) ^ (b<<6) ^ (b<<7) ^ 0x80) & 0x80) return b ^ ((b ^ (b<<1) ^ (b<<2) ^ (b<<3) ^ (b<<4) ^ (b<<5) ^ (b<<6) ^ (b<<7) ^ 0x80) & 0x80)
return bytes([fixByte(a) for a in key]) return bytes(bytearray([fixByte(a) for a in key]))
def deXOR(text, sp, table): def deXOR(text, sp, table):
r='' r=b''
j = sp j = sp
for i in range(len(text)): for i in range(len(text)):
r += chr(ord(table[j]) ^ ord(text[i])) if sys.version_info[0] == 2:
r += chr(ord(table[j]) ^ ord(text[i]))
else:
r += bytes(bytearray([table[j] ^ text[i]]))
j = j + 1 j = j + 1
if j == len(table): if j == len(table):
j = 0 j = 0

View File

@ -50,9 +50,9 @@ try:
except ImportError: except ImportError:
from Crypto.Cipher import AES from Crypto.Cipher import AES
from utilities import SafeUnbuffered from .utilities import SafeUnbuffered
from argv_utils import unicode_argv from .argv_utils import unicode_argv
class IGNOBLEError(Exception): class IGNOBLEError(Exception):
pass pass

View File

@ -27,14 +27,14 @@ import hashlib
import getopt import getopt
import re import re
from utilities import SafeUnbuffered from .utilities import SafeUnbuffered
try: try:
from calibre.constants import iswindows from calibre.constants import iswindows
except: except:
iswindows = sys.platform.startswith('win') iswindows = sys.platform.startswith('win')
from argv_utils import unicode_argv from .argv_utils import unicode_argv
class DrmException(Exception): class DrmException(Exception):
pass pass

View File

@ -70,9 +70,8 @@ def unpad(data, padding=16):
return data[:-pad_len] return data[:-pad_len]
from utilities import SafeUnbuffered from .utilities import SafeUnbuffered
from .argv_utils import unicode_argv
from argv_utils import unicode_argv
class ADEPTError(Exception): class ADEPTError(Exception):

View File

@ -93,12 +93,12 @@ def unpad(data, padding=16):
return data[:-pad_len] return data[:-pad_len]
from utilities import SafeUnbuffered from .utilities import SafeUnbuffered
iswindows = sys.platform.startswith('win') iswindows = sys.platform.startswith('win')
isosx = sys.platform.startswith('darwin') isosx = sys.platform.startswith('darwin')
from argv_utils import unicode_argv from .argv_utils import unicode_argv
class ADEPTError(Exception): class ADEPTError(Exception):
pass pass

View File

@ -88,9 +88,9 @@ import kgenpids
import androidkindlekey import androidkindlekey
import kfxdedrm import kfxdedrm
from utilities import SafeUnbuffered from .utilities import SafeUnbuffered
from argv_utils import unicode_argv from .argv_utils import unicode_argv
# cleanup unicode filenames # cleanup unicode filenames

View File

@ -190,6 +190,10 @@ def getKindlePids(rec209, token, serialnum):
if isinstance(serialnum,str): if isinstance(serialnum,str):
serialnum = serialnum.encode('utf-8') serialnum = serialnum.encode('utf-8')
if sys.version_info[0] == 2:
if isinstance(serialnum,unicode):
serialnum = serialnum.encode('utf-8')
if rec209 is None: if rec209 is None:
return [serialnum] return [serialnum]

View File

@ -62,7 +62,9 @@ except NameError:
# Routines common to Mac and PC # Routines common to Mac and PC
from utilities import SafeUnbuffered from .utilities import SafeUnbuffered
from .argv_utils import unicode_argv
try: try:
from calibre.constants import iswindows, isosx from calibre.constants import iswindows, isosx
@ -70,7 +72,7 @@ except:
iswindows = sys.platform.startswith('win') iswindows = sys.platform.startswith('win')
isosx = sys.platform.startswith('darwin') isosx = sys.platform.startswith('darwin')
from argv_utils import unicode_argv
class DrmException(Exception): class DrmException(Exception):
pass pass
@ -238,9 +240,14 @@ if iswindows:
# replace any non-ASCII values with 0xfffd # replace any non-ASCII values with 0xfffd
for i in range(0,len(buffer)): for i in range(0,len(buffer)):
if buffer[i]>"\u007f": if sys.version_info[0] == 2:
#print "swapping char "+str(i)+" ("+buffer[i]+")" if buffer[i]>u"\u007f":
buffer[i] = "\ufffd" #print "swapping char "+str(i)+" ("+buffer[i]+")"
buffer[i] = u"\ufffd"
else:
if buffer[i]>"\u007f":
#print "swapping char "+str(i)+" ("+buffer[i]+")"
buffer[i] = "\ufffd"
# return utf-8 encoding of modified username # return utf-8 encoding of modified username
#print "modified username:"+buffer.value #print "modified username:"+buffer.value
return buffer.value.encode('utf-8') return buffer.value.encode('utf-8')

View File

@ -16,24 +16,24 @@
import sys import sys
import binascii import binascii
from utilities import SafeUnbuffered from .utilities import SafeUnbuffered
from argv_utils import unicode_argv from .argv_utils import unicode_argv
letters = 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789' letters = b'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789'
def crc32(s): def crc32(s):
return (~binascii.crc32(s,-1))&0xFFFFFFFF return (~binascii.crc32(s,-1))&0xFFFFFFFF
def checksumPid(s): def checksumPid(s):
crc = crc32(s.encode('ascii')) crc = crc32(s)
crc = crc ^ (crc >> 16) crc = crc ^ (crc >> 16)
res = s res = s
l = len(letters) l = len(letters)
for i in (0,1): for i in (0,1):
b = crc & 0xff b = crc & 0xff
pos = (b // l) ^ (b % l) pos = (b // l) ^ (b % l)
res += letters[pos%l] res += bytes(bytearray([letters[pos%l]]))
crc >>= 8 crc >>= 8
return res return res
@ -43,16 +43,19 @@ def pidFromSerial(s, l):
arr1 = [0]*l arr1 = [0]*l
for i in range(len(s)): for i in range(len(s)):
arr1[i%l] ^= s[i] if sys.version_info[0] == 2:
arr1[i%l] ^= ord(s[i])
else:
arr1[i%l] ^= s[i]
crc_bytes = [crc >> 24 & 0xff, crc >> 16 & 0xff, crc >> 8 & 0xff, crc & 0xff] crc_bytes = [crc >> 24 & 0xff, crc >> 16 & 0xff, crc >> 8 & 0xff, crc & 0xff]
for i in range(l): for i in range(l):
arr1[i] ^= crc_bytes[i&3] arr1[i] ^= crc_bytes[i&3]
pid = '' pid = b""
for i in range(l): for i in range(l):
b = arr1[i] & 0xff b = arr1[i] & 0xff
pid+=letters[(b >> 7) + ((b >> 5 & 3) ^ (b & 0x1f))] pid+=bytes(bytearray([letters[(b >> 7) + ((b >> 5 & 3) ^ (b & 0x1f))]]))
return pid return pid

View File

@ -80,11 +80,14 @@ import sys
import os import os
import struct import struct
import binascii import binascii
from alfcrypto import Pukall_Cipher
from utilities import SafeUnbuffered
from argv_utils import unicode_argv #@@CALIBRE_COMPAT_CODE@@
from .alfcrypto import Pukall_Cipher
from .utilities import SafeUnbuffered
from .argv_utils import unicode_argv
class DrmException(Exception): class DrmException(Exception):
@ -103,19 +106,26 @@ def PC1(key, src, decryption=True):
except: except:
raise raise
# accepts unicode returns unicode letters = b'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789'
def crc32(s):
return (~binascii.crc32(s,-1))&0xFFFFFFFF
def checksumPid(s): def checksumPid(s):
letters = 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789'
crc = (~binascii.crc32(s.encode('utf-8'),-1))&0xFFFFFFFF s = s.encode()
crc = crc32(s)
crc = crc ^ (crc >> 16) crc = crc ^ (crc >> 16)
res = s res = s
l = len(letters) l = len(letters)
for i in (0,1): for i in (0,1):
b = crc & 0xff b = crc & 0xff
pos = (b // l) ^ (b % l) pos = (b // l) ^ (b % l)
res += letters[pos%l] res += bytes(bytearray([letters[pos%l]]))
crc >>= 8 crc >>= 8
return res return res.decode()
# expects bytearray # expects bytearray
def getSizeOfTrailingDataEntries(ptr, size, flags): def getSizeOfTrailingDataEntries(ptr, size, flags):
@ -124,7 +134,11 @@ def getSizeOfTrailingDataEntries(ptr, size, flags):
if size <= 0: if size <= 0:
return result return result
while True: while True:
v = ptr[size-1] if sys.version_info[0] == 2:
v = ord(ptr[size-1])
else:
v = ptr[size-1]
result |= (v & 0x7F) << bitpos result |= (v & 0x7F) << bitpos
bitpos += 7 bitpos += 7
size -= 1 size -= 1
@ -140,7 +154,10 @@ def getSizeOfTrailingDataEntries(ptr, size, flags):
# if multibyte data is included in the encryped data, we'll # if multibyte data is included in the encryped data, we'll
# have already cleared this flag. # have already cleared this flag.
if flags & 1: if flags & 1:
num += (ptr[size - num - 1] & 0x3) + 1 if sys.version_info[0] == 2:
num += (ord(ptr[size - num - 1]) & 0x3) + 1
else:
num += (ptr[size - num - 1] & 0x3) + 1
return num return num
@ -299,7 +316,10 @@ class MobiBook:
for pid in pidlist: for pid in pidlist:
bigpid = pid.encode('utf-8').ljust(16,b'\0') bigpid = pid.encode('utf-8').ljust(16,b'\0')
temp_key = PC1(keyvec1, bigpid, False) temp_key = PC1(keyvec1, bigpid, False)
temp_key_sum = sum(temp_key) & 0xff if sys.version_info[0] == 2:
temp_key_sum = sum(map(ord,temp_key)) & 0xff
else:
temp_key_sum = sum(temp_key) & 0xff
found_key = None found_key = None
for i in range(count): for i in range(count):
verification, size, type, cksum, cookie = struct.unpack('>LLLBxxx32s', data[i*0x30:i*0x30+0x30]) verification, size, type, cksum, cookie = struct.unpack('>LLLBxxx32s', data[i*0x30:i*0x30+0x30])
@ -315,7 +335,11 @@ class MobiBook:
# Then try the default encoding that doesn't require a PID # Then try the default encoding that doesn't require a PID
pid = '00000000' pid = '00000000'
temp_key = keyvec1 temp_key = keyvec1
temp_key_sum = sum(temp_key) & 0xff if sys.version_info[0] == 2:
temp_key_sum = sum(map(ord,temp_key)) & 0xff
else:
temp_key_sum = sum(temp_key) & 0xff
for i in range(count): for i in range(count):
verification, size, type, cksum, cookie = struct.unpack('>LLLBxxx32s', data[i*0x30:i*0x30+0x30]) verification, size, type, cksum, cookie = struct.unpack('>LLLBxxx32s', data[i*0x30:i*0x30+0x30])
if cksum == temp_key_sum: if cksum == temp_key_sum:

View File

@ -24,10 +24,10 @@ import traceback
from struct import pack from struct import pack
from struct import unpack from struct import unpack
from alfcrypto import Topaz_Cipher from .alfcrypto import Topaz_Cipher
from utilities import SafeUnbuffered from .utilities import SafeUnbuffered
from argv_utils import unicode_argv from .argv_utils import unicode_argv
#global switch #global switch

View File

@ -3,11 +3,13 @@ DeDRM tools for ebooks
This is a fork of Apprentice Harper's version of the DeDRM tools. Apprentice Harper said that the original version of the plugin [is no longer maintained](https://github.com/apprenticeharper/DeDRM_tools#no-longer-maintained), so I've taken over, merged a bunch of open PRs, and added a ton more features and bugfixes. This is a fork of Apprentice Harper's version of the DeDRM tools. Apprentice Harper said that the original version of the plugin [is no longer maintained](https://github.com/apprenticeharper/DeDRM_tools#no-longer-maintained), so I've taken over, merged a bunch of open PRs, and added a ton more features and bugfixes.
The latest stable (released) version is v10.0.3 which [can be downloaded here](https://github.com/noDRM/DeDRM_tools/releases/tag/v10.0.3). The latest stable (released) version is v10.0.3 which [can be downloaded here](https://github.com/noDRM/DeDRM_tools/releases/tag/v10.0.3). The latest beta is v10.0.9, as a release candidate for v10.1.0. It [can be downloaded here](https://github.com/noDRM/DeDRM_tools/releases/tag/v10.0.9).
Take a look at [the CHANGELOG](https://github.com/noDRM/DeDRM_tools/blob/master/CHANGELOG.md) to see a list of changes since the last version by Apprentice Harper (v7.2.1). This plugin will start with version v10.0.0. The latest alpha version is available [at this link](https://github.com/noDRM/DeDRM_tools_autorelease/releases). This version is completely untested and will contain the latest code changes in this repository. With each commit in this repository, a new automatic alpha version will be uploaded there. If you want the most up-to-date code to test things and are okay with the plugin occasionally breaking, you can download this version.
The v10.0.0 versions of this plugin should both work with Calibre 5.x (Python 3) as well as Calibre 4.x and lower (Python 2). If you encounter issues with this plugin in Calibre 4.x or lower, please open a bug report. Take a look at [the CHANGELOG](https://github.com/noDRM/DeDRM_tools/blob/master/CHANGELOG.md) to see a list of changes since the last version by Apprentice Harper (v7.2.1).
My version of the plugin should both work with Calibre 5.x/6.x (Python 3) as well as Calibre 4.x and lower (Python 2). If you encounter issues with this plugin in Calibre 4.x or lower, please open a bug report.
# Original README from Apprentice Harper # Original README from Apprentice Harper