%PDF- %PDF-
Direktori : /lib/calibre/calibre/devices/smart_device_app/ |
Current File : //lib/calibre/calibre/devices/smart_device_app/driver.py |
#!/usr/bin/env python3 ''' Created on 29 Jun 2012 @author: charles ''' import hashlib import json import os import posixpath import random import select import socket import sys import threading import time import traceback from collections import defaultdict from errno import EAGAIN, EINTR from functools import wraps from threading import Thread from calibre import prints from calibre.constants import DEBUG, cache_dir, numeric_version from calibre.devices.errors import ( ControlError, InitialConnectionError, OpenFailed, OpenFeedback, PacketError, TimeoutError, UserFeedback ) from calibre.devices.interface import DevicePlugin, currently_connected_device from calibre.devices.usbms.books import Book, CollectionsBookList from calibre.devices.usbms.deviceconfig import DeviceConfig from calibre.devices.usbms.driver import USBMS from calibre.devices.utils import build_template_regexp, sanity_check from calibre.ebooks import BOOK_EXTENSIONS from calibre.ebooks.metadata import title_sort from calibre.ebooks.metadata.book.base import Metadata from calibre.ebooks.metadata.book.json_codec import JsonCodec from calibre.library import current_library_name from calibre.ptempfile import PersistentTemporaryFile from calibre.utils.config_base import tweaks from calibre.utils.filenames import ascii_filename as sanitize, shorten_components_to from calibre.utils.ipc import eintr_retry_call from calibre.utils.mdns import ( get_all_ips, publish as publish_zeroconf, unpublish as unpublish_zeroconf ) from calibre.utils.socket_inheritance import set_socket_inherit from polyglot import queue from polyglot.builtins import as_bytes, iteritems, itervalues def synchronous(tlockname): """A decorator to place an instance based lock around a method """ def _synched(func): @wraps(func) def _synchronizer(self, *args, **kwargs): with self.__getattribute__(tlockname): return func(self, *args, **kwargs) return _synchronizer return _synched class ConnectionListener(Thread): def __init__(self, driver): Thread.__init__(self) self.daemon = True self.driver = driver self.keep_running = True self.all_ip_addresses = dict() def stop(self): self.keep_running = False def _close_socket(self, the_socket): try: the_socket.shutdown(socket.SHUT_RDWR) except: # the shutdown can fail if the socket isn't fully connected. Ignore it pass the_socket.close() def run(self): device_socket = None get_all_ips(reinitialize=True) while self.keep_running: try: time.sleep(1) except: # Happens during interpreter shutdown break if not self.keep_running: break if not self.all_ip_addresses: self.all_ip_addresses = get_all_ips() if self.all_ip_addresses: self.driver._debug("All IP addresses", self.all_ip_addresses) if not self.driver.connection_queue.empty(): d = currently_connected_device.device if d is not None: self.driver._debug('queue not serviced', d.get_gui_name()) try: sock = self.driver.connection_queue.get_nowait() s = self.driver._json_encode( self.driver.opcodes['CALIBRE_BUSY'], {'otherDevice': d.get_gui_name()}) self.driver._send_byte_string(device_socket, (b'%d' % len(s)) + as_bytes(s)) sock.close() except queue.Empty: pass if getattr(self.driver, 'broadcast_socket', None) is not None: while True: ans = select.select((self.driver.broadcast_socket,), (), (), 0) if len(ans[0]) > 0: try: packet = self.driver.broadcast_socket.recvfrom(100) remote = packet[1] content_server_port = '' try: from calibre.srv.opts import server_config content_server_port = str(server_config().port) except Exception: pass message = (self.driver.ZEROCONF_CLIENT_STRING + ' (on ' + str(socket.gethostname().partition('.')[0]) + ');' + content_server_port + ',' + str(self.driver.port)).encode('utf-8') self.driver._debug('received broadcast', packet, message) self.driver.broadcast_socket.sendto(message, remote) except: pass else: break if self.driver.connection_queue.empty() and \ getattr(self.driver, 'listen_socket', None) is not None: ans = select.select((self.driver.listen_socket,), (), (), 0) if len(ans[0]) > 0: # timeout in 100 ms to detect rare case where the socket goes # away between the select and the accept try: self.driver._debug('attempt to open device socket') device_socket = None self.driver.listen_socket.settimeout(0.100) device_socket, ign = eintr_retry_call( self.driver.listen_socket.accept) set_socket_inherit(device_socket, False) self.driver.listen_socket.settimeout(None) device_socket.settimeout(None) try: self.driver.connection_queue.put_nowait(device_socket) except queue.Full: self._close_socket(device_socket) device_socket = None self.driver._debug('driver is not answering') except socket.timeout: pass except OSError: x = sys.exc_info()[1] self.driver._debug('unexpected socket exception', x.args[0]) self._close_socket(device_socket) device_socket = None # raise class SDBook(Book): def __init__(self, prefix, lpath, size=None, other=None): Book.__init__(self, prefix, lpath, size=size, other=other) path = getattr(self, 'path', lpath) self.path = path.replace('\\', '/') class SMART_DEVICE_APP(DeviceConfig, DevicePlugin): name = 'SmartDevice App Interface' gui_name = _('Wireless device') gui_name_template = '%s: %s' icon = I('devices/tablet.png') description = _('Communicate with Smart Device apps') supported_platforms = ['windows', 'osx', 'linux'] author = 'Charles Haley' version = (0, 0, 1) # Invalid USB vendor information so the scanner will never match VENDOR_ID = [0xffff] PRODUCT_ID = [0xffff] BCD = [0xffff] FORMATS = list(BOOK_EXTENSIONS) ALL_FORMATS = list(BOOK_EXTENSIONS) HIDE_FORMATS_CONFIG_BOX = True USER_CAN_ADD_NEW_FORMATS = False DEVICE_PLUGBOARD_NAME = 'SMART_DEVICE_APP' CAN_SET_METADATA = [] CAN_DO_DEVICE_DB_PLUGBOARD = False SUPPORTS_SUB_DIRS = True MUST_READ_METADATA = True NEWS_IN_FOLDER = True SUPPORTS_USE_AUTHOR_SORT = False WANTS_UPDATED_THUMBNAILS = True MANAGES_DEVICE_PRESENCE = True # Guess about the max length on windows. This number will be reduced by # the length of the path on the client, and by the fudge factor below. We # use this on all platforms because the device might be connected to windows # in the future. MAX_PATH_LEN = 250 # guess of length of MTP name. The length of the full path to the folder # on the device is added to this. That path includes the device's mount point # making this number effectively around 10 to 15 larger. PATH_FUDGE_FACTOR = 40 THUMBNAIL_HEIGHT = 160 DEFAULT_THUMBNAIL_HEIGHT = 160 THUMBNAIL_COMPRESSION_QUALITY = 75 DEFAULT_THUMBNAIL_COMPRESSION_QUALITY = 75 PREFIX = '' BACKLOADING_ERROR_MESSAGE = None SAVE_TEMPLATE = '{title} - {authors} ({id})' # Some network protocol constants BASE_PACKET_LEN = 4096 PROTOCOL_VERSION = 1 MAX_CLIENT_COMM_TIMEOUT = 300.0 # Wait at most N seconds for an answer MAX_UNSUCCESSFUL_CONNECTS = 5 SEND_NOOP_EVERY_NTH_PROBE = 5 DISCONNECT_AFTER_N_SECONDS = 30*60 # 30 minutes PURGE_CACHE_ENTRIES_DAYS = 30 CURRENT_CC_VERSION = 128 ZEROCONF_CLIENT_STRING = 'calibre wireless device client' # A few "random" port numbers to use for detecting clients using broadcast # The clients are expected to broadcast a UDP 'hi there' on all of these # ports when they attempt to connect. Calibre will respond with the port # number the client should use. This scheme backs up mdns. And yes, we # must hope that no other application on the machine is using one of these # ports in datagram mode. # If you change the ports here, all clients will also need to change. BROADCAST_PORTS = [54982, 48123, 39001, 44044, 59678] opcodes = { 'NOOP' : 12, 'OK' : 0, 'BOOK_DONE' : 11, 'CALIBRE_BUSY' : 18, 'SET_LIBRARY_INFO' : 19, 'DELETE_BOOK' : 13, 'DISPLAY_MESSAGE' : 17, 'ERROR' : 20, 'FREE_SPACE' : 5, 'GET_BOOK_FILE_SEGMENT' : 14, 'GET_BOOK_METADATA' : 15, 'GET_BOOK_COUNT' : 6, 'GET_DEVICE_INFORMATION' : 3, 'GET_INITIALIZATION_INFO': 9, 'SEND_BOOKLISTS' : 7, 'SEND_BOOK' : 8, 'SEND_BOOK_METADATA' : 16, 'SET_CALIBRE_DEVICE_INFO': 1, 'SET_CALIBRE_DEVICE_NAME': 2, 'TOTAL_SPACE' : 4, } reverse_opcodes = {v: k for k, v in iteritems(opcodes)} MESSAGE_PASSWORD_ERROR = 1 MESSAGE_UPDATE_NEEDED = 2 MESSAGE_SHOW_TOAST = 3 ALL_BY_TITLE = _('All by title') ALL_BY_AUTHOR = _('All by author') ALL_BY_SOMETHING = _('All by something') EXTRA_CUSTOMIZATION_MESSAGE = [ _('Enable connections at startup') + ':::<p>' + _('Check this box to allow connections when calibre starts') + '</p>', '', _('Security password') + ':::<p>' + _('Enter a password that the device app must use to connect to calibre') + '</p>', '', _('Use fixed network port') + ':::<p>' + _('If checked, use the port number in the "Port" box, otherwise ' 'the driver will pick a random port') + '</p>', _('Port number: ') + ':::<p>' + _('Enter the port number the driver is to use if the "fixed port" box is checked') + '</p>', _('Print extra debug information') + ':::<p>' + _('Check this box if requested when reporting problems') + '</p>', '', _('Comma separated list of metadata fields ' 'to turn into collections on the device.') + ':::<p>' + _('Possibilities include: series, tags, authors, etc' + '. Three special collections are available: %(abt)s:%(abtv)s, ' '%(aba)s:%(abav)s, and %(abs)s:%(absv)s. Add ' 'these values to the list to enable them. The collections will be ' 'given the name provided after the ":" character.')%dict( abt='abt', abtv=ALL_BY_TITLE, aba='aba', abav=ALL_BY_AUTHOR, abs='abs', absv=ALL_BY_SOMETHING), '', _('Enable the no-activity timeout') + ':::<p>' + _('If this box is checked, calibre will automatically disconnect if ' 'a connected device does nothing for %d minutes. Unchecking this ' ' box disables this timeout, so calibre will never automatically ' 'disconnect.')%(DISCONNECT_AFTER_N_SECONDS/60,) + '</p>', _('Use this IP address') + ':::<p>' + _('Use this option if you want to force the driver to listen on a ' 'particular IP address. The driver will listen only on the ' 'entered address, and this address will be the one advertized ' 'over mDNS (BonJour).') + '</p>', _('Replace books with same calibre ID') + ':::<p>' + _('Use this option to overwrite a book on the device if that book ' 'has the same calibre identifier as the book being sent. The file name of the ' 'book will not change even if the save template produces a ' 'different result. Using this option in most cases prevents ' 'having multiple copies of a book on the device.') + '</p>', _('Cover thumbnail compression quality') + ':::<p>' + _('Use this option to control the size and quality of the cover ' 'file sent to the device. It must be between 50 and 99. ' 'The larger the number the higher quality the cover, but also ' 'the larger the file. For example, changing this from 70 to 90 ' 'results in a much better cover that is approximately 2.5 ' 'times as big. To see the changes you must force calibre ' 'to resend metadata to the device, either by changing ' 'the metadata for the book (updating the last modification ' 'time) or resending the book itself.') + '</p>', _('Use metadata cache') + ':::<p>' + _('Setting this option allows calibre to keep a copy of metadata ' 'on the device, speeding up device connections. Unsetting this ' 'option disables keeping the copy, forcing the device to send ' 'metadata to calibre on every connect. Unset this option if ' 'you think that the cache might not be operating correctly.') + '</p>', '', _('Additional file extensions to send to the device') + ':::<p>' + _('This is a comma-separated list of format file extensions you want ' 'to be able to send to the device. For example, you might have ' 'audio books in your library with the extension "m4b" that you ' 'want to listen to on your device. Don\'t worry about the "extra ' 'enabled extensions" warning.'), _('Ignore device free space') + ':::<p>' + _("Check this box to ignore the amount of free space reported by your " "devices. This might be needed if you store books on an SD card and " "the device doesn't have much free main memory.") + '</p>', ] EXTRA_CUSTOMIZATION_DEFAULT = [ False, '', '', '', False, '9090', False, '', '', '', False, '', True, '75', True, '', '', False, ] OPT_AUTOSTART = 0 OPT_PASSWORD = 2 OPT_USE_PORT = 4 OPT_PORT_NUMBER = 5 OPT_EXTRA_DEBUG = 6 OPT_COLLECTIONS = 8 OPT_AUTODISCONNECT = 10 OPT_FORCE_IP_ADDRESS = 11 OPT_OVERWRITE_BOOKS_UUID = 12 OPT_COMPRESSION_QUALITY = 13 OPT_USE_METADATA_CACHE = 14 OPT_EXTRA_EXTENSIONS = 16 OPT_IGNORE_FREESPACE = 17 OPTNAME_TO_NUMBER_MAP = { 'password': OPT_PASSWORD, 'autostart': OPT_AUTOSTART, 'use_fixed_port': OPT_USE_PORT, 'port_number': OPT_PORT_NUMBER, 'force_ip_address': OPT_FORCE_IP_ADDRESS, 'thumbnail_compression_quality': OPT_COMPRESSION_QUALITY, } def __init__(self, path): self.sync_lock = threading.RLock() self.noop_counter = 0 self.debug_start_time = time.time() self.debug_time = time.time() self.is_connected = False monkeypatch_zeroconf() # Don't call this method from the GUI unless you are sure that there is no # network traffic in progress. Otherwise the gui might hang waiting for the # network timeout def _debug(self, *args): # manual synchronization so we don't lose the calling method name import inspect with self.sync_lock: if not DEBUG: return total_elapsed = time.time() - self.debug_start_time elapsed = time.time() - self.debug_time print('SMART_DEV (%7.2f:%7.3f) %s'%(total_elapsed, elapsed, inspect.stack()[1][3]), end='') for a in args: try: if isinstance(a, dict): printable = {} for k,v in iteritems(a): if isinstance(v, (bytes, str)) and len(v) > 50: printable[k] = 'too long' else: printable[k] = v prints('', printable, end='') else: prints('', a, end='') except: prints('', 'value too long', end='') print() self.debug_time = time.time() # local utilities # copied from USBMS. Perhaps this could be a classmethod in usbms? def _update_driveinfo_record(self, dinfo, prefix, location_code, name=None): import uuid from calibre.utils.date import isoformat, now if not isinstance(dinfo, dict): dinfo = {} if dinfo.get('device_store_uuid', None) is None: dinfo['device_store_uuid'] = str(uuid.uuid4()) if dinfo.get('device_name') is None: dinfo['device_name'] = self.get_gui_name() if name is not None: dinfo['device_name'] = name dinfo['location_code'] = location_code dinfo['last_library_uuid'] = getattr(self, 'current_library_uuid', None) dinfo['calibre_version'] = '.'.join([str(i) for i in numeric_version]) dinfo['date_last_connected'] = isoformat(now()) dinfo['prefix'] = self.PREFIX return dinfo # copied with changes from USBMS.Device. In particular, we needed to # remove the 'path' argument and all its uses. Also removed the calls to # filename_callback and sanitize_path_components def _create_upload_path(self, mdata, fname, create_dirs=True): fname = sanitize(fname) ext = os.path.splitext(fname)[1] try: # If we have already seen this book's UUID, use the existing path if self.settings().extra_customization[self.OPT_OVERWRITE_BOOKS_UUID]: existing_book = self._uuid_in_cache(mdata.uuid, ext) if (existing_book and existing_book.lpath and self.known_metadata.get(existing_book.lpath, None)): return existing_book.lpath # If the device asked for it, try to use the UUID as the file name. # Fall back to the ch if the UUID doesn't exist. if self.client_wants_uuid_file_names and mdata.uuid: return (mdata.uuid + ext) except: pass dotless_ext = ext[1:] if len(ext) > 0 else ext maxlen = (self.MAX_PATH_LEN - (self.PATH_FUDGE_FACTOR + self.exts_path_lengths.get(dotless_ext, self.PATH_FUDGE_FACTOR))) special_tag = None if mdata.tags: for t in mdata.tags: if t.startswith(_('News')) or t.startswith('/'): special_tag = t break settings = self.settings() template = self.save_template() if mdata.tags and _('News') in mdata.tags: try: p = mdata.pubdate date = (p.year, p.month, p.day) except: today = time.localtime() date = (today[0], today[1], today[2]) template = "{title}_%d-%d-%d" % date use_subdirs = self.SUPPORTS_SUB_DIRS and settings.use_subdirs from calibre.library.save_to_disk import config, get_components opts = config().parse() if not isinstance(template, str): template = template.decode('utf-8') app_id = str(getattr(mdata, 'application_id', '')) id_ = mdata.get('id', fname) extra_components = get_components(template, mdata, id_, timefmt=opts.send_timefmt, length=maxlen-len(app_id)-1, last_has_extension=False) if not extra_components: extra_components.append(sanitize(fname)) else: extra_components[-1] = sanitize(extra_components[-1]+ext) if extra_components[-1] and extra_components[-1][0] in ('.', '_'): extra_components[-1] = 'x' + extra_components[-1][1:] if special_tag is not None: name = extra_components[-1] extra_components = [] tag = special_tag if tag.startswith(_('News')): if self.NEWS_IN_FOLDER: extra_components.append('News') else: for c in tag.split('/'): c = sanitize(c) if not c: continue extra_components.append(c) extra_components.append(name) if not use_subdirs: # Leave this stuff here in case we later decide to use subdirs extra_components = extra_components[-1:] def remove_trailing_periods(x): ans = x while ans.endswith('.'): ans = ans[:-1].strip() if not ans: ans = 'x' return ans extra_components = list(map(remove_trailing_periods, extra_components)) components = shorten_components_to(maxlen, extra_components) filepath = posixpath.join(*components) self._debug('lengths', dotless_ext, maxlen, self.exts_path_lengths.get(dotless_ext, self.PATH_FUDGE_FACTOR), len(filepath)) return filepath def _strip_prefix(self, path): if self.PREFIX and path.startswith(self.PREFIX): return path[len(self.PREFIX):] return path # JSON booklist encode & decode # If the argument is a booklist or contains a book, use the metadata json # codec to first convert it to a string dict def _json_encode(self, op, arg): res = {} for k,v in iteritems(arg): if isinstance(v, (Book, Metadata)): res[k] = self.json_codec.encode_book_metadata(v) series = v.get('series', None) if series: tsorder = tweaks['save_template_title_series_sorting'] series = title_sort(series, order=tsorder) else: series = '' self._debug('series sort = ', series) res[k]['_series_sort_'] = series else: res[k] = v from calibre.utils.config import to_json return json.dumps([op, res], default=to_json) # Network functions def _read_binary_from_net(self, length): try: self.device_socket.settimeout(self.MAX_CLIENT_COMM_TIMEOUT) v = self.device_socket.recv(length) self.device_socket.settimeout(None) return v except: self._close_device_socket() raise def _read_string_from_net(self): data = b'0' while True: dex = data.find(b'[') if dex >= 0: break # recv seems to return a pointer into some internal buffer. # Things get trashed if we don't make a copy of the data. v = self._read_binary_from_net(2) if len(v) == 0: return b'' # documentation says the socket is broken permanently. data += v total_len = int(data[:dex]) data = data[dex:] pos = len(data) while pos < total_len: v = self._read_binary_from_net(total_len - pos) if len(v) == 0: return b'' # documentation says the socket is broken permanently. data += v pos += len(v) return data def _send_byte_string(self, sock, s): if not isinstance(s, bytes): self._debug('given a non-byte string!') self._close_device_socket() raise PacketError("Internal error: found a string that isn't bytes") sent_len = 0 total_len = len(s) while sent_len < total_len: try: sock.settimeout(self.MAX_CLIENT_COMM_TIMEOUT) if sent_len == 0: amt_sent = sock.send(s) else: amt_sent = sock.send(s[sent_len:]) sock.settimeout(None) if amt_sent <= 0: raise OSError('Bad write on socket') sent_len += amt_sent except OSError as e: self._debug('socket error', e, e.errno) if e.args[0] != EAGAIN and e.args[0] != EINTR: self._close_device_socket() raise time.sleep(0.1) # lets not hammer the OS too hard except: self._close_device_socket() raise # This must be protected by a lock because it is called from the GUI thread # (the sync stuff) and the device manager thread @synchronous('sync_lock') def _call_client(self, op, arg, print_debug_info=True, wait_for_response=True): if op != 'NOOP': self.noop_counter = 0 extra_debug = self.settings().extra_customization[self.OPT_EXTRA_DEBUG] if print_debug_info or extra_debug: if extra_debug: self._debug(op, 'wfr', wait_for_response, arg) else: self._debug(op, 'wfr', wait_for_response) if self.device_socket is None: return None, None try: s = self._json_encode(self.opcodes[op], arg) if print_debug_info and extra_debug: self._debug('send string', s) self._send_byte_string(self.device_socket, (b'%d' % len(s)) + as_bytes(s)) if not wait_for_response: return None, None return self._receive_from_client(print_debug_info=print_debug_info) except socket.timeout: self._debug('timeout communicating with device') self._close_device_socket() raise TimeoutError('Device did not respond in reasonable time') except OSError: self._debug('device went away') self._close_device_socket() raise ControlError(desc='Device closed the network connection') except: self._debug('other exception') traceback.print_exc() self._close_device_socket() raise raise ControlError(desc='Device responded with incorrect information') def _receive_from_client(self, print_debug_info=True): from calibre.utils.config import from_json extra_debug = self.settings().extra_customization[self.OPT_EXTRA_DEBUG] try: v = self._read_string_from_net() if print_debug_info and extra_debug: self._debug('received string', v) if v: v = json.loads(v, object_hook=from_json) if print_debug_info and extra_debug: self._debug('receive after decode') # , v) return (self.reverse_opcodes[v[0]], v[1]) self._debug('protocol error -- empty json string') except socket.timeout: self._debug('timeout communicating with device') self._close_device_socket() raise TimeoutError('Device did not respond in reasonable time') except OSError: self._debug('device went away') self._close_device_socket() raise ControlError(desc='Device closed the network connection') except: self._debug('other exception') traceback.print_exc() self._close_device_socket() raise raise ControlError(desc='Device responded with incorrect information') # Write a file to the device as a series of binary strings. def _put_file(self, infile, lpath, book_metadata, this_book, total_books): close_ = False if not hasattr(infile, 'read'): infile, close_ = lopen(infile, 'rb'), True infile.seek(0, os.SEEK_END) length = infile.tell() book_metadata.size = length infile.seek(0) opcode, result = self._call_client('SEND_BOOK', {'lpath': lpath, 'length': length, 'metadata': book_metadata, 'thisBook': this_book, 'totalBooks': total_books, 'willStreamBooks': True, 'willStreamBinary' : True, 'wantsSendOkToSendbook' : self.can_send_ok_to_sendbook, 'canSupportLpathChanges': True}, print_debug_info=False, wait_for_response=self.can_send_ok_to_sendbook) if self.can_send_ok_to_sendbook: if opcode == 'ERROR': raise UserFeedback(msg='Sending book %s to device failed' % lpath, details=result.get('message', ''), level=UserFeedback.ERROR) return lpath = result.get('lpath', lpath) book_metadata.lpath = lpath self._set_known_metadata(book_metadata) pos = 0 failed = False with infile: while True: b = infile.read(self.max_book_packet_len) blen = len(b) if not b: break self._send_byte_string(self.device_socket, b) pos += blen self.time = None if close_: infile.close() return (-1, None) if failed else (length, lpath) def _metadata_in_cache(self, uuid, ext_or_lpath, lastmod): from calibre.utils.date import now, parse_date try: key = self._make_metadata_cache_key(uuid, ext_or_lpath) if isinstance(lastmod, str): if lastmod == 'None': return None lastmod = parse_date(lastmod) if key in self.device_book_cache and self.device_book_cache[key]['book'].last_modified == lastmod: self.device_book_cache[key]['last_used'] = now() return self.device_book_cache[key]['book'].deepcopy(lambda : SDBook('', '')) except: traceback.print_exc() return None def _metadata_already_on_device(self, book): try: v = self.known_metadata.get(book.lpath, None) if v is not None: # Metadata is the same if the uuids match, if the last_modified dates # match, and if the height of the thumbnails is the same. The last # is there to allow a device to demand a different thumbnail size if (v.get('uuid', None) == book.get('uuid', None) and v.get('last_modified', None) == book.get('last_modified', None)): v_thumb = v.get('thumbnail', None) b_thumb = book.get('thumbnail', None) if bool(v_thumb) != bool(b_thumb): return False return not v_thumb or v_thumb[1] == b_thumb[1] except: traceback.print_exc() return False def _uuid_in_cache(self, uuid, ext): try: for b in itervalues(self.device_book_cache): metadata = b['book'] if metadata.get('uuid', '') != uuid: continue if metadata.get('lpath', '').endswith(ext): return metadata except: traceback.print_exc() return None def _read_metadata_cache(self): self._debug('device uuid', self.device_uuid) from calibre.utils.config import from_json try: old_cache_file_name = os.path.join(cache_dir(), 'device_drivers_' + self.__class__.__name__ + '_metadata_cache.pickle') if os.path.exists(old_cache_file_name): os.remove(old_cache_file_name) except: pass try: old_cache_file_name = os.path.join(cache_dir(), 'device_drivers_' + self.__class__.__name__ + '_metadata_cache.json') if os.path.exists(old_cache_file_name): os.remove(old_cache_file_name) except: pass cache_file_name = os.path.join(cache_dir(), 'wireless_device_' + self.device_uuid + '_metadata_cache.json') self.device_book_cache = defaultdict(dict) self.known_metadata = {} try: count = 0 if os.path.exists(cache_file_name): with lopen(cache_file_name, mode='rb') as fd: while True: rec_len = fd.readline() if len(rec_len) != 8: break raw = fd.read(int(rec_len)) book = json.loads(raw.decode('utf-8'), object_hook=from_json) key = list(book.keys())[0] metadata = self.json_codec.raw_to_book(book[key]['book'], SDBook, self.PREFIX) book[key]['book'] = metadata self.device_book_cache.update(book) lpath = metadata.get('lpath') self.known_metadata[lpath] = metadata count += 1 self._debug('loaded', count, 'cache items') except: traceback.print_exc() self.device_book_cache = defaultdict(dict) self.known_metadata = {} try: if os.path.exists(cache_file_name): os.remove(cache_file_name) except: traceback.print_exc() def _write_metadata_cache(self): self._debug() from calibre.utils.date import now now_ = now() from calibre.utils.config import to_json try: purged = 0 count = 0 prefix = os.path.join(cache_dir(), 'wireless_device_' + self.device_uuid + '_metadata_cache') with lopen(prefix + '.tmp', mode='wb') as fd: for key,book in iteritems(self.device_book_cache): if (now_ - book['last_used']).days > self.PURGE_CACHE_ENTRIES_DAYS: purged += 1 continue json_metadata = defaultdict(dict) json_metadata[key]['book'] = self.json_codec.encode_book_metadata(book['book']) json_metadata[key]['last_used'] = book['last_used'] result = as_bytes(json.dumps(json_metadata, indent=2, default=to_json)) fd.write(("%0.7d\n"%(len(result)+1)).encode('ascii')) fd.write(result) fd.write(b'\n') count += 1 self._debug('wrote', count, 'entries, purged', purged, 'entries') from calibre.utils.filenames import atomic_rename atomic_rename(fd.name, prefix + '.json') except: traceback.print_exc() def _make_metadata_cache_key(self, uuid, lpath_or_ext): key = None if uuid and lpath_or_ext: key = uuid + lpath_or_ext return key def _set_known_metadata(self, book, remove=False): from calibre.utils.date import now lpath = book.lpath ext = os.path.splitext(lpath)[1] uuid = book.get('uuid', None) if self.client_cache_uses_lpaths: key = self._make_metadata_cache_key(uuid, lpath) else: key = self._make_metadata_cache_key(uuid, ext) if remove: self.known_metadata.pop(lpath, None) if key: self.device_book_cache.pop(key, None) else: # Check if we have another UUID with the same lpath. If so, remove it # Must try both the extension and the lpath because of the cache change existing_uuid = self.known_metadata.get(lpath, {}).get('uuid', None) if existing_uuid and existing_uuid != uuid: self.device_book_cache.pop(self._make_metadata_cache_key(existing_uuid, ext), None) self.device_book_cache.pop(self._make_metadata_cache_key(existing_uuid, lpath), None) new_book = book.deepcopy() self.known_metadata[lpath] = new_book if key: self.device_book_cache[key]['book'] = new_book self.device_book_cache[key]['last_used'] = now() # Force close a socket. The shutdown permits the close even if data transfer # is in progress def _close_socket(self, the_socket): try: the_socket.shutdown(socket.SHUT_RDWR) except: # the shutdown can fail if the socket isn't fully connected. Ignore it pass the_socket.close() def _close_device_socket(self): if self.device_socket is not None: try: self._close_socket(self.device_socket) except: pass self.device_socket = None self._write_metadata_cache() self.is_connected = False def _attach_to_port(self, sock, port): try: ip_addr = self.settings().extra_customization[self.OPT_FORCE_IP_ADDRESS] self._debug('try ip address "'+ ip_addr + '"', 'on port', port) if ip_addr: sock.bind((ip_addr, port)) else: sock.bind(('', port)) except OSError: self._debug('socket error on port', port) port = 0 except: self._debug('Unknown exception while attaching port to socket') traceback.print_exc() raise return port def _close_listen_socket(self): self._close_socket(self.listen_socket) self.listen_socket = None self.is_connected = False if getattr(self, 'broadcast_socket', None) is not None: self._close_socket(self.broadcast_socket) self.broadcast_socket = None def _read_file_metadata(self, temp_file_name): from calibre.customize.ui import quick_metadata from calibre.ebooks.metadata.meta import get_metadata ext = temp_file_name.rpartition('.')[-1].lower() with lopen(temp_file_name, 'rb') as stream: with quick_metadata: return get_metadata(stream, stream_type=ext, force_read_metadata=True, pattern=build_template_regexp(self.save_template())) # The public interface methods. @synchronous('sync_lock') def detect_managed_devices(self, devices_on_system, force_refresh=False): if getattr(self, 'listen_socket', None) is None: self.is_connected = False if self.is_connected: self.noop_counter += 1 if (self.noop_counter > self.SEND_NOOP_EVERY_NTH_PROBE and (self.noop_counter % self.SEND_NOOP_EVERY_NTH_PROBE) != 1): try: ans = select.select((self.device_socket,), (), (), 0) if len(ans[0]) == 0: return self # The socket indicates that something is there. Given the # protocol, this can only be a disconnect notification. Fall # through and actually try to talk to the client. # This will usually toss an exception if the socket is gone. except: pass if (self.settings().extra_customization[self.OPT_AUTODISCONNECT] and self.noop_counter > self.DISCONNECT_AFTER_N_SECONDS): self._close_device_socket() self._debug('timeout -- disconnected') else: try: if self._call_client('NOOP', dict())[0] is None: self._close_device_socket() except: self._close_device_socket() return self if self.is_connected else None if getattr(self, 'listen_socket', None) is not None: try: ans = self.connection_queue.get_nowait() self.device_socket = ans self.is_connected = True try: peer = self.device_socket.getpeername()[0] attempts = self.connection_attempts.get(peer, 0) if attempts >= self.MAX_UNSUCCESSFUL_CONNECTS: self._debug('too many connection attempts from', peer) self._close_device_socket() raise InitialConnectionError(_('Too many connection attempts from %s') % peer) else: self.connection_attempts[peer] = attempts + 1 except InitialConnectionError: raise except: pass except queue.Empty: self.is_connected = False return self if self.is_connected else None return None @synchronous('sync_lock') def debug_managed_device_detection(self, devices_on_system, output): from functools import partial p = partial(prints, file=output) if self.is_connected: p("A wireless device is connected") return True all_ip_addresses = get_all_ips() if all_ip_addresses: p("All IP addresses", all_ip_addresses) else: p("No IP addresses found") p("No device is connected") return False @synchronous('sync_lock') def open(self, connected_device, library_uuid): from calibre.utils.date import isoformat, now self._debug() if not self.is_connected: # We have been called to retry the connection. Give up immediately raise ControlError(desc='Attempt to open a closed device') self.current_library_uuid = library_uuid self.current_library_name = current_library_name() self.device_uuid = '' try: password = self.settings().extra_customization[self.OPT_PASSWORD] if password: challenge = isoformat(now()) hasher = hashlib.sha1() hasher.update(password.encode('UTF-8')) hasher.update(challenge.encode('UTF-8')) hash_digest = hasher.hexdigest() else: challenge = '' hash_digest = '' formats = self.ALL_FORMATS[:] extras = [f.lower() for f in self.settings().extra_customization[self.OPT_EXTRA_EXTENSIONS].split(',') if f] formats.extend(extras) opcode, result = self._call_client('GET_INITIALIZATION_INFO', {'serverProtocolVersion': self.PROTOCOL_VERSION, 'validExtensions': formats, 'passwordChallenge': challenge, 'currentLibraryName': self.current_library_name, 'currentLibraryUUID': library_uuid, 'pubdateFormat': tweaks['gui_pubdate_display_format'], 'timestampFormat': tweaks['gui_timestamp_display_format'], 'lastModifiedFormat': tweaks['gui_last_modified_display_format'], 'calibre_version': numeric_version, 'canSupportUpdateBooks': True, 'canSupportLpathChanges': True}) if opcode != 'OK': # Something wrong with the return. Close the socket # and continue. self._debug('Protocol error - Opcode not OK') self._close_device_socket() return False if not result.get('versionOK', False): # protocol mismatch self._debug('Protocol error - protocol version mismatch') self._close_device_socket() return False if result.get('maxBookContentPacketLen', 0) <= 0: # protocol mismatch self._debug('Protocol error - bogus book packet length') self._close_device_socket() return False # Set up to recheck the sync columns self.have_checked_sync_columns = False client_can_stream_books = result.get('canStreamBooks', False) self._debug('Device can stream books', client_can_stream_books) client_can_stream_metadata = result.get('canStreamMetadata', False) self._debug('Device can stream metadata', client_can_stream_metadata) client_can_receive_book_binary = result.get('canReceiveBookBinary', False) self._debug('Device can receive book binary', client_can_receive_book_binary) client_can_delete_multiple = result.get('canDeleteMultipleBooks', False) self._debug('Device can delete multiple books', client_can_delete_multiple) if not (client_can_stream_books and client_can_stream_metadata and client_can_receive_book_binary and client_can_delete_multiple): self._debug('Software on device too old') self._close_device_socket() raise OpenFeedback(_('The app on your device is too old and is no ' 'longer supported. Update it to a newer version.')) self.client_can_use_metadata_cache = result.get('canUseCachedMetadata', False) self._debug('Device can use cached metadata', self.client_can_use_metadata_cache) self.client_cache_uses_lpaths = result.get('cacheUsesLpaths', False) self._debug('Cache uses lpaths', self.client_cache_uses_lpaths) self.can_send_ok_to_sendbook = result.get('canSendOkToSendbook', False) self._debug('Can send OK to sendbook', self.can_send_ok_to_sendbook) self.can_accept_library_info = result.get('canAcceptLibraryInfo', False) self._debug('Can accept library info', self.can_accept_library_info) self.will_ask_for_update_books = result.get('willAskForUpdateBooks', False) self._debug('Will ask for update books', self.will_ask_for_update_books) self.set_temp_mark_when_syncing_read = \ result.get('setTempMarkWhenReadInfoSynced', False) self._debug('Will set temp mark when syncing read', self.set_temp_mark_when_syncing_read) if not self.settings().extra_customization[self.OPT_USE_METADATA_CACHE]: self.client_can_use_metadata_cache = False self._debug('metadata caching disabled by option') self.client_device_kind = result.get('deviceKind', '') self._debug('Client device kind', self.client_device_kind) self.client_device_name = result.get('deviceName', self.client_device_kind) self._debug('Client device name', self.client_device_name) self.client_app_name = result.get('appName', "") self._debug('Client app name', self.client_app_name) self.app_version_number = result.get('ccVersionNumber', '0') self._debug('App version #:', self.app_version_number) try: if (self.client_app_name == 'CalibreCompanion' and self.app_version_number < self.CURRENT_CC_VERSION): self._debug('Telling client to update') self._call_client("DISPLAY_MESSAGE", {'messageKind': self.MESSAGE_UPDATE_NEEDED, 'lastestKnownAppVersion': self.CURRENT_CC_VERSION}) except: pass self.max_book_packet_len = result.get('maxBookContentPacketLen', self.BASE_PACKET_LEN) self._debug('max_book_packet_len', self.max_book_packet_len) exts = result.get('acceptedExtensions', None) if exts is None or not isinstance(exts, list) or len(exts) == 0: self._debug('Protocol error - bogus accepted extensions') self._close_device_socket() return False self.client_wants_uuid_file_names = result.get('useUuidFileNames', False) self._debug('Device wants UUID file names', self.client_wants_uuid_file_names) config = self._configProxy() config['format_map'] = exts self._debug('selected formats', config['format_map']) self.exts_path_lengths = result.get('extensionPathLengths', {}) self._debug('extension path lengths', self.exts_path_lengths) self.THUMBNAIL_HEIGHT = result.get('coverHeight', self.DEFAULT_THUMBNAIL_HEIGHT) self._debug('cover height', self.THUMBNAIL_HEIGHT) if 'coverWidth' in result: # Setting this field forces the aspect ratio self.THUMBNAIL_WIDTH = result.get('coverWidth', (self.DEFAULT_THUMBNAIL_HEIGHT/3) * 4) self._debug('cover width', self.THUMBNAIL_WIDTH) elif hasattr(self, 'THUMBNAIL_WIDTH'): delattr(self, 'THUMBNAIL_WIDTH') self.is_read_sync_col = result.get('isReadSyncCol', None) self._debug('Device is_read sync col', self.is_read_sync_col) self.is_read_date_sync_col = result.get('isReadDateSyncCol', None) self._debug('Device is_read_date sync col', self.is_read_date_sync_col) if password: returned_hash = result.get('passwordHash', None) if result.get('passwordHash', None) is None: # protocol mismatch self._debug('Protocol error - missing password hash') self._close_device_socket() return False if returned_hash != hash_digest: # bad password self._debug('password mismatch') try: self._call_client("DISPLAY_MESSAGE", {'messageKind': self.MESSAGE_PASSWORD_ERROR, 'currentLibraryName': self.current_library_name, 'currentLibraryUUID': library_uuid}) except: pass self._close_device_socket() # Don't bother with a message. The user will be informed on # the device. raise OpenFailed('') try: peer = self.device_socket.getpeername()[0] self.connection_attempts[peer] = 0 except: pass return True except socket.timeout: self._close_device_socket() except OSError: x = sys.exc_info()[1] self._debug('unexpected socket exception', x.args[0]) self._close_device_socket() raise return False def get_gui_name(self): if getattr(self, 'client_device_name', None): return self.gui_name_template%(self.gui_name, self.client_device_name) if getattr(self, 'client_device_kind', None): return self.gui_name_template%(self.gui_name, self.client_device_kind) return self.gui_name def config_widget(self): from calibre.gui2.device_drivers.configwidget import ConfigWidget cw = ConfigWidget(self.settings(), self.FORMATS, self.SUPPORTS_SUB_DIRS, self.MUST_READ_METADATA, self.SUPPORTS_USE_AUTHOR_SORT, self.EXTRA_CUSTOMIZATION_MESSAGE, self) return cw @synchronous('sync_lock') def get_device_information(self, end_session=True): self._debug() self.report_progress(1.0, _('Get device information...')) opcode, result = self._call_client('GET_DEVICE_INFORMATION', dict()) if opcode == 'OK': self.driveinfo = result['device_info'] self._update_driveinfo_record(self.driveinfo, self.PREFIX, 'main') self.device_uuid = self.driveinfo['device_store_uuid'] self._call_client('SET_CALIBRE_DEVICE_INFO', self.driveinfo) self._read_metadata_cache() return (self.get_gui_name(), result['device_version'], result['version'], '', {'main':self.driveinfo}) return (self.get_gui_name(), '', '', '') @synchronous('sync_lock') def set_driveinfo_name(self, location_code, name): self._update_driveinfo_record(self.driveinfo, "main", name) self._call_client('SET_CALIBRE_DEVICE_NAME', {'location_code': 'main', 'name':name}) @synchronous('sync_lock') def reset(self, key='-1', log_packets=False, report_progress=None, detected_device=None) : self._debug() self.set_progress_reporter(report_progress) @synchronous('sync_lock') def set_progress_reporter(self, report_progress): self._debug() self.report_progress = report_progress if self.report_progress is None: self.report_progress = lambda x, y: x @synchronous('sync_lock') def card_prefix(self, end_session=True): self._debug() return (None, None) @synchronous('sync_lock') def total_space(self, end_session=True): self._debug() opcode, result = self._call_client('TOTAL_SPACE', {}) if opcode == 'OK': return (result['total_space_on_device'], 0, 0) # protocol error if we get here return (0, 0, 0) @synchronous('sync_lock') def free_space(self, end_session=True): self._debug() opcode, result = self._call_client('FREE_SPACE', {}) if opcode == 'OK': self._debug('free space:', result['free_space_on_device']) return (result['free_space_on_device'], 0, 0) # protocol error if we get here return (0, 0, 0) @synchronous('sync_lock') def books(self, oncard=None, end_session=True): self._debug(oncard) if oncard is not None: return CollectionsBookList(None, None, None) opcode, result = self._call_client('GET_BOOK_COUNT', {'canStream':True, 'canScan':True, 'willUseCachedMetadata': self.client_can_use_metadata_cache, 'supportsSync': (bool(self.is_read_sync_col) or bool(self.is_read_date_sync_col)), 'canSupportBookFormatSync': True}) bl = CollectionsBookList(None, self.PREFIX, self.settings) if opcode == 'OK': count = result['count'] will_use_cache = self.client_can_use_metadata_cache if will_use_cache: books_on_device = [] self._debug('caching. count=', count) for i in range(0, count): opcode, result = self._receive_from_client(print_debug_info=False) books_on_device.append(result) self._debug('received all books. count=', count) books_to_send = [] lpaths_on_device = set() for r in books_on_device: if r.get('lpath', None): book = self._metadata_in_cache(r['uuid'], r['lpath'], r['last_modified']) else: book = self._metadata_in_cache(r['uuid'], r['extension'], r['last_modified']) if book: if self.client_cache_uses_lpaths: lpaths_on_device.add(r.get('lpath')) bl.add_book_extended(book, replace_metadata=True, check_for_duplicates=not self.client_cache_uses_lpaths) book.set('_is_read_', r.get('_is_read_', None)) book.set('_sync_type_', r.get('_sync_type_', None)) book.set('_last_read_date_', r.get('_last_read_date_', None)) book.set('_format_mtime_', r.get('_format_mtime_', None)) else: books_to_send.append(r['priKey']) self._debug('processed cache. count=', len(books_on_device)) count_of_cache_items_deleted = 0 if self.client_cache_uses_lpaths: for lpath in tuple(self.known_metadata): if lpath not in lpaths_on_device: try: uuid = self.known_metadata[lpath].get('uuid', None) if uuid is not None: key = self._make_metadata_cache_key(uuid, lpath) self.device_book_cache.pop(key, None) self.known_metadata.pop(lpath, None) count_of_cache_items_deleted += 1 except: self._debug('Exception while deleting book from caches', lpath) traceback.print_exc() self._debug('removed', count_of_cache_items_deleted, 'books from caches') count = len(books_to_send) self._debug('caching. Need count from device', count) self._call_client('NOOP', {'count': count}, print_debug_info=False, wait_for_response=False) for priKey in books_to_send: self._call_client('NOOP', {'priKey':priKey}, print_debug_info=False, wait_for_response=False) for i in range(0, count): if (i % 100) == 0: self._debug('getting book metadata. Done', i, 'of', count) opcode, result = self._receive_from_client(print_debug_info=False) if opcode == 'OK': try: if '_series_sort_' in result: del result['_series_sort_'] book = self.json_codec.raw_to_book(result, SDBook, self.PREFIX) book.set('_is_read_', result.get('_is_read_', None)) book.set('_sync_type_', result.get('_sync_type_', None)) book.set('_last_read_date_', result.get('_last_read_date_', None)) bl.add_book_extended(book, replace_metadata=True, check_for_duplicates=not self.client_cache_uses_lpaths) if '_new_book_' in result: book.set('_new_book_', True) else: self._set_known_metadata(book) except: self._debug('exception retrieving metadata for book', result.get('title', 'Unknown')) traceback.print_exc() else: raise ControlError(desc='book metadata not returned') total = 0 for book in bl: if book.get('_new_book_', None): total += 1 count = 0 for book in bl: if book.get('_new_book_', None): paths = [book.lpath] self._set_known_metadata(book, remove=True) self.prepare_addable_books(paths, this_book=count, total_books=total) book.smart_update(self._read_file_metadata(paths[0])) del book._new_book_ count += 1 self._debug('finished getting book metadata') return bl @synchronous('sync_lock') def sync_booklists(self, booklists, end_session=True): colattrs = [x.strip() for x in self.settings().extra_customization[self.OPT_COLLECTIONS].split(',')] self._debug('collection attributes', colattrs) coldict = {} if colattrs: collections = booklists[0].get_collections(colattrs) for k,v in iteritems(collections): lpaths = [] for book in v: lpaths.append(book.lpath) coldict[k] = lpaths # If we ever do device_db plugboards, this is where it will go. We will # probably need to send two booklists, one with calibre's data that is # given back by "books", and one that has been plugboarded. books_to_send = [] for book in booklists[0]: if (book.get('_force_send_metadata_', None) or not self._metadata_already_on_device(book)): books_to_send.append(book) count = len(books_to_send) self._call_client('SEND_BOOKLISTS', {'count': count, 'collections': coldict, 'willStreamMetadata': True, 'supportsSync': (bool(self.is_read_sync_col) or bool(self.is_read_date_sync_col))}, wait_for_response=False) if count: for i,book in enumerate(books_to_send): self._debug('sending metadata for book', book.lpath, book.title) self._set_known_metadata(book) opcode, result = self._call_client( 'SEND_BOOK_METADATA', {'index': i, 'count': count, 'data': book, 'supportsSync': (bool(self.is_read_sync_col) or bool(self.is_read_date_sync_col))}, print_debug_info=False, wait_for_response=False) if not self.have_bad_sync_columns: # Update the local copy of the device's read info just in case # the device is re-synced. This emulates what happens on the device # when the metadata is received. try: if bool(self.is_read_sync_col): book.set('_is_read_', book.get(self.is_read_sync_col, None)) except: self._debug('failed to set local copy of _is_read_') traceback.print_exc() try: if bool(self.is_read_date_sync_col): book.set('_last_read_date_', book.get(self.is_read_date_sync_col, None)) except: self._debug('failed to set local copy of _last_read_date_') traceback.print_exc() # Write the cache here so that if we are interrupted on disconnect then the # almost-latest info will be available. self._write_metadata_cache() @synchronous('sync_lock') def eject(self): self._debug() self._call_client('NOOP', {'ejecting': True}) self._close_device_socket() @synchronous('sync_lock') def post_yank_cleanup(self): self._debug() @synchronous('sync_lock') def upload_books(self, files, names, on_card=None, end_session=True, metadata=None): if self.settings().extra_customization[self.OPT_EXTRA_DEBUG]: self._debug(names) else: self._debug() if not self.settings().extra_customization[self.OPT_IGNORE_FREESPACE]: sanity_check(on_card='', files=files, card_prefixes=[], free_space=self.free_space()) paths = [] names = iter(names) metadata = iter(metadata) for i, infile in enumerate(files): mdata, fname = next(metadata), next(names) lpath = self._create_upload_path(mdata, fname, create_dirs=False) self._debug('lpath', lpath) if not hasattr(infile, 'read'): infile = USBMS.normalize_path(infile) book = SDBook(self.PREFIX, lpath, other=mdata) length, lpath = self._put_file(infile, lpath, book, i, len(files)) if length < 0: raise ControlError(desc='Sending book %s to device failed' % lpath) paths.append((lpath, length)) # No need to deal with covers. The client will get the thumbnails # in the mi structure self.report_progress((i + 1) / float(len(files)), _('Transferring books to device...')) self.report_progress(1.0, _('Transferring books to device...')) self._debug('finished uploading %d books' % (len(files))) return paths @synchronous('sync_lock') def add_books_to_metadata(self, locations, metadata, booklists): self._debug('adding metadata for %d books' % (len(metadata))) metadata = iter(metadata) for i, location in enumerate(locations): self.report_progress((i + 1) / float(len(locations)), _('Adding books to device metadata listing...')) info = next(metadata) lpath = location[0] length = location[1] lpath = self._strip_prefix(lpath) book = SDBook(self.PREFIX, lpath, other=info) if book.size is None: book.size = length b = booklists[0].add_book(book, replace_metadata=True) if b: b._new_book = True from calibre.utils.date import isoformat, now b.set('_format_mtime_', isoformat(now())) self.report_progress(1.0, _('Adding books to device metadata listing...')) self._debug('finished adding metadata') @synchronous('sync_lock') def delete_books(self, paths, end_session=True): if self.settings().extra_customization[self.OPT_EXTRA_DEBUG]: self._debug(paths) else: self._debug() new_paths = [] for path in paths: new_paths.append(self._strip_prefix(path)) opcode, result = self._call_client('DELETE_BOOK', {'lpaths': new_paths}) for i in range(0, len(new_paths)): opcode, result = self._receive_from_client(False) self._debug('removed book with UUID', result['uuid']) self._debug('removed', len(new_paths), 'books') @synchronous('sync_lock') def remove_books_from_metadata(self, paths, booklists): if self.settings().extra_customization[self.OPT_EXTRA_DEBUG]: self._debug(paths) else: self._debug() for i, path in enumerate(paths): path = self._strip_prefix(path) self.report_progress((i + 1) / float(len(paths)), _('Removing books from device metadata listing...')) for bl in booklists: for book in bl: if path == book.path: bl.remove_book(book) self._set_known_metadata(book, remove=True) self.report_progress(1.0, _('Removing books from device metadata listing...')) self._debug('finished removing metadata for %d books' % (len(paths))) @synchronous('sync_lock') def get_file(self, path, outfile, end_session=True, this_book=None, total_books=None): if self.settings().extra_customization[self.OPT_EXTRA_DEBUG]: self._debug(path) else: self._debug() eof = False position = 0 while not eof: opcode, result = self._call_client('GET_BOOK_FILE_SEGMENT', {'lpath' : path, 'position': position, 'thisBook': this_book, 'totalBooks': total_books, 'canStream':True, 'canStreamBinary': True}, print_debug_info=False) if opcode == 'OK': length = result.get('fileLength') remaining = length while remaining > 0: v = self._read_binary_from_net(min(remaining, self.max_book_packet_len)) outfile.write(v) remaining -= len(v) eof = True else: raise ControlError(desc='request for book data failed') @synchronous('sync_lock') def prepare_addable_books(self, paths, this_book=None, total_books=None): for idx, path in enumerate(paths): (ign, ext) = os.path.splitext(path) with PersistentTemporaryFile(suffix=ext) as tf: self.get_file(path, tf, this_book=this_book, total_books=total_books) paths[idx] = tf.name tf.name = path return paths @synchronous('sync_lock') def set_plugboards(self, plugboards, pb_func): self._debug() self.plugboards = plugboards self.plugboard_func = pb_func @synchronous('sync_lock') def set_library_info(self, library_name, library_uuid, field_metadata): self._debug(library_name, library_uuid) if self.can_accept_library_info: other_info = {} from calibre.ebooks.metadata.sources.prefs import msprefs other_info['id_link_rules'] = msprefs.get('id_link_rules', {}) self._call_client('SET_LIBRARY_INFO', {'libraryName' : library_name, 'libraryUuid': library_uuid, 'fieldMetadata': field_metadata.all_metadata(), 'otherInfo': other_info}, print_debug_info=True) @synchronous('sync_lock') def specialize_global_preferences(self, device_prefs): device_prefs.set_overrides(manage_device_metadata='on_connect') def _show_message(self, message): self._call_client("DISPLAY_MESSAGE", {'messageKind': self.MESSAGE_SHOW_TOAST, 'message': message}) def _check_if_format_send_needed(self, db, id_, book): if not self.will_ask_for_update_books: return (None, False) from calibre.utils.date import isoformat, parse_date try: if not hasattr(book, '_format_mtime_'): return (None, False) ext = posixpath.splitext(book.lpath)[1][1:] fmt_metadata = db.new_api.format_metadata(id_, ext) if fmt_metadata: calibre_mtime = fmt_metadata['mtime'] if calibre_mtime > self.now: if not self.have_sent_future_dated_book_message: self.have_sent_future_dated_book_message = True self._show_message(_('You have book formats in your library ' 'with dates in the future. See calibre ' 'for details')) return (None, True) cc_mtime = parse_date(book.get('_format_mtime_'), as_utc=True) self._debug(book.title, 'cal_mtime', calibre_mtime, 'cc_mtime', cc_mtime) if cc_mtime < calibre_mtime: book.set('_format_mtime_', isoformat(self.now)) return (posixpath.basename(book.lpath), False) except: self._debug('exception checking if must send format', book.title) traceback.print_exc() return (None, False) @synchronous('sync_lock') def synchronize_with_db(self, db, id_, book, first_call): from calibre.utils.date import is_date_undefined, now, parse_date if first_call: self.have_sent_future_dated_book_message = False self.now = now() if self.have_bad_sync_columns or not (self.is_read_sync_col or self.is_read_date_sync_col): # Not syncing or sync columns are invalid return (None, self._check_if_format_send_needed(db, id_, book)) # Check the validity of the columns once per connection. We do it # here because we have access to the db to get field_metadata if not self.have_checked_sync_columns: fm = db.field_metadata.custom_field_metadata() if self.is_read_sync_col: if self.is_read_sync_col not in fm: self._debug('is_read_sync_col not in field_metadata') self._show_message(_("The read sync column %s is " "not in calibre's library")%self.is_read_sync_col) self.have_bad_sync_columns = True elif fm[self.is_read_sync_col]['datatype'] != 'bool': self._debug('is_read_sync_col not bool type') self._show_message(_("The read sync column %s is " "not a Yes/No column")%self.is_read_sync_col) self.have_bad_sync_columns = True if self.is_read_date_sync_col: if self.is_read_date_sync_col not in fm: self._debug('is_read_date_sync_col not in field_metadata') self._show_message(_("The read date sync column %s is " "not in calibre's library")%self.is_read_date_sync_col) self.have_bad_sync_columns = True elif fm[self.is_read_date_sync_col]['datatype'] != 'datetime': self._debug('is_read_date_sync_col not date type') self._show_message(_("The read date sync column %s is " "not a date column")%self.is_read_date_sync_col) self.have_bad_sync_columns = True self.have_checked_sync_columns = True if self.have_bad_sync_columns: return (None, self._check_if_format_send_needed(db, id_, book)) # if we are marking synced books, clear all the current marks if self.set_temp_mark_when_syncing_read: self._debug('clearing temp marks') db.set_marked_ids(()) sync_type = book.get('_sync_type_', None) # We need to check if our attributes are in the book. If they are not # then this is metadata coming from calibre to the device for the first # time, in which case we must not sync it. if hasattr(book, '_is_read_'): is_read = book.get('_is_read_', None) has_is_read = True else: has_is_read = False if hasattr(book, '_last_read_date_'): # parse_date returns UNDEFINED_DATE if the value is None is_read_date = parse_date(book.get('_last_read_date_', None)) if is_date_undefined(is_read_date): is_read_date = None has_is_read_date = True else: has_is_read_date = False force_return_changed_books = False changed_books = set() if sync_type == 3: # The book metadata was built by the device from metadata in the # book file itself. It must not be synced, because the metadata is # almost surely wrong. However, the fact that we got here means that # book matching has succeeded. Arrange that calibre's metadata is # sent back to the device. This isn't strictly necessary as sending # back the info will be arranged in other ways. self._debug('Book with device-generated metadata', book.get('title', 'huh?')) book.set('_force_send_metadata_', True) force_return_changed_books = True elif sync_type == 2: # This is a special case where the user just set a sync column. In # this case the device value wins if it is not None, otherwise the # calibre value wins. # Check is_read if has_is_read and self.is_read_sync_col: try: calibre_val = db.new_api.field_for(self.is_read_sync_col, id_, default_value=None) if is_read is not None: # The CC value wins. Check if it is different from calibre's # value to avoid updating the db to the same value if is_read != calibre_val: self._debug('special update calibre to is_read', book.get('title', 'huh?'), 'to', is_read, calibre_val) changed_books = db.new_api.set_field(self.is_read_sync_col, {id_: is_read}) if self.set_temp_mark_when_syncing_read: db.data.toggle_marked_ids({id_}) elif calibre_val is not None: # Calibre value wins. Force the metadata for the # book to be sent to the device even if the mod # dates haven't changed. self._debug('special update is_read to calibre value', book.get('title', 'huh?'), 'to', calibre_val) book.set('_force_send_metadata_', True) force_return_changed_books = True except: self._debug('exception special syncing is_read', self.is_read_sync_col) traceback.print_exc() # Check is_read_date. if has_is_read_date and self.is_read_date_sync_col: try: # The db method returns None for undefined dates. calibre_val = db.new_api.field_for(self.is_read_date_sync_col, id_, default_value=None) if is_read_date is not None: if is_read_date != calibre_val: self._debug('special update calibre to is_read_date', book.get('title', 'huh?'), 'to', is_read_date, calibre_val) changed_books |= db.new_api.set_field(self.is_read_date_sync_col, {id_: is_read_date}) if self.set_temp_mark_when_syncing_read: db.data.toggle_marked_ids({id_}) elif calibre_val is not None: self._debug('special update is_read_date to calibre value', book.get('title', 'huh?'), 'to', calibre_val) book.set('_force_send_metadata_', True) force_return_changed_books = True except: self._debug('exception special syncing is_read_date', self.is_read_sync_col) traceback.print_exc() else: # This is the standard sync case. If the CC value has changed, it # wins, otherwise the calibre value is synced to CC in the normal # fashion (mod date) if has_is_read and self.is_read_sync_col: try: orig_is_read = book.get(self.is_read_sync_col, None) if is_read != orig_is_read: # The value in the device's is_read checkbox is not the # same as the last one that came to the device from # calibre during the last connect, meaning that the user # changed it. Write the one from the device to calibre's # db. self._debug('standard update is_read', book.get('title', 'huh?'), 'to', is_read, 'was', orig_is_read) changed_books = db.new_api.set_field(self.is_read_sync_col, {id_: is_read}) if self.set_temp_mark_when_syncing_read: db.data.toggle_marked_ids({id_}) except: self._debug('exception standard syncing is_read', self.is_read_sync_col) traceback.print_exc() if has_is_read_date and self.is_read_date_sync_col: try: orig_is_read_date = book.get(self.is_read_date_sync_col, None) if is_date_undefined(orig_is_read_date): orig_is_read_date = None if is_read_date != orig_is_read_date: self._debug('standard update is_read_date', book.get('title', 'huh?'), 'to', is_read_date, 'was', orig_is_read_date) changed_books |= db.new_api.set_field(self.is_read_date_sync_col, {id_: is_read_date}) if self.set_temp_mark_when_syncing_read: db.data.toggle_marked_ids({id_}) except: self._debug('Exception standard syncing is_read_date', self.is_read_date_sync_col) traceback.print_exc() if changed_books or force_return_changed_books: # One of the two values was synced, giving a (perhaps empty) list of # changed books. Return that. return (changed_books, self._check_if_format_send_needed(db, id_, book)) # Nothing was synced. The user might have changed the value in calibre. # If so, that value will be sent to the device in the normal way. Note # that because any updated value has already been synced and so will # also be sent, the device should put the calibre value into its # checkbox (or whatever it uses) return (None, self._check_if_format_send_needed(db, id_, book)) @synchronous('sync_lock') def startup(self): self.listen_socket = None self.is_connected = False def _startup_on_demand(self): if getattr(self, 'listen_socket', None) is not None: # we are already running return message = None # The driver is not running so must be started. It needs to protect itself # from access by the device thread before it is fully setup. Thus the lock. with self.sync_lock: if len(self.opcodes) != len(self.reverse_opcodes): self._debug(self.opcodes, self.reverse_opcodes) self.is_connected = False self.listen_socket = None self.device_socket = None self.json_codec = JsonCodec() self.known_metadata = {} self.device_book_cache = defaultdict(dict) self.debug_time = time.time() self.debug_start_time = time.time() self.max_book_packet_len = 0 self.noop_counter = 0 self.connection_attempts = {} self.client_wants_uuid_file_names = False self.is_read_sync_col = None self.is_read_date_sync_col = None self.have_checked_sync_columns = False self.have_bad_sync_columns = False self.have_sent_future_dated_book_message = False self.now = None compression_quality_ok = True try: cq = int(self.settings().extra_customization[self.OPT_COMPRESSION_QUALITY]) if cq < 50 or cq > 99: compression_quality_ok = False else: self.THUMBNAIL_COMPRESSION_QUALITY = cq except: compression_quality_ok = False if not compression_quality_ok: self.THUMBNAIL_COMPRESSION_QUALITY = 70 message = _('Bad compression quality setting. It must be a number ' 'between 50 and 99. Forced to be %d.')%self.DEFAULT_THUMBNAIL_COMPRESSION_QUALITY self._debug(message) self.set_option('thumbnail_compression_quality', str(self.DEFAULT_THUMBNAIL_COMPRESSION_QUALITY)) try: self.listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) set_socket_inherit(self.listen_socket, False) except: traceback.print_exc() message = 'creation of listen socket failed' self._debug(message) return message i = 0 if self.settings().extra_customization[self.OPT_USE_PORT]: try: opt_port = int(self.settings().extra_customization[self.OPT_PORT_NUMBER]) except: message = _('Invalid port in options: %s')% \ self.settings().extra_customization[self.OPT_PORT_NUMBER] self._debug(message) self._close_listen_socket() return message port = self._attach_to_port(self.listen_socket, opt_port) if port == 0: message = _('Failed to connect to port %d. Try a different value.')%opt_port self._debug(message) self._close_listen_socket() return message else: while i < 100: # try 9090 then up to 99 random port numbers i += 1 port = self._attach_to_port(self.listen_socket, 9090 if i == 1 else random.randint(8192, 65525)) if port != 0: break if port == 0: message = _('Failed to allocate a random port') self._debug(message) self._close_listen_socket() return message try: self.listen_socket.listen(1) except: message = 'listen on port %d failed' % port self._debug(message) self._close_listen_socket() return message try: ip_addr = self.settings().extra_customization[self.OPT_FORCE_IP_ADDRESS] publish_zeroconf('calibre smart device client', '_calibresmartdeviceapp._tcp', port, {}, use_ip_address=ip_addr) except: self._debug('registration with bonjour failed') traceback.print_exc() self._debug('listening on port', port) self.port = port # Now try to open a UDP socket to receive broadcasts on try: self.broadcast_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) set_socket_inherit(self.broadcast_socket, False) except: message = 'creation of broadcast socket failed. This is not fatal.' self._debug(message) self.broadcast_socket = None else: for p in self.BROADCAST_PORTS: port = self._attach_to_port(self.broadcast_socket, p) if port != 0: self._debug('broadcast socket listening on port', port) break if port == 0: self._close_socket(self.broadcast_socket) self.broadcast_socket = None message = 'attaching port to broadcast socket failed. This is not fatal.' self._debug(message) self.connection_queue = queue.Queue(1) self.connection_listener = ConnectionListener(self) self.connection_listener.start() return message def _shutdown(self): # Force close any socket open by a device. This will cause any IO on the # socket to fail, eventually releasing the transaction lock. self._close_device_socket() # Now lockup so we can shutdown the control socket and unpublish mDNS with self.sync_lock: if getattr(self, 'listen_socket', None) is not None: self.connection_listener.stop() try: unpublish_zeroconf('calibre smart device client', '_calibresmartdeviceapp._tcp', self.port, {}) except: self._debug('deregistration with bonjour failed') traceback.print_exc() self._close_listen_socket() # Methods for dynamic control. Do not call _debug in these methods, as it # uses the sync lock. def is_dynamically_controllable(self): return 'smartdevice' def start_plugin(self): return self._startup_on_demand() def stop_plugin(self): self._shutdown() def get_option(self, opt_string, default=None): opt = self.OPTNAME_TO_NUMBER_MAP.get(opt_string) if opt is not None: return self.settings().extra_customization[opt] return default def set_option(self, opt_string, value): opt = self.OPTNAME_TO_NUMBER_MAP.get(opt_string) if opt is not None: config = self._configProxy() ec = config['extra_customization'] ec[opt] = value config['extra_customization'] = ec def is_running(self): return getattr(self, 'listen_socket', None) is not None def monkeypatch_zeroconf(): # Hack to work around the newly-enforced 15 character service name limit. # "monkeypatch" zeroconf with a function without the check try: from zeroconf._utils.name import service_type_name service_type_name.__kwdefaults__['strict'] = False except ImportError: # Can't remove name limit. pass