GStreamer-based episode length detection (bug 882)
[gpodder.git] / src / gpodder / sync.py
blob0cee9c17189196fdc45e2a090fe2b9ad522b54a4
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2010 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
30 from gpodder import gstreamer
32 from gpodder.liblogger import log
34 import time
35 import calendar
37 _ = gpodder.gettext
39 gpod_available = True
40 try:
41 import gpod
42 except:
43 gpod_available = False
44 log('(gpodder.sync) Could not find gpod')
46 pymtp_available = True
47 try:
48 import pymtp
49 except:
50 pymtp_available = False
51 log('(gpodder.sync) Could not find pymtp.')
53 try:
54 import mad
55 except:
56 log('(gpodder.sync) Could not find pymad')
58 try:
59 import eyeD3
60 except:
61 log( '(gpodder.sync) Could not find eyeD3')
63 try:
64 import Image
65 except:
66 log('(gpodder.sync) Could not find Python Imaging Library (PIL)')
68 # Register our dependencies for the synchronization module
69 services.dependency_manager.depend_on(_('iPod synchronization'), _('Support synchronization of podcasts to Apple iPod devices via libgpod.'), ['gpod', 'mad', 'eyeD3'], [])
70 services.dependency_manager.depend_on(_('MTP device synchronization'), _('Support synchronization of podcasts to devices using the Media Transfer Protocol via pymtp.'), ['pymtp'], [])
71 services.dependency_manager.depend_on(_('iPod OGG converter'), _('Convert OGG podcasts to MP3 files on synchronization to iPods using oggdec and LAME.'), [], ['oggdec', 'lame'])
72 services.dependency_manager.depend_on(_('iPod video podcasts'), _('Detect video lengths via MPlayer, to synchronize video podcasts to iPods.'), [], ['mplayer'])
73 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'], [])
75 import os
76 import os.path
77 import glob
78 import shutil
79 import sys
80 import time
81 import string
82 import email.Utils
83 import re
86 def open_device(config):
87 device_type = config.device_type
88 if device_type == 'ipod':
89 return iPodDevice(config)
90 elif device_type == 'filesystem':
91 return MP3PlayerDevice(config)
92 elif device_type == 'mtp':
93 return MTPDevice(config)
94 else:
95 return None
97 def get_track_length(filename):
98 length = gstreamer.get_track_length(filename)
99 if length is not None:
100 return length
102 if util.find_command('mplayer') is not None:
103 try:
104 mplayer_output = os.popen('mplayer -msglevel all=-1 -identify -vo null -ao null -frames 0 "%s" 2>/dev/null' % filename).read()
105 return int(float(mplayer_output[mplayer_output.index('ID_LENGTH'):].splitlines()[0][10:])*1000)
106 except:
107 pass
108 else:
109 log('Please install MPlayer for track length detection.')
111 try:
112 mad_info = mad.MadFile(filename)
113 return int(mad_info.total_time())
114 except:
115 pass
117 try:
118 eyed3_info = eyeD3.Mp3AudioFile(filename)
119 return int(eyed3_info.getPlayTime()*1000)
120 except:
121 pass
123 return int(60*60*1000*3) # Default is three hours (to be on the safe side)
126 class SyncTrack(object):
128 This represents a track that is on a device. You need
129 to specify at least the following keyword arguments,
130 because these will be used to display the track in the
131 GUI. All other keyword arguments are optional and can
132 be used to reference internal objects, etc... See the
133 iPod synchronization code for examples.
135 Keyword arguments needed:
136 playcount (How often has the track been played?)
137 podcast (Which podcast is this track from? Or: Folder name)
138 released (The release date of the episode)
140 If any of these fields is unknown, it should not be
141 passed to the function (the values will default to None
142 for all required fields).
144 def __init__(self, title, length, modified, **kwargs):
145 self.title = title
146 self.length = length
147 self.filesize = util.format_filesize(length)
148 self.modified = modified
150 # Set some (possible) keyword arguments to default values
151 self.playcount = None
152 self.podcast = None
153 self.released = None
155 # Convert keyword arguments to object attributes
156 self.__dict__.update(kwargs)
159 class Device(services.ObservableService):
160 def __init__(self, config):
161 self._config = config
162 self.cancelled = False
163 self.allowed_types = ['audio', 'video']
164 self.errors = []
165 self.tracks_list = []
166 signals = ['progress', 'sub-progress', 'status', 'done', 'post-done']
167 services.ObservableService.__init__(self, signals)
169 def open(self):
170 pass
172 def cancel(self):
173 self.cancelled = True
174 self.notify('status', _('Cancelled by user'))
176 def close(self):
177 self.notify('status', _('Writing data to disk'))
178 if self._config.sync_disks_after_transfer and not gpodder.win32:
179 successful_sync = (os.system('sync') == 0)
180 else:
181 log('Not syncing disks. Unmount your device before unplugging.', sender=self)
182 successful_sync = True
183 self.notify('done')
184 self.notify('post-done', self, successful_sync)
185 return True
187 def add_tracks(self, tracklist=[], force_played=False):
188 for track in list(tracklist):
189 # Filter tracks that are not meant to be synchronized
190 does_not_exist = not track.was_downloaded(and_exists=True)
191 exclude_played = track.is_played and not force_played and \
192 self._config.only_sync_not_played
193 wrong_type = track.file_type() not in self.allowed_types
195 if does_not_exist or exclude_played or wrong_type:
196 log('Excluding %s from sync', track.title, sender=self)
197 tracklist.remove(track)
199 compare_episodes = lambda a, b: cmp(a.pubDate, b.pubDate)
200 for id, track in enumerate(sorted(tracklist, cmp=compare_episodes)):
201 if self.cancelled:
202 return False
204 self.notify('progress', id+1, len(tracklist))
206 added = self.add_track(track)
208 if self._config.on_sync_mark_played:
209 log('Marking as played on transfer: %s', track.url, sender=self)
210 track.mark(is_played=True)
212 if added and self._config.on_sync_delete and not track.is_locked:
213 log('Removing episode after transfer: %s', track.url, sender=self)
214 track.delete_from_disk()
215 return True
217 def convert_track(self, episode):
218 filename = episode.local_filename(create=False)
219 # The file has to exist, if we ought to transfer it, and therefore,
220 # local_filename(create=False) must never return None as filename
221 assert filename is not None
222 (fn, extension) = os.path.splitext(filename)
223 if libconverter.converters.has_converter(extension):
224 if self._config.disable_pre_sync_conversion:
225 log('Pre-sync conversion is not enabled, set disable_pre_sync_conversion to "False" to enable')
226 return filename
228 log('Converting: %s', filename, sender=self)
229 callback_status = lambda percentage: self.notify('sub-progress', int(percentage))
230 local_filename = libconverter.converters.convert(filename, callback=callback_status)
232 if local_filename is None:
233 log('Cannot convert %s', original_filename, sender=self)
234 return filename
236 return str(local_filename)
238 return filename
240 def remove_tracks(self, tracklist=[]):
241 for id, track in enumerate(tracklist):
242 if self.cancelled:
243 return False
244 self.notify('progress', id, len(tracklist))
245 self.remove_track(track)
246 return True
248 def get_all_tracks(self):
249 pass
251 def add_track(self, track):
252 pass
254 def remove_track(self, track):
255 pass
257 def get_free_space(self):
258 pass
260 def episode_on_device(self, episode):
261 return self._track_on_device(episode.title)
263 def _track_on_device(self, track_name):
264 for t in self.tracks_list:
265 title = t.title
266 if track_name == title:
267 return t
268 return None
270 class iPodDevice(Device):
271 def __init__(self, config):
272 Device.__init__(self, config)
274 self.mountpoint = str(self._config.ipod_mount)
276 self.itdb = None
277 self.podcast_playlist = None
280 def get_free_space(self):
281 # Reserve 10 MiB for iTunesDB writing (to be on the safe side)
282 RESERVED_FOR_ITDB = 1024*1024*10
283 return util.get_free_disk_space(self.mountpoint) - RESERVED_FOR_ITDB
285 def open(self):
286 Device.open(self)
287 if not gpod_available or not os.path.isdir(self.mountpoint):
288 return False
290 self.notify('status', _('Opening iPod database'))
291 self.itdb = gpod.itdb_parse(self.mountpoint, None)
292 if self.itdb is None:
293 return False
295 self.itdb.mountpoint = self.mountpoint
296 self.podcasts_playlist = gpod.itdb_playlist_podcasts(self.itdb)
298 if self.podcasts_playlist:
299 self.notify('status', _('iPod opened'))
301 # build the initial tracks_list
302 self.tracks_list = self.get_all_tracks()
304 return True
305 else:
306 return False
308 def close(self):
309 if self.itdb is not None:
310 self.notify('status', _('Saving iPod database'))
311 gpod.itdb_write(self.itdb, None)
312 self.itdb = None
314 Device.close(self)
315 return True
317 def update_played_or_delete(self, channel, episodes, delete_from_db):
319 Check whether episodes on ipod are played and update as played
320 and delete if required.
322 for episode in episodes:
323 track = self.episode_on_device(episode)
324 if track:
325 gtrack = track.libgpodtrack
326 if gtrack.playcount > 0:
327 if delete_from_db and not gtrack.rating:
328 log('Deleting episode from db %s', gtrack.title, sender=self)
329 channel.delete_episode_by_url(gtrack.podcasturl)
330 else:
331 log('Marking episode as played %s', gtrack.title, sender=self)
332 episode.mark(is_played=True)
334 def purge(self):
335 for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
336 if gpod.itdb_filename_on_ipod(track) is None:
337 log('Episode has no file: %s', track.title, sender=self)
338 # self.remove_track_gpod(track)
339 elif track.playcount > 0 and not track.rating:
340 log('Purging episode: %s', track.title, sender=self)
341 self.remove_track_gpod(track)
343 def get_all_tracks(self):
344 tracks = []
345 for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
346 filename = gpod.itdb_filename_on_ipod(track)
347 length = util.calculate_size(filename)
349 timestamp = util.file_modification_timestamp(filename)
350 modified = util.format_date(timestamp)
351 released = gpod.itdb_time_mac_to_host(track.time_released)
352 released = util.format_date(released)
354 t = SyncTrack(track.title, length, modified, modified_sort=timestamp, libgpodtrack=track, playcount=track.playcount, released=released, podcast=track.artist)
355 tracks.append(t)
356 return tracks
358 def remove_track(self, track):
359 self.notify('status', _('Removing %s') % track.title)
360 self.remove_track_gpod(track.libgpodtrack)
362 def remove_track_gpod(self, track):
363 filename = gpod.itdb_filename_on_ipod(track)
365 try:
366 gpod.itdb_playlist_remove_track(self.podcasts_playlist, track)
367 except:
368 log('Track %s not in playlist', track.title, sender=self)
370 gpod.itdb_track_unlink(track)
371 util.delete_file(filename)
373 def add_track(self, episode):
374 self.notify('status', _('Adding %s') % episode.title)
375 for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
376 if episode.url == track.podcasturl:
377 if track.playcount > 0:
378 episode.mark(is_played=True)
379 # Mark as played on iPod if played locally (and set podcast flags)
380 self.set_podcast_flags(track, episode)
381 return True
383 original_filename = episode.local_filename(create=False)
384 # The file has to exist, if we ought to transfer it, and therefore,
385 # local_filename(create=False) must never return None as filename
386 assert original_filename is not None
387 local_filename = original_filename
389 if util.calculate_size(original_filename) > self.get_free_space():
390 log('Not enough space on %s, sync aborted...', self.mountpoint, sender = self)
391 d = {'episode': episode.title, 'mountpoint': self.mountpoint}
392 message =_('Error copying %(episode)s: Not enough free space on %(mountpoint)s')
393 self.errors.append(message % d)
394 self.cancelled = True
395 return False
397 local_filename = self.convert_track(episode)
399 (fn, extension) = os.path.splitext(local_filename)
400 if extension.lower().endswith('ogg'):
401 log('Cannot copy .ogg files to iPod.', sender=self)
402 return False
404 track = gpod.itdb_track_new()
406 # Add release time to track if pubDate has a valid value
407 if episode.pubDate > 0:
408 try:
409 # libgpod>= 0.5.x uses a new timestamp format
410 track.time_released = gpod.itdb_time_host_to_mac(int(episode.pubDate))
411 except:
412 # old (pre-0.5.x) libgpod versions expect mactime, so
413 # we're going to manually build a good mactime timestamp here :)
415 # + 2082844800 for unixtime => mactime (1970 => 1904)
416 track.time_released = int(episode.pubDate + 2082844800)
418 track.title = str(episode.title)
419 track.album = str(episode.channel.title)
420 track.artist = str(episode.channel.title)
421 track.description = str(util.remove_html_tags(episode.description))
423 track.podcasturl = str(episode.url)
424 track.podcastrss = str(episode.channel.url)
426 track.tracklen = get_track_length(local_filename)
427 track.size = os.path.getsize(local_filename)
429 if episode.file_type() == 'audio':
430 track.filetype = 'mp3'
431 track.mediatype = 0x00000004
432 elif episode.file_type() == 'video':
433 track.filetype = 'm4v'
434 track.mediatype = 0x00000006
436 self.set_podcast_flags(track, episode)
437 self.set_cover_art(track, local_filename)
439 gpod.itdb_track_add(self.itdb, track, -1)
440 gpod.itdb_playlist_add_track(self.podcasts_playlist, track, -1)
441 gpod.itdb_cp_track_to_ipod(track, str(local_filename), None)
443 # If the file has been converted, delete the temporary file here
444 if local_filename != original_filename:
445 util.delete_file(local_filename)
447 return True
449 def set_podcast_flags(self, track, episode):
450 try:
451 # Set blue bullet for unplayed tracks on 5G iPods
452 if episode.is_played:
453 track.mark_unplayed = 0x01
454 if track.playcount == 0:
455 track.playcount = 1
456 else:
457 if track.playcount > 0 or track.bookmark_time > 0:
458 #track is partially played so no blue bullet
459 track.mark_unplayed = 0x01
460 else:
461 #totally unplayed
462 track.mark_unplayed = 0x02
464 # Set several flags for to podcast values
465 track.remember_playback_position = 0x01
466 track.flag1 = 0x02
467 track.flag2 = 0x01
468 track.flag3 = 0x01
469 track.flag4 = 0x01
470 except:
471 log('Seems like your python-gpod is out-of-date.', sender=self)
473 def set_cover_art(self, track, local_filename):
474 try:
475 tag = eyeD3.Tag()
476 if tag.link(local_filename):
477 if 'APIC' in tag.frames and len(tag.frames['APIC']) > 0:
478 apic = tag.frames['APIC'][0]
480 extension = 'jpg'
481 if apic.mimeType == 'image/png':
482 extension = 'png'
483 cover_filename = '%s.cover.%s' (local_filename, extension)
485 cover_file = open(cover_filename, 'w')
486 cover_file.write(apic.imageData)
487 cover_file.close()
489 gpod.itdb_track_set_thumbnails(track, cover_filename)
490 return True
491 except:
492 log('Error getting cover using eyeD3', sender=self)
494 try:
495 cover_filename = os.path.join(os.path.dirname(local_filename), 'folder.jpg')
497 if os.path.isfile(cover_filename):
498 gpod.itdb_track_set_thumbnails(track, cover_filename)
499 return True
500 except:
501 log('Error getting cover using channel cover', sender=self)
503 return False
506 class MP3PlayerDevice(Device):
507 # if different players use other filenames besides
508 # .scrobbler.log, add them to this list
509 scrobbler_log_filenames = ['.scrobbler.log']
511 def __init__(self, config):
512 Device.__init__(self, config)
513 self.destination = self._config.mp3_player_folder
514 self.buffer_size = 1024*1024 # 1 MiB
515 self.scrobbler_log = []
517 def get_free_space(self):
518 return util.get_free_disk_space(self.destination)
520 def open(self):
521 Device.open(self)
522 self.notify('status', _('Opening MP3 player'))
523 if util.directory_is_writable(self.destination):
524 self.notify('status', _('MP3 player opened'))
525 # build the initial tracks_list
526 self.tracks_list = self.get_all_tracks()
527 if self._config.mp3_player_use_scrobbler_log:
528 mp3_player_mount_point = util.find_mount_point(self.destination)
529 # If a moint point cannot be found look inside self.destination for scrobbler_log_filenames
530 # this prevents us from os.walk()'ing the entire / filesystem
531 if mp3_player_mount_point == '/':
532 mp3_player_mount_point = self.destination
533 log_location = self.find_scrobbler_log(mp3_player_mount_point)
534 if log_location is not None and self.load_audioscrobbler_log(log_location):
535 log('Using Audioscrobbler log data to mark tracks as played', sender=self)
536 return True
537 else:
538 return False
540 def add_track(self, episode):
541 self.notify('status', _('Adding %s') % episode.title.decode('utf-8', 'ignore'))
543 if self._config.fssync_channel_subfolders:
544 # Add channel title as subfolder
545 folder = episode.channel.title
546 # Clean up the folder name for use on limited devices
547 folder = util.sanitize_filename(folder, self._config.mp3_player_max_filename_length)
548 folder = os.path.join(self.destination, folder)
549 else:
550 folder = self.destination
552 from_file = util.sanitize_encoding(self.convert_track(episode))
553 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)
555 to_file = filename_base + os.path.splitext(from_file)[1].lower()
557 # dirty workaround: on bad (empty) episode titles,
558 # we simply use the from_file basename
559 # (please, podcast authors, FIX YOUR RSS FEEDS!)
560 if os.path.splitext(to_file)[0] == '':
561 to_file = os.path.basename(from_file)
563 to_file = os.path.join(folder, to_file)
565 if not os.path.exists(folder):
566 try:
567 os.makedirs(folder)
568 except:
569 log('Cannot create folder on MP3 player: %s', folder, sender=self)
570 return False
572 if self._config.mp3_player_use_scrobbler_log and not episode.is_played:
573 # FIXME: This misses some things when channel.title<>album tag which is what
574 # the scrobbling entity will be using.
575 if [episode.channel.title, episode.title] in self.scrobbler_log:
576 log('Marking "%s" from "%s" as played', episode.title, episode.channel.title, sender=self)
577 episode.mark(is_played=True)
579 if self._config.rockbox_copy_coverart and not os.path.exists(os.path.join(folder, 'cover.bmp')):
580 log('Creating Rockbox album art for "%s"', episode.channel.title, sender=self)
581 self.copy_player_cover_art(folder, from_file, \
582 'cover.bmp', 'BMP', self._config.rockbox_coverart_size)
584 if self._config.custom_player_copy_coverart \
585 and not os.path.exists(os.path.join(folder, \
586 self._config.custom_player_coverart_name)):
587 log('Creating custom player album art for "%s"',
588 episode.channel.title, sender=self)
589 self.copy_player_cover_art(folder, from_file, \
590 self._config.custom_player_coverart_name, \
591 self._config.custom_player_coverart_format, \
592 self._config.custom_player_coverart_size)
594 if not os.path.exists(to_file):
595 log('Copying %s => %s', os.path.basename(from_file), to_file.decode(util.encoding), sender=self)
596 return self.copy_file_progress(from_file, to_file)
598 return True
600 def copy_file_progress(self, from_file, to_file):
601 try:
602 out_file = open(to_file, 'wb')
603 except IOError, ioerror:
604 d = {'filename': ioerror.filename, 'message': ioerror.strerror}
605 self.errors.append(_('Error opening %(filename)s: %(message)s') % d)
606 self.cancel()
607 return False
609 try:
610 in_file = open(from_file, 'rb')
611 except IOError, ioerror:
612 d = {'filename': ioerror.filename, 'message': ioerror.strerror}
613 self.errors.append(_('Error opening %(filename)s: %(message)s') % d)
614 self.cancel()
615 return False
617 in_file.seek(0, 2)
618 bytes = in_file.tell()
619 in_file.seek(0)
621 bytes_read = 0
622 s = in_file.read(self.buffer_size)
623 while s:
624 bytes_read += len(s)
625 try:
626 out_file.write(s)
627 except IOError, ioerror:
628 self.errors.append(ioerror.strerror)
629 try:
630 out_file.close()
631 except:
632 pass
633 try:
634 log('Trying to remove partially copied file: %s' % to_file, sender=self)
635 os.unlink( to_file)
636 log('Yeah! Unlinked %s at least..' % to_file, sender=self)
637 except:
638 log('Error while trying to unlink %s. OH MY!' % to_file, sender=self)
639 self.cancel()
640 return False
641 self.notify('sub-progress', int(min(100, 100*float(bytes_read)/float(bytes))))
642 s = in_file.read(self.buffer_size)
643 out_file.close()
644 in_file.close()
646 return True
648 def get_all_tracks(self):
649 tracks = []
651 if self._config.fssync_channel_subfolders:
652 files = glob.glob(os.path.join(self.destination, '*', '*'))
653 else:
654 files = glob.glob(os.path.join(self.destination, '*'))
656 for filename in files:
657 (title, extension) = os.path.splitext(os.path.basename(filename))
658 length = util.calculate_size(filename)
660 timestamp = util.file_modification_timestamp(filename)
661 modified = util.format_date(timestamp)
662 if self._config.fssync_channel_subfolders:
663 podcast_name = os.path.basename(os.path.dirname(filename))
664 else:
665 podcast_name = None
667 t = SyncTrack(title, length, modified, modified_sort=timestamp, filename=filename, podcast=podcast_name)
668 tracks.append(t)
669 return tracks
671 def episode_on_device(self, episode):
672 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)
673 return self._track_on_device(e)
675 def remove_track(self, track):
676 self.notify('status', _('Removing %s') % track.title)
677 util.delete_file(track.filename)
678 directory = os.path.dirname(track.filename)
679 if self.directory_is_empty(directory) and self._config.fssync_channel_subfolders:
680 try:
681 os.rmdir(directory)
682 except:
683 log('Cannot remove %s', directory, sender=self)
685 def directory_is_empty(self, directory):
686 files = glob.glob(os.path.join(directory, '*'))
687 dotfiles = glob.glob(os.path.join(directory, '.*'))
688 return len(files+dotfiles) == 0
690 def find_scrobbler_log(self, mount_point):
691 """ find an audioscrobbler log file from log_filenames in the mount_point dir """
692 for dirpath, dirnames, filenames in os.walk(mount_point):
693 for log_file in self.scrobbler_log_filenames:
694 filename = os.path.join(dirpath, log_file)
695 if os.path.isfile(filename):
696 return filename
698 # No scrobbler log on that device
699 return None
701 def copy_player_cover_art(self, destination, local_filename, \
702 cover_dst_name, cover_dst_format, \
703 cover_dst_size):
705 Try to copy the channel cover to the podcast folder on the MP3
706 player. This makes the player, e.g. Rockbox (rockbox.org), display the
707 cover art in its interface.
709 You need the Python Imaging Library (PIL) installed to be able to
710 convert the cover file to a Bitmap file, which Rockbox needs.
712 try:
713 cover_loc = os.path.join(os.path.dirname(local_filename), 'cover')
714 if not os.path.exists(cover_loc):
715 cover_loc = os.path.join(os.path.dirname(local_filename), '.cover')
716 cover_dst = os.path.join(destination, cover_dst_name)
717 if os.path.isfile(cover_loc):
718 log('Creating cover art file on player', sender=self)
719 log('Cover art size is %s', cover_dst_size, sender=self)
720 size = (cover_dst_size, cover_dst_size)
721 try:
722 cover = Image.open(cover_loc)
723 cover.thumbnail(size)
724 cover.save(cover_dst, cover_dst_format)
725 except IOError:
726 log('Cannot create %s (PIL?)', cover_dst, traceback=True, sender=self)
727 return True
728 else:
729 log('No cover available to set as player cover', sender=self)
730 return True
731 except:
732 log('Error getting cover using channel cover', sender=self)
733 return False
736 def load_audioscrobbler_log(self, log_file):
737 """ Retrive track title and artist info for all the entries
738 in an audioscrobbler portable player format logfile
739 http://www.audioscrobbler.net/wiki/Portable_Player_Logging """
740 try:
741 log('Opening "%s" as AudioScrobbler log.', log_file, sender=self)
742 f = open(log_file, 'r')
743 entries = f.readlines()
744 f.close()
745 except IOError, ioerror:
746 log('Error: "%s" cannot be read.', log_file, sender=self)
747 return False
749 try:
750 # Scrobble Log Format: http://www.audioscrobbler.net/wiki/Portable_Player_Logging
751 # Notably some fields are optional so will appear as \t\t.
752 # Conforming scrobblers should strip any \t's from the actual fields.
753 for entry in entries:
754 entry = entry.split('\t')
755 if len(entry)>=5:
756 artist, album, track, pos, length, rating = entry[:6]
757 # L means at least 50% of the track was listened to (S means < 50%)
758 if 'L' in rating:
759 # Whatever is writing the logs will only have the taginfo in the
760 # file to work from. Mostly album~=channel name
761 if len(track):
762 self.scrobbler_log.append([album, track])
763 else:
764 log('Skipping logging of %s (missing track)', album)
765 else:
766 log('Skipping scrobbler entry: %d elements %s', len(entry), entry)
768 except:
769 log('Error while parsing "%s".', log_file, sender=self)
771 return True
773 class MTPDevice(Device):
774 def __init__(self, config):
775 Device.__init__(self, config)
776 self.__model_name = None
777 self.__MTPDevice = pymtp.MTP()
779 def __callback(self, sent, total):
780 if self.cancelled:
781 return -1
782 percentage = round(float(sent)/float(total)*100)
783 text = ('%i%%' % percentage)
784 self.notify('progress', sent, total, text)
786 def __date_to_mtp(self, date):
788 this function format the given date and time to a string representation
789 according to MTP specifications: YYYYMMDDThhmmss.s
791 return
792 the string representation od the given date
794 if not date:
795 return ""
796 try:
797 d = time.gmtime(date)
798 return time.strftime("%Y%m%d-%H%M%S.0Z", d)
799 except Exception, exc:
800 log('ERROR: An error has happend while trying to convert date to an mtp string (%s)', exc, sender=self)
801 return None
803 def __mtp_to_date(self, mtp):
805 this parse the mtp's string representation for date
806 according to specifications (YYYYMMDDThhmmss.s) to
807 a python time object
810 if not mtp:
811 return None
813 try:
814 mtp = mtp.replace(" ", "0") # replace blank with 0 to fix some invalid string
815 d = time.strptime(mtp[:8] + mtp[9:13],"%Y%m%d%H%M%S")
816 _date = calendar.timegm(d)
817 if len(mtp)==20:
818 # TIME ZONE SHIFTING: the string contains a hour/min shift relative to a time zone
819 try:
820 shift_direction=mtp[15]
821 hour_shift = int(mtp[16:18])
822 minute_shift = int(mtp[18:20])
823 shift_in_sec = hour_shift * 3600 + minute_shift * 60
824 if shift_direction == "+":
825 _date += shift_in_sec
826 elif shift_direction == "-":
827 _date -= shift_in_sec
828 else:
829 raise ValueError("Expected + or -")
830 except Exception, exc:
831 log('WARNING: ignoring invalid time zone information for %s (%s)', mtp, exc, sender=self)
832 return max( 0, _date )
833 except Exception, exc:
834 log('WARNING: the mtp date "%s" can not be parsed against mtp specification (%s)', mtp, exc, sender=self)
835 return None
837 def get_name(self):
839 this function try to find a nice name for the device.
840 First, it tries to find a friendly (user assigned) name
841 (this name can be set by other application and is stored on the device).
842 if no friendly name was assign, it tries to get the model name (given by the vendor).
843 If no name is found at all, a generic one is returned.
845 Once found, the name is cached internaly to prevent reading again the device
847 return
848 the name of the device
851 if self.__model_name:
852 return self.__model_name
854 self.__model_name = self.__MTPDevice.get_devicename() # actually libmtp.Get_Friendlyname
855 if not self.__model_name or self.__model_name == "?????":
856 self.__model_name = self.__MTPDevice.get_modelname()
857 if not self.__model_name:
858 self.__model_name = "MTP device"
860 return self.__model_name
862 def open(self):
863 Device.open(self)
864 log("opening the MTP device", sender=self)
865 self.notify('status', _('Opening the MTP device'), )
867 try:
868 self.__MTPDevice.connect()
869 # build the initial tracks_list
870 self.tracks_list = self.get_all_tracks()
871 except Exception, exc:
872 log('unable to find an MTP device (%s)', exc, sender=self, traceback=True)
873 return False
875 self.notify('status', _('%s opened') % self.get_name())
876 return True
878 def close(self):
879 log("closing %s", self.get_name(), sender=self)
880 self.notify('status', _('Closing %s') % self.get_name())
882 try:
883 self.__MTPDevice.disconnect()
884 except Exception, exc:
885 log('unable to close %s (%s)', self.get_name(), exc, sender=self)
886 return False
888 self.notify('status', _('%s closed') % self.get_name())
889 Device.close(self)
890 return True
892 def add_track(self, episode):
893 self.notify('status', _('Adding %s...') % episode.title)
894 filename = str(self.convert_track(episode))
895 log("sending " + filename + " (" + episode.title + ").", sender=self)
897 try:
898 # verify free space
899 needed = util.calculate_size(filename)
900 free = self.get_free_space()
901 if needed > free:
902 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)
903 self.cancelled = True
904 return False
906 # fill metadata
907 metadata = pymtp.LIBMTP_Track()
908 metadata.title = str(episode.title)
909 metadata.artist = str(episode.channel.title)
910 metadata.album = str(episode.channel.title)
911 metadata.genre = "podcast"
912 metadata.date = self.__date_to_mtp(episode.pubDate)
913 metadata.duration = get_track_length(str(filename))
915 # send the file
916 self.__MTPDevice.send_track_from_file(filename,
917 util.sanitize_filename(metadata.title),
918 metadata, 0, callback=self.__callback)
919 except:
920 log('unable to add episode %s', episode.title, sender=self, traceback=True)
921 return False
923 return True
925 def remove_track(self, sync_track):
926 self.notify('status', _('Removing %s') % sync_track.mtptrack.title)
927 log("removing %s", sync_track.mtptrack.title, sender=self)
929 try:
930 self.__MTPDevice.delete_object(sync_track.mtptrack.item_id)
931 except Exception, exc:
932 log('unable remove file %s (%s)', sync_track.mtptrack.filename, exc, sender=self)
934 log('%s removed', sync_track.mtptrack.title , sender=self)
936 def get_all_tracks(self):
937 try:
938 listing = self.__MTPDevice.get_tracklisting(callback=self.__callback)
939 except Exception, exc:
940 log('unable to get file listing %s (%s)', exc, sender=self)
942 tracks = []
943 for track in listing:
944 title = track.title
945 if not title or title=="": title=track.filename
946 if len(title) > 50: title = title[0:49] + '...'
947 artist = track.artist
948 if artist and len(artist) > 50: artist = artist[0:49] + '...'
949 length = track.filesize
950 age_in_days = 0
951 date = self.__mtp_to_date(track.date)
952 if not date:
953 modified = track.date # not a valid mtp date. Display what mtp gave anyway
954 modified_sort = -1 # no idea how to sort invalid date
955 else:
956 modified = util.format_date(date)
957 modified_sort = date
959 t = SyncTrack(title, length, modified, modified_sort=modified_sort, mtptrack=track, podcast=artist)
960 tracks.append(t)
961 return tracks
963 def get_free_space(self):
964 return self.__MTPDevice.get_freespace()