diff --git a/resources/images/devices/ipad.png b/resources/images/devices/ipad.png index 119d53dc9a..823109194f 100644 Binary files a/resources/images/devices/ipad.png and b/resources/images/devices/ipad.png differ diff --git a/resources/images/devices/itunes.png b/resources/images/devices/itunes.png new file mode 100644 index 0000000000..cd8579d492 Binary files /dev/null and b/resources/images/devices/itunes.png differ diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py index f58e4ec27a..3b46976ca7 100644 --- a/src/calibre/devices/apple/driver.py +++ b/src/calibre/devices/apple/driver.py @@ -277,7 +277,6 @@ class ITUNES(DriverBase): # Fetch a list of books from iPod device connected to iTunes - if 'iPod' in self.sources: booklist = BookList(self.log) cached_books = {} @@ -366,7 +365,7 @@ class ITUNES(DriverBase): self._dump_cached_books('returning from books()',indent=2) return booklist else: - return [] + return BookList(self.log) def can_handle(self, device_info, debug=False): ''' @@ -377,7 +376,7 @@ class ITUNES(DriverBase): Confirm that: - iTunes is running - - there is an iPod-type device connected + - there is an iDevice connected This gets called first when the device fingerprint is read, so it needs to instantiate iTunes if necessary This gets called ~1x/second while device fingerprint is sensed @@ -2049,8 +2048,13 @@ class ITUNES(DriverBase): running_apps = appscript.app('System Events') if not 'iTunes' in running_apps.processes.name(): if DEBUG: - self.log.info( "ITUNES:open(): Launching iTunes" ) - self.iTunes = iTunes= appscript.app('iTunes', hide=True) + self.log.info( "ITUNES:_launch_iTunes(): Launching iTunes" ) + try: + self.iTunes = iTunes= appscript.app('iTunes', hide=True) + except: + self.iTunes = None + raise UserFeedback(' ITUNES._launch_iTunes(): unable to find installed iTunes', details=None, level=UserFeedback.WARN) + iTunes.run() self.initial_status = 'launched' else: @@ -2091,7 +2095,12 @@ class ITUNES(DriverBase): os.path.normpath("//server/share") returns "\\\\server\\share" ''' # Instantiate iTunes - self.iTunes = win32com.client.Dispatch("iTunes.Application") + try: + self.iTunes = win32com.client.Dispatch("iTunes.Application") + except: + self.iTunes = None + raise UserFeedback(' ITUNES._launch_iTunes(): unable to find installed iTunes', details=None, level=UserFeedback.WARN) + if not DEBUG: self.iTunes.Windows[0].Minimized = True self.initial_status = 'launched' @@ -2564,6 +2573,225 @@ class ITUNES(DriverBase): db_added.Genre = tag break +class ITUNES_ASYNC(ITUNES): + ''' + This subclass allows the user to interact directly with iTunes via a menu option + 'Connect to iTunes' in Send to device. + ''' + name = 'iTunes interface' + gui_name = 'Apple iTunes' + icon = I('devices/itunes.png') + description = _('Communicate with iTunes.') + + connected = False + + def __init__(self,path): + if DEBUG: + self.log.info("ITUNES_ASYNC:__init__()") + + if isosx and appscript is None: + self.connected = False + raise UserFeedback('OSX 10.5 or later required', details=None, level=UserFeedback.WARN) + return + else: + self.connected = True + + if isosx: + self._launch_iTunes() + + if iswindows: + try: + pythoncom.CoInitialize() + self._launch_iTunes() + except: + raise UserFeedback('unable to launch iTunes', details=None, level=UserFeedback.WARN) + finally: + pythoncom.CoUninitialize() + + self.manual_sync_mode = False + + def books(self, oncard=None, end_session=True): + """ + Return a list of ebooks on the device. + @param oncard: If 'carda' or 'cardb' return a list of ebooks on the + specific storage card, otherwise return list of ebooks + in main memory of device. If a card is specified and no + books are on the card return empty list. + @return: A BookList. + + Implementation notes: + iTunes does not sync purchased books, they are only on the device. They are visible, but + they are not backed up to iTunes. Since calibre can't manage them, don't show them in the + list of device books. + + """ + if not oncard: + if DEBUG: + self.log.info("ITUNES_ASYNC:books(oncard=%s)" % oncard) + + # Fetch a list of books from iTunes + + booklist = BookList(self.log) + cached_books = {} + + if isosx: + library_books = self._get_library_books() + book_count = float(len(library_books)) + for (i,book) in enumerate(library_books): + this_book = Book(library_books[book].name(), library_books[book].artist()) + this_book.path = self.path_template % (library_books[book].name(), + library_books[book].artist()) + try: + this_book.datetime = parse_date(str(library_books[book].date_added())).timetuple() + except: + pass + this_book.db_id = None + this_book.device_collections = [] + #this_book.library_id = library_books[this_book.path] if this_book.path in library_books else None + this_book.library_id = library_books[book] + this_book.size = library_books[book].size() + this_book.uuid = library_books[book].album() + # Hack to discover if we're running in GUI environment + if self.report_progress is not None: + this_book.thumbnail = self._generate_thumbnail(this_book.path, library_books[book]) + else: + this_book.thumbnail = None + booklist.add_book(this_book, False) + + cached_books[this_book.path] = { + 'title':library_books[book].name(), + 'author':[library_books[book].artist()], + 'lib_book':library_books[book], + 'dev_book':None, + 'uuid': library_books[book].composer(), + #'format': 'pdf' if book.KindAsString.startswith('PDF') else 'epub' + } + + if self.report_progress is not None: + self.report_progress(i+1/book_count, _('%d of %d') % (i+1, book_count)) + + elif iswindows: + try: + pythoncom.CoInitialize() + self.iTunes = win32com.client.Dispatch("iTunes.Application") + library_books = self._get_library_books() + book_count = float(len(library_books)) + for (i,book) in enumerate(library_books): + this_book = Book(library_books[book].Name, library_books[book].Artist) + this_book.path = self.path_template % (library_books[book].Name, + library_books[book].Artist) + try: + this_book.datetime = parse_date(str(library_books[book].DateAdded)).timetuple() + except: + pass + this_book.db_id = None + this_book.device_collections = [] + this_book.library_id = library_books[book] + this_book.size = library_books[book].Size + # Hack to discover if we're running in GUI environment + if self.report_progress is not None: + this_book.thumbnail = self._generate_thumbnail(this_book.path, library_books[book]) + else: + this_book.thumbnail = None + booklist.add_book(this_book, False) + + cached_books[this_book.path] = { + 'title':library_books[book].Name, + 'author':library_books[book].Artist, + 'lib_book':library_books[book], + 'uuid': library_books[book].Composer, + 'format': 'pdf' if library_books[book].KindAsString.startswith('PDF') else 'epub' + } + + if self.report_progress is not None: + self.report_progress(i+1/book_count, + _('%d of %d') % (i+1, book_count)) + + finally: + pythoncom.CoUninitialize() + + if self.report_progress is not None: + self.report_progress(1.0, _('finished')) + self.cached_books = cached_books + if DEBUG: + self._dump_booklist(booklist, 'returning from books()', indent=2) + self._dump_cached_books('returning from books()',indent=2) + return booklist + + else: + return BookList(self.log) + + def disconnect_from_folder(self): + ''' + ''' + if DEBUG: + self.log.info("ITUNES_ASYNC:disconnect_from_folder()") + self.connected = False + + def eject(self): + ''' + Un-mount / eject the device from the OS. This does not check if there + are pending GUI jobs that need to communicate with the device. + ''' + if DEBUG: + self.log.info("ITUNES_ASYNC:eject()") + self.iTunes = None + self.connected = False + + def free_space(self, end_session=True): + """ + Get free space available on the mountpoints: + 1. Main memory + 2. Card A + 3. Card B + + @return: A 3 element list with free space in bytes of (1, 2, 3). If a + particular device doesn't have any of these locations it should return -1. + """ + if DEBUG: + self.log.info("ITUNES_ASYNC:free_space()") + free_space = 0 + if isosx: + s = os.statvfs(os.sep) + free_space = s.f_bavail * s.f_frsize + elif iswindows: + free_bytes = ctypes.c_ulonglong(0) + ctypes.windll.kernel32.GetDiskFreeSpaceExW(ctypes.c_wchar_p(os.sep), None, None, ctypes.pointer(free_bytes)) + free_space = free_bytes.value + return (free_space,-1,-1) + + def get_device_information(self, end_session=True): + """ + Ask device for device information. See L{DeviceInfoQuery}. + @return: (device name, device version, software version on device, mime type) + """ + if DEBUG: + self.log.info("ITUNES_ASYNC:get_device_information()") + + return ('iTunes','hw v1.0','sw v1.0', 'mime type normally goes here') + + def is_usb_connected(self, devices_on_system, debug=False, + only_presence=False): + return self.connected, self + + def sync_booklists(self, booklists, end_session=True): + ''' + Update metadata on device. + @param booklists: A tuple containing the result of calls to + (L{books}(oncard=None), L{books}(oncard='carda'), + L{books}(oncard='cardb')). + ''' + + if DEBUG: + self.log.info("ITUNES_ASYNC.sync_booklists()") + + # Inform user of any problem books + if self.problem_titles: + raise UserFeedback(self.problem_msg, + details='\n'.join(self.problem_titles), level=UserFeedback.WARN) + self.problem_titles = [] + self.problem_msg = None + self.update_list = [] class BookList(list): ''' diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 6be50cf293..ce65ac2f06 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -29,6 +29,7 @@ from calibre.utils.filenames import ascii_filename from calibre.devices.errors import FreeSpaceError from calibre.utils.smtp import compose_mail, sendmail, extract_email_address, \ config as email_config +from calibre.devices.apple.driver import ITUNES_ASYNC from calibre.devices.folder_device.driver import FOLDER_DEVICE # }}} @@ -105,10 +106,11 @@ class DeviceManager(Thread): # {{{ self.scanner = DeviceScanner() self.connected_device = None self.ejected_devices = set([]) - self.connected_device_is_folder = False - self.folder_connection_requests = Queue.Queue(0) + self.mount_connection_requests = Queue.Queue(0) self.open_feedback_slot = open_feedback_slot + ITUNES_STRING = '#itunes#' + def report_progress(self, *args): pass @@ -120,7 +122,7 @@ class DeviceManager(Thread): # {{{ def device(self): return self.connected_device - def do_connect(self, connected_devices, is_folder_device): + def do_connect(self, connected_devices, device_kind): for dev, detected_device in connected_devices: if dev.OPEN_FEEDBACK_MESSAGE is not None: self.open_feedback_slot(dev.OPEN_FEEDBACK_MESSAGE) @@ -133,8 +135,8 @@ class DeviceManager(Thread): # {{{ traceback.print_exc() continue self.connected_device = dev - self.connected_device_is_folder = is_folder_device - self.connected_slot(True, is_folder_device) + self.connected_device_kind = device_kind + self.connected_slot(True, device_kind) return True return False @@ -152,7 +154,7 @@ class DeviceManager(Thread): # {{{ if self.connected_device in self.ejected_devices: self.ejected_devices.remove(self.connected_device) else: - self.connected_slot(False, self.connected_device_is_folder) + self.connected_slot(False, self.connected_device_kind) self.connected_device = None def detect_device(self): @@ -174,18 +176,18 @@ class DeviceManager(Thread): # {{{ possibly_connected_devices.append((device, detected_device)) if possibly_connected_devices: if not self.do_connect(possibly_connected_devices, - is_folder_device=False): + device_kind='device'): prints('Connect to device failed, retrying in 5 seconds...') time.sleep(5) if not self.do_connect(possibly_connected_devices, - is_folder_device=False): + device_kind='usb'): prints('Device connect failed again, giving up') def umount_device(self, *args): if self.is_device_connected and not self.job_manager.has_device_jobs(): self.connected_device.eject() self.ejected_devices.add(self.connected_device) - self.connected_slot(False, self.connected_device_is_folder) + self.connected_slot(False, self.connected_device_kind) def next(self): if not self.jobs.empty(): @@ -196,20 +198,19 @@ class DeviceManager(Thread): # {{{ def run(self): while self.keep_going: - folder_path = None + kls = None while True: try: - folder_path = self.folder_connection_requests.get_nowait() + (kls,device_kind, folder_path) = \ + self.mount_connection_requests.get_nowait() except Queue.Empty: break - if not folder_path or not os.access(folder_path, os.R_OK): - folder_path = None - if not self.is_device_connected and folder_path is not None: + if kls is not None: try: - dev = FOLDER_DEVICE(folder_path) - self.do_connect([[dev, None],], is_folder_device=True) + dev = kls(folder_path) + self.do_connect([[dev, None],], device_kind=device_kind) except: - prints('Unable to open folder as device', folder_path) + prints('Unable to open %s as device (%s)'%(device_kind, folder_path)) traceback.print_exc() else: self.detect_device() @@ -251,13 +252,14 @@ class DeviceManager(Thread): # {{{ # This will be called on the GUI thread. Because of this, we must store # information that the scanner thread will use to do the real work. - def connect_to_folder(self, path): - self.folder_connection_requests.put(path) + # Note: this is used for iTunes + def mount_device(self, kls, kind, path): + self.mount_connection_requests.put((kls, kind, path)) # This is called on the GUI thread. No problem here, because it calls the # device driver, telling it to tell the scanner when it passes by that the - # folder has disconnected. - def disconnect_folder(self): + # folder has disconnected. Note: this is also used for iTunes + def unmount_device(self): if self.connected_device is not None: if hasattr(self.connected_device, 'disconnect_from_folder'): # As we are on the wrong thread, this call must *not* do @@ -375,7 +377,8 @@ class DeviceMenu(QMenu): # {{{ fetch_annotations = pyqtSignal() connect_to_folder = pyqtSignal() - disconnect_from_folder = pyqtSignal() + connect_to_itunes = pyqtSignal() + disconnect_mounted_device = pyqtSignal() def __init__(self, parent=None): QMenu.__init__(self, parent) @@ -492,8 +495,18 @@ class DeviceMenu(QMenu): # {{{ mitem = self.addAction(QIcon(I('eject.svg')), _('Disconnect from folder')) mitem.setEnabled(False) - mitem.triggered.connect(lambda x : self.disconnect_from_folder.emit()) - self.disconnect_from_folder_action = mitem + mitem.triggered.connect(lambda x : self.disconnect_mounted_device.emit()) + self.disconnect_mounted_device_action = mitem + + mitem = self.addAction(QIcon(I('document_open.svg')), _('Connect to iTunes (BETA TEST)')) + mitem.setEnabled(True) + mitem.triggered.connect(lambda x : self.connect_to_itunes.emit()) + self.connect_to_itunes_action = mitem + + mitem = self.addAction(QIcon(I('eject.svg')), _('Disconnect from iTunes (BETA TEST)')) + mitem.setEnabled(False) + mitem.triggered.connect(lambda x : self.disconnect_mounted_device.emit()) + self.disconnect_from_itunes_action = mitem self.addSeparator() self.addMenu(self.set_default_menu) @@ -629,12 +642,17 @@ class DeviceMixin(object): # {{{ def connect_to_folder(self): dir = choose_dir(self, 'Select Device Folder', - _('Select folder to open as device')) - if dir is not None: - self.device_manager.connect_to_folder(dir) + _('Select folder to open as device')) + kls = FOLDER_DEVICE + self.device_manager.mount_device(kls=kls, kind='folder', path=dir) - def disconnect_from_folder(self): - self.device_manager.disconnect_folder() + def connect_to_itunes(self): + kls = ITUNES_ASYNC + self.device_manager.mount_device(kls=kls, kind='itunes', path=None) + + # disconnect from both folder and itunes devices + def disconnect_mounted_device(self): + self.device_manager.unmount_device() def _sync_action_triggered(self, *args): m = getattr(self, '_sync_menu', None) @@ -649,16 +667,22 @@ class DeviceMixin(object): # {{{ self.dispatch_sync_event) self._sync_menu.fetch_annotations.connect(self.fetch_annotations) self._sync_menu.connect_to_folder.connect(self.connect_to_folder) - self._sync_menu.disconnect_from_folder.connect(self.disconnect_from_folder) + self._sync_menu.connect_to_itunes.connect(self.connect_to_itunes) + self._sync_menu.disconnect_mounted_device.connect(self.disconnect_mounted_device) if self.device_connected: self._sync_menu.connect_to_folder_action.setEnabled(False) + self._sync_menu.connect_to_itunes_action.setEnabled(False) if self.device_connected == 'folder': - self._sync_menu.disconnect_from_folder_action.setEnabled(True) + self._sync_menu.disconnect_mounted_device_action.setEnabled(True) + if self.device_connected == 'itunes': + self._sync_menu.disconnect_from_itunes_action.setEnabled(True) else: - self._sync_menu.disconnect_from_folder_action.setEnabled(False) + self._sync_menu.disconnect_mounted_device_action.setEnabled(False) else: self._sync_menu.connect_to_folder_action.setEnabled(True) - self._sync_menu.disconnect_from_folder_action.setEnabled(False) + self._sync_menu.disconnect_mounted_device_action.setEnabled(False) + self._sync_menu.connect_to_itunes_action.setEnabled(True) + self._sync_menu.disconnect_from_itunes_action.setEnabled(False) @@ -694,26 +718,31 @@ class DeviceMixin(object): # {{{ # Device connected {{{ - def set_device_menu_items_state(self, connected, is_folder_device): + def set_device_menu_items_state(self, connected, device_kind): if connected: self._sync_menu.connect_to_folder_action.setEnabled(False) - if is_folder_device: - self._sync_menu.disconnect_from_folder_action.setEnabled(True) + self._sync_menu.connect_to_itunes_action.setEnabled(False) + if device_kind == 'folder': + self._sync_menu.disconnect_mounted_device_action.setEnabled(True) + elif device_kind == 'itunes': + self._sync_menu.disconnect_from_itunes_action.setEnabled(True) self._sync_menu.enable_device_actions(True, self.device_manager.device.card_prefix(), self.device_manager.device) self.eject_action.setEnabled(True) else: self._sync_menu.connect_to_folder_action.setEnabled(True) - self._sync_menu.disconnect_from_folder_action.setEnabled(False) + self._sync_menu.connect_to_itunes_action.setEnabled(True) + self._sync_menu.disconnect_mounted_device_action.setEnabled(False) + self._sync_menu.disconnect_from_itunes_action.setEnabled(False) self._sync_menu.enable_device_actions(False) self.eject_action.setEnabled(False) - def device_detected(self, connected, is_folder_device): + def device_detected(self, connected, device_kind): ''' Called when a device is connected to the computer. ''' - self.set_device_menu_items_state(connected, is_folder_device) + self.set_device_menu_items_state(connected, device_kind) if connected: self.device_manager.get_device_information(\ Dispatcher(self.info_read)) @@ -722,7 +751,7 @@ class DeviceMixin(object): # {{{ self.status_bar.show_message(_('Device: ')+\ self.device_manager.device.__class__.get_gui_name()+\ _(' detected.'), 3000) - self.device_connected = 'device' if not is_folder_device else 'folder' + self.device_connected = device_kind self.location_view.model().device_connected(self.device_manager.device) self.refresh_ondevice_info (device_connected = True, reset_only = True) else: