Add progress indicator for deleting items (bug 268)
[gpodder.git] / src / gpodder / sync.py
blob723bddb29b172d6d63b860596523b3087b74319c
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
21 # sync.py -- Device synchronization
22 # Thomas Perl <thp@perli.net> 2007-12-06
23 # based on libipodsync.py (2006-04-05 Thomas Perl)
25 import gpodder
27 from gpodder import util
28 from gpodder import services
29 from gpodder import libconverter
31 from gpodder.liblogger import log
33 import time
34 import calendar
36 _ = gpodder.gettext
38 gpod_available = True
39 try:
40 import gpod
41 except:
42 gpod_available = False
43 log('(gpodder.sync) Could not find gpod')
45 pymtp_available = True
46 try:
47 import pymtp
48 except:
49 pymtp_available = False
50 log('(gpodder.sync) Could not find pymtp.')
52 try:
53 import mad
54 except:
55 log('(gpodder.sync) Could not find pymad')
57 try:
58 import eyeD3
59 except:
60 log( '(gpodder.sync) Could not find eyeD3')
62 try:
63 import Image
64 except:
65 log('(gpodder.sync) Could not find Python Imaging Library (PIL)')
67 # Register our dependencies for the synchronization module
68 services.dependency_manager.depend_on(_('iPod synchronization'), _('Support synchronization of podcasts to Apple iPod devices via libgpod.'), ['gpod', 'mad', 'eyeD3'], [])
69 services.dependency_manager.depend_on(_('MTP device synchronization'), _('Support synchronization of podcasts to devices using the Media Transfer Protocol via pymtp.'), ['pymtp'], [])
70 services.dependency_manager.depend_on(_('iPod OGG converter'), _('Convert OGG podcasts to MP3 files on synchronization to iPods using oggdec and LAME.'), [], ['oggdec', 'lame'])
71 services.dependency_manager.depend_on(_('iPod video podcasts'), _('Detect video lengths via MPlayer, to synchronize video podcasts to iPods.'), [], ['mplayer'])
72 services.dependency_manager.depend_on(_('Rockbox cover art support'), _('Copy podcast cover art to filesystem-based MP3 players running Rockbox.org firmware. Needs Python Imaging.'), ['Image'], [])
74 import os
75 import os.path
76 import glob
77 import shutil
78 import sys
79 import time
80 import string
81 import email.Utils
82 import re
85 def open_device(config):
86 device_type = config.device_type
87 if device_type == 'ipod':
88 return iPodDevice(config)
89 elif device_type == 'filesystem':
90 return MP3PlayerDevice(config)
91 elif device_type == 'mtp':
92 return MTPDevice(config)
93 else:
94 return None
96 def get_track_length(filename):
97 if util.find_command('mplayer') is not None:
98 try:
99 mplayer_output = os.popen('mplayer -msglevel all=-1 -identify -vo null -ao null -frames 0 "%s" 2>/dev/null' % filename).read()
100 return int(float(mplayer_output[mplayer_output.index('ID_LENGTH'):].splitlines()[0][10:])*1000)
101 except:
102 pass
103 else:
104 log('Please install MPlayer for track length detection.')
106 try:
107 mad_info = mad.MadFile(filename)
108 return int(mad_info.total_time())
109 except:
110 pass
112 try:
113 eyed3_info = eyeD3.Mp3AudioFile(filename)
114 return int(eyed3_info.getPlayTime()*1000)
115 except:
116 pass
118 return int(60*60*1000*3) # Default is three hours (to be on the safe side)
121 class SyncTrack(object):
123 This represents a track that is on a device. You need
124 to specify at least the following keyword arguments,
125 because these will be used to display the track in the
126 GUI. All other keyword arguments are optional and can
127 be used to reference internal objects, etc... See the
128 iPod synchronization code for examples.
130 Keyword arguments needed:
131 playcount (How often has the track been played?)
132 podcast (Which podcast is this track from? Or: Folder name)
133 released (The release date of the episode)
135 If any of these fields is unknown, it should not be
136 passed to the function (the values will default to None
137 for all required fields).
139 def __init__(self, title, length, modified, **kwargs):
140 self.title = title
141 self.length = length
142 self.filesize = util.format_filesize(length)
143 self.modified = modified
145 # Set some (possible) keyword arguments to default values
146 self.playcount = None
147 self.podcast = None
148 self.released = None
150 # Convert keyword arguments to object attributes
151 self.__dict__.update(kwargs)
154 class Device(services.ObservableService):
155 def __init__(self, config):
156 self._config = config
157 self.cancelled = False
158 self.allowed_types = ['audio', 'video']
159 self.errors = []
160 self.tracks_list = []
161 signals = ['progress', 'sub-progress', 'status', 'done', 'post-done']
162 services.ObservableService.__init__(self, signals)
164 def open(self):
165 pass
167 def cancel(self):
168 self.cancelled = True
169 self.notify('status', _('Cancelled by user'))
171 def close(self):
172 self.notify('status', _('Writing data to disk'))
173 if self._config.sync_disks_after_transfer:
174 successful_sync = (os.system('sync') == 0)
175 else:
176 log('Not syncing disks. Unmount your device before unplugging.', sender=self)
177 successful_sync = True
178 self.notify('done')
179 self.notify('post-done', self, successful_sync)
180 return True
182 def add_tracks(self, tracklist=[], force_played=False):
183 for track in list(tracklist):
184 # Filter tracks that are not meant to be synchronized
185 does_not_exist = not track.was_downloaded(and_exists=True)
186 exclude_played = track.is_played and not force_played and \
187 self._config.only_sync_not_played
188 wrong_type = track.file_type() not in self.allowed_types
190 if does_not_exist or exclude_played or wrong_type:
191 log('Excluding %s from sync', track.title, sender=self)
192 tracklist.remove(track)
194 compare_episodes = lambda a, b: cmp(a.pubDate, b.pubDate)
195 for id, track in enumerate(sorted(tracklist, cmp=compare_episodes)):
196 if self.cancelled:
197 return False
199 self.notify('progress', id+1, len(tracklist))
201 added = self.add_track(track)
203 if self._config.on_sync_mark_played:
204 log('Marking as played on transfer: %s', track.url, sender=self)
205 track.mark(is_played=True)
207 if added and self._config.on_sync_delete:
208 log('Removing episode after transfer: %s', track.url, sender=self)
209 track.delete_from_disk()
210 return True
212 def convert_track(self, episode):
213 filename = episode.local_filename(create=False)
214 # The file has to exist, if we ought to transfer it, and therefore,
215 # local_filename(create=False) must never return None as filename
216 assert filename is not None
217 (fn, extension) = os.path.splitext(filename)
218 if libconverter.converters.has_converter(extension):
219 if self._config.disable_pre_sync_conversion:
220 log('Pre-sync conversion is not enabled, set disable_pre_sync_conversion to "False" to enable')
221 return filename
223 log('Converting: %s', filename, sender=self)
224 callback_status = lambda percentage: self.notify('sub-progress', int(percentage))
225 local_filename = libconverter.converters.convert(filename, callback=callback_status)
227 if local_filename is None:
228 log('Cannot convert %s', original_filename, sender=self)
229 return filename
231 return str(local_filename)
233 return filename
235 def remove_tracks(self, tracklist=[]):
236 for id, track in enumerate(tracklist):
237 if self.cancelled:
238 return False
239 self.notify('progress', id, len(tracklist))
240 self.remove_track(track)
241 return True
243 def get_all_tracks(self):
244 pass
246 def add_track(self, track):
247 pass
249 def remove_track(self, track):
250 pass
252 def get_free_space(self):
253 pass
255 def episode_on_device(self, episode):
256 return self._track_on_device(episode.title)
258 def _track_on_device(self, track_name):
259 for t in self.tracks_list:
260 title = t.title
261 if track_name == title:
262 return t
263 return None
265 class iPodDevice(Device):
266 def __init__(self, config):
267 Device.__init__(self, config)
269 self.mountpoint = str(self._config.ipod_mount)
271 self.itdb = None
272 self.podcast_playlist = None
275 def get_free_space(self):
276 # Reserve 10 MiB for iTunesDB writing (to be on the safe side)
277 RESERVED_FOR_ITDB = 1024*1024*10
278 return util.get_free_disk_space(self.mountpoint) - RESERVED_FOR_ITDB
280 def open(self):
281 Device.open(self)
282 if not gpod_available or not os.path.isdir(self.mountpoint):
283 return False
285 self.notify('status', _('Opening iPod database'))
286 self.itdb = gpod.itdb_parse(self.mountpoint, None)
287 if self.itdb is None:
288 return False
290 self.itdb.mountpoint = self.mountpoint
291 self.podcasts_playlist = gpod.itdb_playlist_podcasts(self.itdb)
293 if self.podcasts_playlist:
294 self.notify('status', _('iPod opened'))
296 # build the initial tracks_list
297 self.tracks_list = self.get_all_tracks()
299 return True
300 else:
301 return False
303 def close(self):
304 if self.itdb is not None:
305 self.notify('status', _('Saving iPod database'))
306 gpod.itdb_write(self.itdb, None)
307 self.itdb = None
309 Device.close(self)
310 return True
312 def update_played_or_delete(self, channel, episodes, delete_from_db):
314 Check whether episodes on ipod are played and update as played
315 and delete if required.
317 for episode in episodes:
318 track = self.episode_on_device(episode)
319 if track:
320 gtrack = track.libgpodtrack
321 if gtrack.playcount > 0:
322 if delete_from_db and not gtrack.rating:
323 log('Deleting episode from db %s', gtrack.title, sender=self)
324 channel.delete_episode_by_url(gtrack.podcasturl)
325 else:
326 log('Marking episode as played %s', gtrack.title, sender=self)
327 episode.mark(is_played=True)
329 def purge(self):
330 for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
331 if gpod.itdb_filename_on_ipod(track) is None:
332 log('Episode has no file: %s', track.title, sender=self)
333 # self.remove_track_gpod(track)
334 elif track.playcount > 0 and not track.rating:
335 log('Purging episode: %s', track.title, sender=self)
336 self.remove_track_gpod(track)
338 def get_all_tracks(self):
339 tracks = []
340 for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
341 filename = gpod.itdb_filename_on_ipod(track)
342 length = util.calculate_size(filename)
344 timestamp = util.file_modification_timestamp(filename)
345 modified = util.format_date(timestamp)
346 released = gpod.itdb_time_mac_to_host(track.time_released)
347 released = util.format_date(released)
349 t = SyncTrack(track.title, length, modified, modified_sort=timestamp, libgpodtrack=track, playcount=track.playcount, released=released, podcast=track.artist)
350 tracks.append(t)
351 return tracks
353 def remove_track(self, track):
354 self.notify('status', _('Removing %s') % track.title)
355 self.remove_track_gpod(track.libgpodtrack)
357 def remove_track_gpod(self, track):
358 filename = gpod.itdb_filename_on_ipod(track)
360 try:
361 gpod.itdb_playlist_remove_track(self.podcasts_playlist, track)
362 except:
363 log('Track %s not in playlist', track.title, sender=self)
365 gpod.itdb_track_unlink(track)
366 util.delete_file(filename)
368 def add_track(self, episode):
369 self.notify('status', _('Adding %s') % episode.title)
370 for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
371 if episode.url == track.podcasturl:
372 if track.playcount > 0:
373 episode.mark(is_played=True)
374 # Mark as played on iPod if played locally (and set podcast flags)
375 self.set_podcast_flags(track, episode)
376 return True
378 original_filename = episode.local_filename(create=False)
379 # The file has to exist, if we ought to transfer it, and therefore,
380 # local_filename(create=False) must never return None as filename
381 assert original_filename is not None
382 local_filename = original_filename
384 if util.calculate_size(original_filename) > self.get_free_space():
385 log('Not enough space on %s, sync aborted...', self.mountpoint, sender = self)
386 self.errors.append( _('Error copying %s: Not enough free disk space on %s') % (episode.title, self.mountpoint))
387 self.cancelled = True
388 return False
390 local_filename = self.convert_track(episode)
392 (fn, extension) = os.path.splitext(local_filename)
393 if extension.lower().endswith('ogg'):
394 log('Cannot copy .ogg files to iPod.', sender=self)
395 return False
397 track = gpod.itdb_track_new()
399 # Add release time to track if pubDate has a valid value
400 if episode.pubDate > 0:
401 try:
402 # libgpod>= 0.5.x uses a new timestamp format
403 track.time_released = gpod.itdb_time_host_to_mac(int(episode.pubDate))
404 except:
405 # old (pre-0.5.x) libgpod versions expect mactime, so
406 # we're going to manually build a good mactime timestamp here :)
408 # + 2082844800 for unixtime => mactime (1970 => 1904)
409 track.time_released = int(episode.pubDate + 2082844800)
411 track.title = str(episode.title)
412 track.album = str(episode.channel.title)
413 track.artist = str(episode.channel.title)
414 track.description = str(util.remove_html_tags(episode.description))
416 track.podcasturl = str(episode.url)
417 track.podcastrss = str(episode.channel.url)
419 track.tracklen = get_track_length(local_filename)
420 track.size = os.path.getsize(local_filename)
422 if episode.file_type() == 'audio':
423 track.filetype = 'mp3'
424 track.mediatype = 0x00000004
425 elif episode.file_type() == 'video':
426 track.filetype = 'm4v'
427 track.mediatype = 0x00000006
429 self.set_podcast_flags(track, episode)
430 self.set_cover_art(track, local_filename)
432 gpod.itdb_track_add(self.itdb, track, -1)
433 gpod.itdb_playlist_add_track(self.podcasts_playlist, track, -1)
434 gpod.itdb_cp_track_to_ipod(track, str(local_filename), None)
436 # If the file has been converted, delete the temporary file here
437 if local_filename != original_filename:
438 util.delete_file(local_filename)
440 return True
442 def set_podcast_flags(self, track, episode):
443 try:
444 # Set blue bullet for unplayed tracks on 5G iPods
445 if episode.is_played:
446 track.mark_unplayed = 0x01
447 if track.playcount == 0:
448 track.playcount = 1
449 else:
450 if track.playcount > 0 or track.bookmark_time > 0:
451 #track is partially played so no blue bullet
452 track.mark_unplayed = 0x01
453 else:
454 #totally unplayed
455 track.mark_unplayed = 0x02
457 # Set several flags for to podcast values
458 track.remember_playback_position = 0x01
459 track.flag1 = 0x02
460 track.flag2 = 0x01
461 track.flag3 = 0x01
462 track.flag4 = 0x01
463 except:
464 log('Seems like your python-gpod is out-of-date.', sender=self)
466 def set_cover_art(self, track, local_filename):
467 try:
468 tag = eyeD3.Tag()
469 if tag.link(local_filename):
470 if 'APIC' in tag.frames and len(tag.frames['APIC']) > 0:
471 apic = tag.frames['APIC'][0]
473 extension = 'jpg'
474 if apic.mimeType == 'image/png':
475 extension = 'png'
476 cover_filename = '%s.cover.%s' (local_filename, extension)
478 cover_file = open(cover_filename, 'w')
479 cover_file.write(apic.imageData)
480 cover_file.close()
482 gpod.itdb_track_set_thumbnails(track, cover_filename)
483 return True
484 except:
485 log('Error getting cover using eyeD3', sender=self)
487 try:
488 cover_filename = os.path.join(os.path.dirname(local_filename), 'cover')
489 if not os.path.exists(cover_filename):
490 cover_filename = os.path.join(os.path.dirname(local_filename), '.cover')
491 if os.path.isfile(cover_filename):
492 gpod.itdb_track_set_thumbnails(track, cover_filename)
493 return True
494 except:
495 log('Error getting cover using channel cover', sender=self)
497 return False
500 class MP3PlayerDevice(Device):
501 # if different players use other filenames besides
502 # .scrobbler.log, add them to this list
503 scrobbler_log_filenames = ['.scrobbler.log']
505 def __init__(self, config):
506 Device.__init__(self, config)
507 self.destination = self._config.mp3_player_folder
508 self.buffer_size = 1024*1024 # 1 MiB
509 self.scrobbler_log = []
511 def get_free_space(self):
512 return util.get_free_disk_space(self.destination)
514 def open(self):
515 Device.open(self)
516 self.notify('status', _('Opening MP3 player'))
517 if util.directory_is_writable(self.destination):
518 self.notify('status', _('MP3 player opened'))
519 # build the initial tracks_list
520 self.tracks_list = self.get_all_tracks()
521 if self._config.mp3_player_use_scrobbler_log:
522 mp3_player_mount_point = util.find_mount_point(self.destination)
523 # If a moint point cannot be found look inside self.destination for scrobbler_log_filenames
524 # this prevents us from os.walk()'ing the entire / filesystem
525 if mp3_player_mount_point == '/':
526 mp3_player_mount_point = self.destination
527 log_location = self.find_scrobbler_log(mp3_player_mount_point)
528 if log_location is not None and self.load_audioscrobbler_log(log_location):
529 log('Using Audioscrobbler log data to mark tracks as played', sender=self)
530 return True
531 else:
532 return False
534 def add_track(self, episode):
535 self.notify('status', _('Adding %s') % episode.title.decode('utf-8', 'ignore'))
537 if self._config.fssync_channel_subfolders:
538 # Add channel title as subfolder
539 folder = episode.channel.title
540 # Clean up the folder name for use on limited devices
541 folder = util.sanitize_filename(folder, self._config.mp3_player_max_filename_length)
542 folder = os.path.join(self.destination, folder)
543 else:
544 folder = self.destination
546 from_file = util.sanitize_encoding(self.convert_track(episode))
547 filename_base = util.sanitize_filename(episode.sync_filename(self._config.custom_sync_name_enabled, self._config.custom_sync_name), self._config.mp3_player_max_filename_length)
549 to_file = filename_base + os.path.splitext(from_file)[1].lower()
551 # dirty workaround: on bad (empty) episode titles,
552 # we simply use the from_file basename
553 # (please, podcast authors, FIX YOUR RSS FEEDS!)
554 if os.path.splitext(to_file)[0] == '':
555 to_file = os.path.basename(from_file)
557 to_file = os.path.join(folder, to_file)
559 if not os.path.exists(folder):
560 try:
561 os.makedirs(folder)
562 except:
563 log('Cannot create folder on MP3 player: %s', folder, sender=self)
564 return False
566 if self._config.mp3_player_use_scrobbler_log and not episode.is_played:
567 # FIXME: This misses some things when channel.title<>album tag which is what
568 # the scrobbling entity will be using.
569 if [episode.channel.title, episode.title] in self.scrobbler_log:
570 log('Marking "%s" from "%s" as played', episode.title, episode.channel.title, sender=self)
571 episode.mark(is_played=True)
573 if self._config.rockbox_copy_coverart and not os.path.exists(os.path.join(folder, 'cover.bmp')):
574 log('Creating Rockbox album art for "%s"', episode.channel.title, sender=self)
575 self.copy_player_cover_art(folder, from_file, \
576 'cover.bmp', 'BMP', self._config.rockbox_coverart_size)
578 if self._config.custom_player_copy_coverart \
579 and not os.path.exists(os.path.join(folder, \
580 self._config.custom_player_coverart_name)):
581 log('Creating custom player album art for "%s"',
582 episode.channel.title, sender=self)
583 self.copy_player_cover_art(folder, from_file, \
584 self._config.custom_player_coverart_name, \
585 self._config.custom_player_coverart_format, \
586 self._config.custom_player_coverart_size)
588 if not os.path.exists(to_file):
589 log('Copying %s => %s', os.path.basename(from_file), to_file.decode(util.encoding), sender=self)
590 return self.copy_file_progress(from_file, to_file)
592 return True
594 def copy_file_progress(self, from_file, to_file):
595 try:
596 out_file = open(to_file, 'wb')
597 except IOError, ioerror:
598 self.errors.append(_('Error opening %s: %s') % (ioerror.filename, ioerror.strerror))
599 self.cancel()
600 return False
602 try:
603 in_file = open(from_file, 'rb')
604 except IOError, ioerror:
605 self.errors.append(_('Error opening %s: %s') % (ioerror.filename, ioerror.strerror))
606 self.cancel()
607 return False
609 in_file.seek(0, 2)
610 bytes = in_file.tell()
611 in_file.seek(0)
613 bytes_read = 0
614 s = in_file.read(self.buffer_size)
615 while s:
616 bytes_read += len(s)
617 try:
618 out_file.write(s)
619 except IOError, ioerror:
620 self.errors.append(ioerror.strerror)
621 try:
622 out_file.close()
623 except:
624 pass
625 try:
626 log('Trying to remove partially copied file: %s' % to_file, sender=self)
627 os.unlink( to_file)
628 log('Yeah! Unlinked %s at least..' % to_file, sender=self)
629 except:
630 log('Error while trying to unlink %s. OH MY!' % to_file, sender=self)
631 self.cancel()
632 return False
633 self.notify('sub-progress', int(min(100, 100*float(bytes_read)/float(bytes))))
634 s = in_file.read(self.buffer_size)
635 out_file.close()
636 in_file.close()
638 return True
640 def get_all_tracks(self):
641 tracks = []
643 if self._config.fssync_channel_subfolders:
644 files = glob.glob(os.path.join(self.destination, '*', '*'))
645 else:
646 files = glob.glob(os.path.join(self.destination, '*'))
648 for filename in files:
649 (title, extension) = os.path.splitext(os.path.basename(filename))
650 length = util.calculate_size(filename)
652 timestamp = util.file_modification_timestamp(filename)
653 modified = util.format_date(timestamp)
654 if self._config.fssync_channel_subfolders:
655 podcast_name = os.path.basename(os.path.dirname(filename))
656 else:
657 podcast_name = None
659 t = SyncTrack(title, length, modified, modified_sort=timestamp, filename=filename, podcast=podcast_name)
660 tracks.append(t)
661 return tracks
663 def episode_on_device(self, episode):
664 e = util.sanitize_filename(episode.sync_filename(self._config.custom_sync_name_enabled, self._config.custom_sync_name), self._config.mp3_player_max_filename_length)
665 return self._track_on_device(e)
667 def remove_track(self, track):
668 self.notify('status', _('Removing %s') % track.title)
669 util.delete_file(track.filename)
670 directory = os.path.dirname(track.filename)
671 if self.directory_is_empty(directory) and self._config.fssync_channel_subfolders:
672 try:
673 os.rmdir(directory)
674 except:
675 log('Cannot remove %s', directory, sender=self)
677 def directory_is_empty(self, directory):
678 files = glob.glob(os.path.join(directory, '*'))
679 dotfiles = glob.glob(os.path.join(directory, '.*'))
680 return len(files+dotfiles) == 0
682 def find_scrobbler_log(self, mount_point):
683 """ find an audioscrobbler log file from log_filenames in the mount_point dir """
684 for dirpath, dirnames, filenames in os.walk(mount_point):
685 for log_file in self.scrobbler_log_filenames:
686 filename = os.path.join(dirpath, log_file)
687 if os.path.isfile(filename):
688 return filename
690 # No scrobbler log on that device
691 return None
693 def copy_player_cover_art(self, destination, local_filename, \
694 cover_dst_name, cover_dst_format, \
695 cover_dst_size):
697 Try to copy the channel cover to the podcast folder on the MP3
698 player. This makes the player, e.g. Rockbox (rockbox.org), display the
699 cover art in its interface.
701 You need the Python Imaging Library (PIL) installed to be able to
702 convert the cover file to a Bitmap file, which Rockbox needs.
704 try:
705 cover_loc = os.path.join(os.path.dirname(local_filename), 'cover')
706 if not os.path.exists(cover_loc):
707 cover_loc = os.path.join(os.path.dirname(local_filename), '.cover')
708 cover_dst = os.path.join(destination, cover_dst_name)
709 if os.path.isfile(cover_loc):
710 log('Creating cover art file on player', sender=self)
711 log('Cover art size is %s', cover_dst_size, sender=self)
712 size = (cover_dst_size, cover_dst_size)
713 try:
714 cover = Image.open(cover_loc)
715 cover.thumbnail(size)
716 cover.save(cover_dst, cover_dst_format)
717 except IOError:
718 log('Cannot create %s (PIL?)', cover_dst, traceback=True, sender=self)
719 return True
720 else:
721 log('No cover available to set as player cover', sender=self)
722 return True
723 except:
724 log('Error getting cover using channel cover', sender=self)
725 return False
728 def load_audioscrobbler_log(self, log_file):
729 """ Retrive track title and artist info for all the entries
730 in an audioscrobbler portable player format logfile
731 http://www.audioscrobbler.net/wiki/Portable_Player_Logging """
732 try:
733 log('Opening "%s" as AudioScrobbler log.', log_file, sender=self)
734 f = open(log_file, 'r')
735 entries = f.readlines()
736 f.close()
737 except IOError, ioerror:
738 log('Error: "%s" cannot be read.', log_file, sender=self)
739 return False
741 try:
742 # Scrobble Log Format: http://www.audioscrobbler.net/wiki/Portable_Player_Logging
743 # Notably some fields are optional so will appear as \t\t.
744 # Conforming scrobblers should strip any \t's from the actual fields.
745 for entry in entries:
746 entry = entry.split('\t')
747 if len(entry)>=5:
748 artist, album, track, pos, length, rating = entry[:6]
749 # L means at least 50% of the track was listened to (S means < 50%)
750 if 'L' in rating:
751 # Whatever is writing the logs will only have the taginfo in the
752 # file to work from. Mostly album~=channel name
753 if len(track):
754 self.scrobbler_log.append([album, track])
755 else:
756 log('Skipping logging of %s (missing track)', album)
757 else:
758 log('Skipping scrobbler entry: %d elements %s', len(entry), entry)
760 except:
761 log('Error while parsing "%s".', log_file, sender=self)
763 return True
765 class MTPDevice(Device):
766 def __init__(self, config):
767 Device.__init__(self, config)
768 self.__model_name = None
769 self.__MTPDevice = pymtp.MTP()
771 def __callback(self, sent, total):
772 if self.cancelled:
773 return -1
774 percentage = round(float(sent)/float(total)*100)
775 text = ('%i%%' % percentage)
776 self.notify('progress', sent, total, text)
778 def __date_to_mtp(self, date):
780 this function format the given date and time to a string representation
781 according to MTP specifications: YYYYMMDDThhmmss.s
783 return
784 the string representation od the given date
786 if not date:
787 return ""
788 try:
789 d = time.gmtime(date)
790 return time.strftime("%Y%m%d-%H%M%S.0Z", d)
791 except Exception, exc:
792 log('ERROR: An error has happend while trying to convert date to an mtp string (%s)', exc, sender=self)
793 return None
795 def __mtp_to_date(self, mtp):
797 this parse the mtp's string representation for date
798 according to specifications (YYYYMMDDThhmmss.s) to
799 a python time object
802 if not mtp:
803 return None
805 try:
806 mtp = mtp.replace(" ", "0") # replace blank with 0 to fix some invalid string
807 d = time.strptime(mtp[:8] + mtp[9:13],"%Y%m%d%H%M%S")
808 _date = calendar.timegm(d)
809 if len(mtp)==20:
810 # TIME ZONE SHIFTING: the string contains a hour/min shift relative to a time zone
811 try:
812 shift_direction=mtp[15]
813 hour_shift = int(mtp[16:18])
814 minute_shift = int(mtp[18:20])
815 shift_in_sec = hour_shift * 3600 + minute_shift * 60
816 if shift_direction == "+":
817 _date += shift_in_sec
818 elif shift_direction == "-":
819 _date -= shift_in_sec
820 else:
821 raise ValueError("Expected + or -")
822 except Exception, exc:
823 log('WARNING: ignoring invalid time zone information for %s (%s)', mtp, exc, sender=self)
824 return max( 0, _date )
825 except Exception, exc:
826 log('WARNING: the mtp date "%s" can not be parsed against mtp specification (%s)', mtp, exc, sender=self)
827 return None
829 def get_name(self):
831 this function try to find a nice name for the device.
832 First, it tries to find a friendly (user assigned) name
833 (this name can be set by other application and is stored on the device).
834 if no friendly name was assign, it tries to get the model name (given by the vendor).
835 If no name is found at all, a generic one is returned.
837 Once found, the name is cached internaly to prevent reading again the device
839 return
840 the name of the device
843 if self.__model_name:
844 return self.__model_name
846 self.__model_name = self.__MTPDevice.get_devicename() # actually libmtp.Get_Friendlyname
847 if not self.__model_name or self.__model_name == "?????":
848 self.__model_name = self.__MTPDevice.get_modelname()
849 if not self.__model_name:
850 self.__model_name = "MTP device"
852 return self.__model_name
854 def open(self):
855 Device.open(self)
856 log("opening the MTP device", sender=self)
857 self.notify('status', _('Opening the MTP device'), )
859 try:
860 self.__MTPDevice.connect()
861 # build the initial tracks_list
862 self.tracks_list = self.get_all_tracks()
863 except Exception, exc:
864 log('unable to find an MTP device (%s)', exc, sender=self, traceback=True)
865 return False
867 self.notify('status', _('%s opened') % self.get_name())
868 return True
870 def close(self):
871 log("closing %s", self.get_name(), sender=self)
872 self.notify('status', _('Closing %s') % self.get_name())
874 try:
875 self.__MTPDevice.disconnect()
876 except Exception, exc:
877 log('unable to close %s (%s)', self.get_name(), exc, sender=self)
878 return False
880 self.notify('status', _('%s closed') % self.get_name())
881 Device.close(self)
882 return True
884 def add_track(self, episode):
885 self.notify('status', _('Adding %s...') % episode.title)
886 filename = str(self.convert_track(episode))
887 log("sending " + filename + " (" + episode.title + ").", sender=self)
889 try:
890 # verify free space
891 needed = util.calculate_size(filename)
892 free = self.get_free_space()
893 if needed > free:
894 log('Not enough space on device %s: %s available, but need at least %s', self.get_name(), util.format_filesize(free), util.format_filesize(needed), sender=self)
895 self.cancelled = True
896 return False
898 # fill metadata
899 metadata = pymtp.LIBMTP_Track()
900 metadata.title = str(episode.title)
901 metadata.artist = str(episode.channel.title)
902 metadata.album = str(episode.channel.title)
903 metadata.genre = "podcast"
904 metadata.date = self.__date_to_mtp(episode.pubDate)
905 metadata.duration = get_track_length(str(filename))
907 # send the file
908 self.__MTPDevice.send_track_from_file(filename,
909 episode.basename + episode.extension(),
910 metadata, 0, callback=self.__callback)
911 except:
912 log('unable to add episode %s', episode.title, sender=self, traceback=True)
913 return False
915 return True
917 def remove_track(self, sync_track):
918 self.notify('status', _('Removing %s') % sync_track.mtptrack.title)
919 log("removing %s", sync_track.mtptrack.title, sender=self)
921 try:
922 self.__MTPDevice.delete_object(sync_track.mtptrack.item_id)
923 except Exception, exc:
924 log('unable remove file %s (%s)', sync_track.mtptrack.filename, exc, sender=self)
926 log('%s removed', sync_track.mtptrack.title , sender=self)
928 def get_all_tracks(self):
929 try:
930 listing = self.__MTPDevice.get_tracklisting(callback=self.__callback)
931 except Exception, exc:
932 log('unable to get file listing %s (%s)', exc, sender=self)
934 tracks = []
935 for track in listing:
936 title = track.title
937 if not title or title=="": title=track.filename
938 if len(title) > 50: title = title[0:49] + '...'
939 artist = track.artist
940 if artist and len(artist) > 50: artist = artist[0:49] + '...'
941 length = track.filesize
942 age_in_days = 0
943 date = self.__mtp_to_date(track.date)
944 if not date:
945 modified = track.date # not a valid mtp date. Display what mtp gave anyway
946 modified_sort = -1 # no idea how to sort invalid date
947 else:
948 modified = util.format_date(date)
949 modified_sort = date
951 t = SyncTrack(title, length, modified, modified_sort=modified_sort, mtptrack=track, podcast=artist)
952 tracks.append(t)
953 return tracks
955 def get_free_space(self):
956 return self.__MTPDevice.get_freespace()