%PDF- %PDF-
| Direktori : /usr/lib/calibre/calibre/devices/kindle/ |
| Current File : //usr/lib/calibre/calibre/devices/kindle/driver.py |
__license__ = 'GPL v3'
__copyright__ = '2009, John Schember <john at nachtimwald.com>'
__docformat__ = 'restructuredtext en'
'''
Device driver for Amazon's Kindle
'''
import datetime, os, re, json, hashlib, errno
from calibre.constants import DEBUG, filesystem_encoding
from calibre.devices.kindle.bookmark import Bookmark
from calibre.devices.usbms.driver import USBMS
from calibre import strftime, fsync, prints
from polyglot.builtins import as_bytes, as_unicode
'''
Notes on collections:
A collections cache is stored at system/collections.json
The cache is read only, changes made to it are overwritten (it is regenerated)
on device disconnect
A log of collection creation/manipulation is available at
system/userannotationlog
collections.json refers to books via a SHA1 hash of the absolute path to the
book (prefix is /mnt/us on my Kindle). The SHA1 hash may or may not be prefixed
by some characters, use the last 40 characters. For books from Amazon, the ASIN
is used instead.
Changing the metadata and resending the file doesn't seem to affect collections
Adding a book to a collection on the Kindle does not change the book file at all
(i.e. it is binary identical). Therefore collection information is not stored in
file metadata.
'''
def get_files_in(path):
if hasattr(os, 'scandir'):
for dir_entry in os.scandir(path):
if dir_entry.is_file(follow_symlinks=False):
yield dir_entry.name, dir_entry.stat(follow_symlinks=False)
else:
import stat
for x in os.listdir(path):
xp = os.path.join(path, x)
s = os.lstat(xp)
if stat.S_ISREG(s.st_mode):
yield x, s
class KINDLE(USBMS):
name = 'Kindle Device Interface'
gui_name = 'Amazon Kindle'
icon = I('devices/kindle.png')
description = _('Communicate with the Kindle e-book reader.')
author = 'John Schember'
supported_platforms = ['windows', 'osx', 'linux']
# Ordered list of supported formats
FORMATS = ['azw', 'mobi', 'prc', 'azw1', 'tpz', 'txt']
VENDOR_ID = [0x1949]
PRODUCT_ID = [0x0001]
BCD = [0x399]
VENDOR_NAME = 'KINDLE'
WINDOWS_MAIN_MEM = 'INTERNAL_STORAGE'
WINDOWS_CARD_A_MEM = 'CARD_STORAGE'
OSX_MAIN_MEM = 'Kindle Internal Storage Media'
OSX_CARD_A_MEM = 'Kindle Card Storage Media'
MAIN_MEMORY_VOLUME_LABEL = 'Kindle Main Memory'
STORAGE_CARD_VOLUME_LABEL = 'Kindle Storage Card'
EBOOK_DIR_MAIN = 'documents'
EBOOK_DIR_CARD_A = 'documents'
DELETE_EXTS = ['.mbp', '.tan', '.pdr', '.ea', '.apnx', '.phl']
SUPPORTS_SUB_DIRS = True
SUPPORTS_ANNOTATIONS = True
WIRELESS_FILE_NAME_PATTERN = re.compile(
r'(?P<title>[^-]+)-asin_(?P<asin>[a-zA-Z\d]{10,})-type_(?P<type>\w{4})-v_(?P<index>\d+).*')
VIRTUAL_BOOK_EXTENSIONS = frozenset({'kfx'})
VIRTUAL_BOOK_EXTENSION_MESSAGE = _(
'The following books are in KFX format. KFX is a virtual book format, and cannot'
' be transferred from the device. Instead, you should go to your "Manage my'
' content and devices" page on the Amazon homepage and download the book to your computer from there.'
' That will give you a regular AZW3 file that you can add to calibre normally.'
' Click "Show details" to see the list of books.'
)
def is_allowed_book_file(self, filename, path, prefix):
lpath = os.path.join(path, filename).partition(self.normalize_path(prefix))[2].replace('\\', '/')
return '.sdr/' not in lpath
@classmethod
def metadata_from_path(cls, path):
if path.endswith('.kfx'):
from calibre.ebooks.metadata.kfx import read_metadata_kfx
try:
kfx_path = path
with lopen(kfx_path, 'rb') as f:
if f.read(8) != b'\xeaDRMION\xee':
f.seek(0)
mi = read_metadata_kfx(f)
else:
kfx_path = os.path.join(path.rpartition('.')[0] + '.sdr', 'assets', 'metadata.kfx')
with lopen(kfx_path, 'rb') as mf:
mi = read_metadata_kfx(mf)
except Exception:
import traceback
traceback.print_exc()
if DEBUG:
prints('failed kfx path:', kfx_path)
mi = cls.metadata_from_formats([path])
else:
mi = cls.metadata_from_formats([path])
if mi.title == _('Unknown') or ('-asin' in mi.title and '-type' in mi.title):
path = as_unicode(path, filesystem_encoding, 'replace')
match = cls.WIRELESS_FILE_NAME_PATTERN.match(os.path.basename(path))
if match is not None:
mi.title = match.group('title')
return mi
def get_annotations(self, path_map):
MBP_FORMATS = ['azw', 'mobi', 'prc', 'txt']
mbp_formats = set(MBP_FORMATS)
PDR_FORMATS = ['pdf']
pdr_formats = set(PDR_FORMATS)
TAN_FORMATS = ['tpz', 'azw1']
tan_formats = set(TAN_FORMATS)
def get_storage():
storage = []
if self._main_prefix:
storage.append(os.path.join(self._main_prefix, self.EBOOK_DIR_MAIN))
if self._card_a_prefix:
storage.append(os.path.join(self._card_a_prefix, self.EBOOK_DIR_CARD_A))
if self._card_b_prefix:
storage.append(os.path.join(self._card_b_prefix, self.EBOOK_DIR_CARD_B))
return storage
def resolve_bookmark_paths(storage, path_map):
pop_list = []
book_ext = {}
for id in path_map:
file_fmts = set()
for fmt in path_map[id]['fmts']:
file_fmts.add(fmt)
bookmark_extension = None
if file_fmts.intersection(mbp_formats):
book_extension = list(file_fmts.intersection(mbp_formats))[0]
bookmark_extension = 'mbp'
elif file_fmts.intersection(tan_formats):
book_extension = list(file_fmts.intersection(tan_formats))[0]
bookmark_extension = 'tan'
elif file_fmts.intersection(pdr_formats):
book_extension = list(file_fmts.intersection(pdr_formats))[0]
bookmark_extension = 'pdr'
if bookmark_extension:
for vol in storage:
bkmk_path = path_map[id]['path'].replace(os.path.abspath('/<storage>'),vol)
bkmk_path = bkmk_path.replace('bookmark',bookmark_extension)
if os.path.exists(bkmk_path):
path_map[id] = bkmk_path
book_ext[id] = book_extension
break
else:
pop_list.append(id)
else:
pop_list.append(id)
# Remove non-existent bookmark templates
for id in pop_list:
path_map.pop(id)
return path_map, book_ext
def get_my_clippings(storage, bookmarked_books):
# add an entry for 'My Clippings.txt'
for vol in storage:
mc_path = os.path.join(vol,'My Clippings.txt')
if os.path.exists(mc_path):
return mc_path
return None
storage = get_storage()
path_map, book_ext = resolve_bookmark_paths(storage, path_map)
bookmarked_books = {}
for id in path_map:
bookmark_ext = path_map[id].rpartition('.')[2]
myBookmark = Bookmark(path_map[id], id, book_ext[id], bookmark_ext)
bookmarked_books[id] = self.UserAnnotation(type='kindle_bookmark', value=myBookmark)
mc_path = get_my_clippings(storage, bookmarked_books)
if mc_path:
timestamp = datetime.datetime.utcfromtimestamp(os.path.getmtime(mc_path))
bookmarked_books['clippings'] = self.UserAnnotation(type='kindle_clippings',
value=dict(path=mc_path,timestamp=timestamp))
# This returns as job.result in gui2.ui.annotations_fetched(self,job)
return bookmarked_books
def generate_annotation_html(self, bookmark):
from calibre.ebooks.BeautifulSoup import BeautifulSoup
# Returns <div class="user_annotations"> ... </div>
last_read_location = bookmark.last_read_location
timestamp = datetime.datetime.utcfromtimestamp(bookmark.timestamp)
percent_read = bookmark.percent_read
ka_soup = BeautifulSoup()
dtc = 0
divTag = ka_soup.new_tag('div')
divTag['class'] = 'user_annotations'
# Add the last-read location
if bookmark.book_format == 'pdf':
markup = _("%(time)s<br />Last page read: %(loc)d (%(pr)d%%)") % dict(
time=strftime('%x', timestamp.timetuple()),
loc=last_read_location,
pr=percent_read)
else:
markup = _("%(time)s<br />Last page read: Location %(loc)d (%(pr)d%%)") % dict(
time=strftime('%x', timestamp.timetuple()),
loc=last_read_location,
pr=percent_read)
spanTag = BeautifulSoup('<span style="font-weight:bold">' + markup + '</span>').find('span')
divTag.insert(dtc, spanTag)
dtc += 1
divTag.insert(dtc, ka_soup.new_tag('br'))
dtc += 1
if bookmark.user_notes:
user_notes = bookmark.user_notes
annotations = []
# Add the annotations sorted by location
# Italicize highlighted text
for location in sorted(user_notes):
if user_notes[location]['text']:
annotations.append(
_('<b>Location %(dl)d • %(typ)s</b><br />%(text)s<br />') % dict(
dl=user_notes[location]['displayed_location'],
typ=user_notes[location]['type'],
text=(user_notes[location]['text'] if
user_notes[location]['type'] == 'Note' else
'<i>%s</i>' % user_notes[location]['text'])))
else:
if bookmark.book_format == 'pdf':
annotations.append(
_('<b>Page %(dl)d • %(typ)s</b><br />') % dict(
dl=user_notes[location]['displayed_location'],
typ=user_notes[location]['type']))
else:
annotations.append(
_('<b>Location %(dl)d • %(typ)s</b><br />') % dict(
dl=user_notes[location]['displayed_location'],
typ=user_notes[location]['type']))
for annotation in annotations:
annot = BeautifulSoup('<span>' + annotation + '</span>').find('span')
divTag.insert(dtc, annot)
dtc += 1
ka_soup.insert(0,divTag)
return ka_soup
def add_annotation_to_library(self, db, db_id, annotation):
from calibre.ebooks.metadata import MetaInformation
from calibre.ebooks.BeautifulSoup import prettify
bm = annotation
ignore_tags = {'Catalog', 'Clippings'}
if bm.type == 'kindle_bookmark':
mi = db.get_metadata(db_id, index_is_id=True)
user_notes_soup = self.generate_annotation_html(bm.value)
if mi.comments:
a_offset = mi.comments.find('<div class="user_annotations">')
ad_offset = mi.comments.find('<hr class="annotations_divider" />')
if a_offset >= 0:
mi.comments = mi.comments[:a_offset]
if ad_offset >= 0:
mi.comments = mi.comments[:ad_offset]
if set(mi.tags).intersection(ignore_tags):
return
if mi.comments:
hrTag = user_notes_soup.new_tag('hr')
hrTag['class'] = 'annotations_divider'
user_notes_soup.insert(0, hrTag)
mi.comments += prettify(user_notes_soup)
else:
mi.comments = prettify(user_notes_soup)
# Update library comments
db.set_comment(db_id, mi.comments)
# Add bookmark file to db_id
db.add_format_with_hooks(db_id, bm.value.bookmark_extension,
bm.value.path, index_is_id=True)
elif bm.type == 'kindle_clippings':
# Find 'My Clippings' author=Kindle in database, or add
last_update = 'Last modified %s' % strftime('%x %X',bm.value['timestamp'].timetuple())
mc_id = list(db.data.search_getting_ids('title:"My Clippings"', '', sort_results=False))
if mc_id:
db.add_format_with_hooks(mc_id[0], 'TXT', bm.value['path'],
index_is_id=True)
mi = db.get_metadata(mc_id[0], index_is_id=True)
mi.comments = last_update
db.set_metadata(mc_id[0], mi)
else:
mi = MetaInformation('My Clippings', authors=['Kindle'])
mi.tags = ['Clippings']
mi.comments = last_update
db.add_books([bm.value['path']], ['txt'], [mi])
class KINDLE2(KINDLE):
name = 'Kindle 2/3/4/Touch/PaperWhite/Voyage Device Interface'
description = _('Communicate with the Kindle 2/3/4/Touch/PaperWhite/Voyage e-book reader.')
FORMATS = ['azw', 'mobi', 'azw3', 'prc', 'azw1', 'tpz', 'azw4', 'kfx', 'pobi', 'pdf', 'txt']
DELETE_EXTS = KINDLE.DELETE_EXTS + ['.mbp1', '.mbs', '.sdr', '.han']
# On the Touch, there's also .asc files, but not using the same basename
# (for X-Ray & End Actions), azw3f & azw3r files, but all of them are in
# the .sdr sidecar folder
PRODUCT_ID = [0x0002, 0x0004, 0x0324]
BCD = [0x0100, 0x0310, 0x401, 0x409]
# SUPPORTS_SUB_DIRS = False # Apparently the Paperwhite doesn't like files placed in subdirectories
# SUPPORTS_SUB_DIRS_FOR_SCAN = True
EXTRA_CUSTOMIZATION_MESSAGE = [
_('Send page number information when sending books') + ':::' + _(
'The Kindle 3 and newer versions can use page number information'
' in MOBI files. With this option, calibre will calculate and send'
' this information to the Kindle when uploading MOBI files by'
' USB. Note that the page numbers do not correspond to any paper'
' book.'),
_('Page count calculation method') + ':::' + '<p>' + _(
'There are multiple ways to generate the page number information.'
' If a page count is given then the book will be divided into that many pages.'
' Otherwise the number of pages will be approximated using one of the following'
' methods.<ul>'
' <li>fast: 2300 characters of uncompressed text per page.\n\n'
' <li>accurate: Based on the number of chapters, paragraphs, and visible lines in the book.'
' This method is designed to simulate an average paperback book where there are 32 lines per'
' page and a maximum of 70 characters per line.\n\n'
' <li>pagebreak: The "pagebreak" method uses the presence of <mbp:pagebreak> tags within'
' the book to determine pages.</ul>'
'Methods other than "fast" are going to be much slower.'
' Further, if "pagebreak" fails to determine a page count accurate will be used, and if '
' "accurate" fails fast will be used.'),
_('Custom column name to retrieve page counts from') + ':::' + _(
'If you have a custom column in your library that you use to'
' store the page count of books, you can have calibre use that'
' information, instead of calculating a page count. Specify the'
' name of the custom column here, for example, #pages.'),
_('Custom column name to retrieve calculation method from') + ':::' + _(
'If you have a custom column in your library that you use to'
' store the preferred method for calculating the number of pages'
' for a book, you can have calibre use that method instead of the'
' default one selected above. Specify the name of the custom'
' column here, for example, #pagemethod. The custom column should have the '
' values: fast, accurate or pagebreak.'),
_('Overwrite existing APNX on device') + ':::' + _(
'Uncheck this option to allow an APNX file existing on the device'
' to have priority over the version which calibre would send.'
' Since APNX files are usually deleted when a book is removed from'
' the Kindle, this is mostly useful when resending a book to the'
' device which is already on the device (e.g. after making a'
' modification).'),
]
EXTRA_CUSTOMIZATION_DEFAULT = [
True,
'fast',
'',
'',
True,
]
OPT_APNX = 0
OPT_APNX_METHOD = 1
OPT_APNX_CUST_COL = 2
OPT_APNX_METHOD_COL = 3
OPT_APNX_OVERWRITE = 4
EXTRA_CUSTOMIZATION_CHOICES = {OPT_APNX_METHOD:{'fast', 'accurate', 'pagebreak'}}
# x330 on the PaperWhite
# x262 on the Touch. Doesn't choke on x330, though.
# x470 on the Voyage, checked that it works on PW, Touch checked by eschwartz.
# x500 on the Oasis 2017. checked that it works on the PW3
THUMBNAIL_HEIGHT = 500
@classmethod
def migrate_extra_customization(cls, vals):
if isinstance(vals[cls.OPT_APNX_METHOD], bool):
# Previously this option used to be a bool
vals[cls.OPT_APNX_METHOD] = 'accurate' if vals[cls.OPT_APNX_METHOD] else 'fast'
return vals
def formats_to_scan_for(self):
ans = USBMS.formats_to_scan_for(self) | {'azw3', 'kfx'}
return ans
def books(self, oncard=None, end_session=True):
bl = USBMS.books(self, oncard=oncard, end_session=end_session)
# Read collections information
collections = os.path.join(self._main_prefix, 'system', 'collections.json')
if os.access(collections, os.R_OK):
try:
self.kindle_update_booklist(bl, collections)
except:
import traceback
traceback.print_exc()
return bl
def kindle_update_booklist(self, bl, collections):
with lopen(collections, 'rb') as f:
collections = f.read()
collections = json.loads(collections)
path_map = {}
for name, val in collections.items():
col = name.split('@')[0]
items = val.get('items', [])
for x in items:
x = x[-40:]
if x not in path_map:
path_map[x] = set()
path_map[x].add(col)
if path_map:
for book in bl:
path = '/mnt/us/'+book.lpath
h = hashlib.sha1(as_bytes(path)).hexdigest()
if h in path_map:
book.device_collections = list(sorted(path_map[h]))
def post_open_callback(self):
try:
self.sync_cover_thumbnails()
except Exception:
import traceback
traceback.print_exc()
# Detect if the product family needs .apnx files uploaded to sidecar folder
product_id = self.device_being_opened[1]
self.sidecar_apnx = False
if product_id > 0x3:
# Check if we need to put the apnx into a sidecar dir
for _, dirnames, _ in os.walk(self._main_prefix):
for x in dirnames:
if x.endswith('.sdr'):
self.sidecar_apnx = True
return
def upload_cover(self, path, filename, metadata, filepath):
'''
Upload sidecar files: cover thumbnails and page count
'''
# Upload the cover thumbnail
try:
self.upload_kindle_thumbnail(metadata, filepath)
except:
import traceback
traceback.print_exc()
# Upload the apnx file
self.upload_apnx(path, filename, metadata, filepath)
def amazon_system_thumbnails_dir(self):
return os.path.join(self._main_prefix, 'system', 'thumbnails')
def thumbpath_from_filepath(self, filepath):
from calibre.ebooks.metadata.kfx import (CONTAINER_MAGIC, read_book_key_kfx)
from calibre.ebooks.mobi.reader.headers import MetadataHeader
from calibre.utils.logging import default_log
thumb_dir = self.amazon_system_thumbnails_dir()
if not os.path.exists(thumb_dir):
return
with lopen(filepath, 'rb') as f:
is_kfx = f.read(4) == CONTAINER_MAGIC
f.seek(0)
uuid = cdetype = None
if is_kfx:
uuid, cdetype = read_book_key_kfx(f)
else:
mh = MetadataHeader(f, default_log)
if mh.exth is not None:
uuid = mh.exth.uuid
cdetype = mh.exth.cdetype
if not uuid or not cdetype:
return
return os.path.join(thumb_dir,
'thumbnail_{uuid}_{cdetype}_portrait.jpg'.format(
uuid=uuid, cdetype=cdetype))
def amazon_cover_bug_cache_dir(self):
# see https://www.mobileread.com/forums/showthread.php?t=329945
return os.path.join(self._main_prefix, 'amazon-cover-bug')
def upload_kindle_thumbnail(self, metadata, filepath):
coverdata = getattr(metadata, 'thumbnail', None)
if not coverdata or not coverdata[2]:
return
tp = self.thumbpath_from_filepath(filepath)
if tp:
with lopen(tp, 'wb') as f:
f.write(coverdata[2])
fsync(f)
cache_dir = self.amazon_cover_bug_cache_dir()
try:
os.mkdir(cache_dir)
except OSError:
pass
with lopen(os.path.join(cache_dir, os.path.basename(tp)), 'wb') as f:
f.write(coverdata[2])
fsync(f)
def sync_cover_thumbnails(self):
import shutil
# See https://www.mobileread.com/forums/showthread.php?t=329945
# for why this is needed
if DEBUG:
prints('Syncing cover thumbnails to workaround amazon cover bug')
dest_dir = self.amazon_system_thumbnails_dir()
src_dir = self.amazon_cover_bug_cache_dir()
if not os.path.exists(dest_dir) or not os.path.exists(src_dir):
return
count = 0
for name, src_stat_result in get_files_in(src_dir):
dest_path = os.path.join(dest_dir, name)
try:
dest_stat_result = os.lstat(dest_path)
except OSError:
needs_sync = True
else:
needs_sync = src_stat_result.st_size != dest_stat_result.st_size
if needs_sync:
count += 1
if DEBUG:
prints('Restoring cover thumbnail:', name)
with lopen(os.path.join(src_dir, name), 'rb') as src, lopen(dest_path, 'wb') as dest:
shutil.copyfileobj(src, dest)
fsync(dest)
if DEBUG:
prints(f'Restored {count} cover thumbnails that were destroyed by Amazon')
def delete_single_book(self, path):
try:
tp1 = self.thumbpath_from_filepath(path)
if tp1:
tp2 = os.path.join(self.amazon_cover_bug_cache_dir(), os.path.basename(tp1))
for tp in (tp1, tp2):
try:
os.remove(tp)
except OSError as err:
if err.errno != errno.ENOENT:
prints(f'Failed to delete thumbnail for {path!r} at {tp!r} with error: {err}')
except Exception:
import traceback
traceback.print_exc()
USBMS.delete_single_book(self, path)
def upload_apnx(self, path, filename, metadata, filepath):
from calibre.devices.kindle.apnx import APNXBuilder
opts = self.settings()
if not opts.extra_customization[self.OPT_APNX]:
return
if os.path.splitext(filepath.lower())[1] not in ('.azw', '.mobi',
'.prc', '.azw3'):
return
# Create the sidecar folder if necessary
if (self.sidecar_apnx):
path = os.path.join(os.path.dirname(filepath), filename+".sdr")
if not os.path.exists(path):
os.makedirs(path)
cust_col_name = opts.extra_customization[self.OPT_APNX_CUST_COL]
custom_page_count = 0
if cust_col_name:
try:
custom_page_count = int(metadata.get(cust_col_name, 0))
except:
pass
apnx_path = '%s.apnx' % os.path.join(path, filename)
apnx_builder = APNXBuilder()
# Check to see if there is an existing apnx file on Kindle we should keep.
if opts.extra_customization[self.OPT_APNX_OVERWRITE] or not os.path.exists(apnx_path):
try:
method = opts.extra_customization[self.OPT_APNX_METHOD]
cust_col_name = opts.extra_customization[self.OPT_APNX_METHOD_COL]
if cust_col_name:
try:
temp = str(metadata.get(cust_col_name)).lower()
if temp in self.EXTRA_CUSTOMIZATION_CHOICES[self.OPT_APNX_METHOD]:
method = temp
else:
print("Invalid method choice for this book (%r), ignoring." % temp)
except:
print('Could not retrieve override method choice, using default.')
apnx_builder.write_apnx(filepath, apnx_path, method=method, page_count=custom_page_count)
except:
print('Failed to generate APNX')
import traceback
traceback.print_exc()
class KINDLE_DX(KINDLE2):
name = 'Kindle DX Device Interface'
description = _('Communicate with the Kindle DX e-book reader.')
FORMATS = ['azw', 'mobi', 'prc', 'azw1', 'tpz', 'azw4', 'pobi', 'pdf', 'txt']
PRODUCT_ID = [0x0003]
BCD = [0x0100]
def upload_kindle_thumbnail(self, metadata, filepath):
pass
def delete_single_book(self, path):
USBMS.delete_single_book(self, path)
class KINDLE_FIRE(KINDLE2):
name = 'Kindle Fire Device Interface'
description = _('Communicate with the Kindle Fire')
gui_name = 'Fire'
FORMATS = ['azw3', 'azw', 'mobi', 'prc', 'azw1', 'tpz', 'azw4', 'kfx', 'pobi', 'pdf', 'txt']
PRODUCT_ID = [0x0006]
BCD = [0x216, 0x100]
EBOOK_DIR_MAIN = 'Documents'
SUPPORTS_SUB_DIRS = False
SCAN_FROM_ROOT = True
SUPPORTS_SUB_DIRS_FOR_SCAN = True
VENDOR_NAME = 'AMAZON'
WINDOWS_MAIN_MEM = 'KINDLE'
def upload_kindle_thumbnail(self, metadata, filepath):
pass
def delete_single_book(self, path):
USBMS.delete_single_book(self, path)