Experimental support for gtkhtml2 for episode dialog (bug 162)
[gpodder.git] / src / gpodder / sync.py
blob7d7f536e1a4782d8dd3df47b83932c1e78e24947
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2008 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)
26 from gpodder import util
27 from gpodder import services
28 from gpodder import libconverter
29 from gpodder import libtagupdate
31 from gpodder.liblogger import log
32 from gpodder.libgpodder import gl
33 from gpodder.dbsqlite import db
35 import time
36 import calendar
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 import os
68 import os.path
69 import glob
70 import shutil
71 import sys
72 import time
73 import string
74 import email.Utils
75 import re
78 def open_device():
79 device_type = gl.config.device_type
80 if device_type == 'ipod':
81 return iPodDevice()
82 elif device_type == 'filesystem':
83 return MP3PlayerDevice()
84 elif device_type == 'mtp':
85 return MTPDevice()
86 else:
87 return None
89 def get_track_length(filename):
90 if util.find_command('mplayer') is not None:
91 try:
92 mplayer_output = os.popen('mplayer -msglevel all=-1 -identify -vo null -ao null -frames 0 "%s" 2>/dev/null' % filename).read()
93 return int(float(mplayer_output[mplayer_output.index('ID_LENGTH'):].splitlines()[0][10:])*1000)
94 except:
95 pass
96 else:
97 log('Please install MPlayer for track length detection.')
99 try:
100 mad_info = mad.MadFile(filename)
101 return int(mad_info.total_time())
102 except:
103 pass
105 try:
106 eyed3_info = eyeD3.Mp3AudioFile(filename)
107 return int(eyed3_info.getPlayTime()*1000)
108 except:
109 pass
111 return int(60*60*1000*3) # Default is three hours (to be on the safe side)
114 class SyncTrack(object):
116 This represents a track that is on a device. You need
117 to specify at least the following keyword arguments,
118 because these will be used to display the track in the
119 GUI. All other keyword arguments are optional and can
120 be used to reference internal objects, etc... See the
121 iPod synchronization code for examples.
123 Keyword arguments needed:
124 playcount (How often has the track been played?)
125 podcast (Which podcast is this track from? Or: Folder name)
126 released (The release date of the episode)
128 If any of these fields is unknown, it should not be
129 passed to the function (the values will default to None
130 for all required fields).
132 def __init__(self, title, length, modified, **kwargs):
133 self.title = title
134 self.length = length
135 self.filesize = util.format_filesize(length, gl.config.use_si_units)
136 self.modified = modified
138 # Set some (possible) keyword arguments to default values
139 self.playcount = None
140 self.podcast = None
141 self.released = None
143 # Convert keyword arguments to object attributes
144 self.__dict__.update(kwargs)
147 class Device(services.ObservableService):
148 def __init__(self):
149 self.cancelled = False
150 self.allowed_types = ['audio', 'video']
151 self.errors = []
152 self.tracks_list = []
153 signals = ['progress', 'sub-progress', 'status', 'done', 'post-done']
154 services.ObservableService.__init__(self, signals)
156 def open(self):
157 pass
159 def cancel(self):
160 self.cancelled = True
161 self.notify('status', _('Cancelled by user'))
163 def close(self):
164 self.notify('status', _('Writing data to disk'))
165 successful_sync = not os.system('sync')
166 self.notify('done')
167 self.notify('post-done', self, successful_sync)
168 return True
170 def add_tracks(self, tracklist=[], force_played=False):
171 for id, track in enumerate(tracklist):
172 if self.cancelled:
173 return False
175 self.notify('progress', id+1, len(tracklist))
177 if not track.was_downloaded(and_exists=True):
178 continue
180 if track.is_played and gl.config.only_sync_not_played and not force_played:
181 continue
183 if track.file_type() not in self.allowed_types:
184 continue
186 added = self.add_track(track)
188 if gl.config.on_sync_mark_played:
189 log('Marking as played on transfer: %s', track.url, sender=self)
190 db.mark_episode(track.url, is_played=True)
192 if added and gl.config.on_sync_delete:
193 log('Removing episode after transfer: %s', track.url, sender=self)
194 track.delete_from_disk()
195 return True
197 def remove_tracks(self, tracklist=[]):
198 for id, track in enumerate(tracklist):
199 if self.cancelled:
200 return False
201 self.notify('progress', id, len(tracklist))
202 self.remove_track(track)
203 return True
205 def get_all_tracks(self):
206 pass
208 def add_track(self, track):
209 pass
211 def remove_track(self, track):
212 pass
214 def get_free_space(self):
215 pass
217 def episode_on_device(self, episode):
218 return self._track_on_device(episode.title)
220 def _track_on_device( self, track_name ):
221 for t in self.tracks_list:
222 if track_name == t.title:
223 return t
224 return False
226 class iPodDevice(Device):
227 def __init__(self):
228 Device.__init__(self)
230 self.mountpoint = str(gl.config.ipod_mount)
232 self.itdb = None
233 self.podcast_playlist = None
236 def get_free_space(self):
237 # Reserve 10 MiB for iTunesDB writing (to be on the safe side)
238 RESERVED_FOR_ITDB = 1024*1024*10
239 return util.get_free_disk_space(self.mountpoint) - RESERVED_FOR_ITDB
241 def open(self):
242 Device.open(self)
243 if not gpod_available or not os.path.isdir(self.mountpoint):
244 return False
246 self.notify('status', _('Opening iPod database'))
247 self.itdb = gpod.itdb_parse(self.mountpoint, None)
248 if self.itdb is None:
249 return False
251 self.itdb.mountpoint = self.mountpoint
252 self.podcasts_playlist = gpod.itdb_playlist_podcasts(self.itdb)
254 if self.podcasts_playlist:
255 self.notify('status', _('iPod opened'))
257 # build the initial tracks_list
258 self.tracks_list = self.get_all_tracks()
260 return True
261 else:
262 return False
264 def close(self):
265 if self.itdb is not None:
266 self.notify('status', _('Saving iPod database'))
267 gpod.itdb_write(self.itdb, None)
268 self.itdb = None
270 if gl.config.ipod_write_gtkpod_extended:
271 # Fix up iTunesDB.ext (gtkpod extended database),
272 # so gtkpod will not complain about a wrong sha1sum
273 self.notify('status', _('Writing extended gtkpod database'))
274 ext_filename = os.path.join(self.mountpoint, 'iPod_Control', 'iTunes', 'iTunesDB.ext')
275 idb_filename = os.path.join(self.mountpoint, 'iPod_Control', 'iTunes', 'iTunesDB')
276 if os.path.exists(ext_filename) and os.path.exists(idb_filename):
277 try:
278 db = gpod.ipod.Database(self.mountpoint)
279 gpod.gtkpod.parse(ext_filename, db, idb_filename)
280 gpod.gtkpod.write(ext_filename, db, idb_filename)
281 db.close()
282 except:
283 log('Error when writing iTunesDB.ext', sender=self, traceback=True)
284 else:
285 log('I could not find %s or %s. Will not update extended gtkpod DB.', ext_filename, idb_filename, sender=self)
286 else:
287 log('Not writing extended gtkpod DB. Set "ipod_write_gpod_extended" to True if I should write it.', sender=self)
289 Device.close(self)
290 return True
292 def get_all_tracks(self):
293 tracks = []
294 for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
295 filename = gpod.itdb_filename_on_ipod(track)
296 length = util.calculate_size(filename)
298 age_in_days = util.file_age_in_days(filename)
299 modified = util.file_age_to_string(age_in_days)
300 released = gpod.itdb_time_mac_to_host(track.time_released)
301 released = util.format_date(released)
303 t = SyncTrack(track.title, length, modified, libgpodtrack=track, playcount=track.playcount, released=released, podcast=track.artist)
304 tracks.append(t)
305 return tracks
307 def remove_track(self, track):
308 self.notify('status', _('Removing %s') % track.title)
309 track = track.libgpodtrack
310 filename = gpod.itdb_filename_on_ipod(track)
312 try:
313 gpod.itdb_playlist_remove_track(self.podcasts_playlist, track)
314 except:
315 log('Track %s not in playlist', track.title, sender=self)
317 gpod.itdb_track_unlink(track)
318 util.delete_file(filename)
320 def add_track(self, episode):
321 self.notify('status', _('Adding %s') % episode.title)
322 for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
323 if episode.url == track.podcasturl:
324 if track.playcount > 0:
325 db.mark_episode(track.podcasturl, is_played=True)
326 # Mark as played on iPod if played locally (and set podcast flags)
327 self.set_podcast_flags(track)
328 return True
330 original_filename = str(episode.local_filename())
331 local_filename = original_filename
333 if util.calculate_size(original_filename) > self.get_free_space():
334 log('Not enough space on %s, sync aborted...', self.mountpoint, sender = self)
335 self.errors.append( _('Error copying %s: Not enough free disk space on %s') % (episode.title, self.mountpoint))
336 self.cancelled = True
337 return False
339 (fn, extension) = os.path.splitext(original_filename)
340 if libconverter.converters.has_converter(extension):
341 log('Converting: %s', original_filename, sender=self)
342 callback_status = lambda percentage: self.notify('sub-progress', int(percentage))
343 local_filename = libconverter.converters.convert(original_filename, callback=callback_status)
345 if not libtagupdate.update_metadata_on_file(local_filename, title=episode.title, artist=episode.channel.title):
346 log('Could not set metadata on converted file %s', local_filename, sender=self)
348 if local_filename is None:
349 log('Cannot convert %s', original_filename, sender=self)
350 return False
351 else:
352 local_filename = str(local_filename)
354 (fn, extension) = os.path.splitext(local_filename)
355 if extension.lower().endswith('ogg'):
356 log('Cannot copy .ogg files to iPod.', sender=self)
357 return False
359 track = gpod.itdb_track_new()
361 # Add release time to track if pubDate has a valid value
362 if episode.pubDate > 0:
363 try:
364 # libgpod>= 0.5.x uses a new timestamp format
365 track.time_released = gpod.itdb_time_host_to_mac(int(episode.pubDate))
366 except:
367 # old (pre-0.5.x) libgpod versions expect mactime, so
368 # we're going to manually build a good mactime timestamp here :)
370 # + 2082844800 for unixtime => mactime (1970 => 1904)
371 track.time_released = int(episode.pubDate + 2082844800)
373 track.title = str(episode.title)
374 track.album = str(episode.channel.title)
375 track.artist = str(episode.channel.title)
376 track.description = str(util.remove_html_tags(episode.description))
378 track.podcasturl = str(episode.url)
379 track.podcastrss = str(episode.channel.url)
381 track.tracklen = get_track_length(local_filename)
382 track.size = os.path.getsize(local_filename)
384 if episode.file_type() == 'audio':
385 track.filetype = 'mp3'
386 track.mediatype = 0x00000004
387 elif episode.file_type() == 'video':
388 track.filetype = 'm4v'
389 track.mediatype = 0x00000006
391 self.set_podcast_flags(track)
392 self.set_cover_art(track, local_filename)
394 gpod.itdb_track_add(self.itdb, track, -1)
395 gpod.itdb_playlist_add_track(self.podcasts_playlist, track, -1)
396 gpod.itdb_cp_track_to_ipod( track, local_filename, None)
398 # If the file has been converted, delete the temporary file here
399 if local_filename != original_filename:
400 util.delete_file(local_filename)
402 return True
404 def set_podcast_flags(self, track):
405 try:
406 # Set blue bullet for unplayed tracks on 5G iPods
407 episode = db.load_episode(track.podcasturl)
408 if episode['is_played']:
409 track.mark_unplayed = 0x01
410 if track.playcount == 0:
411 track.playcount = 1
412 else:
413 track.mark_unplayed = 0x02
415 # Set several flags for to podcast values
416 track.remember_playback_position = 0x01
417 track.flag1 = 0x02
418 track.flag2 = 0x01
419 track.flag3 = 0x01
420 track.flag4 = 0x01
421 except:
422 log('Seems like your python-gpod is out-of-date.', sender=self)
424 def set_cover_art(self, track, local_filename):
425 try:
426 tag = eyeD3.Tag()
427 if tag.link(local_filename):
428 if 'APIC' in tag.frames and len(tag.frames['APIC']) > 0:
429 apic = tag.frames['APIC'][0]
431 extension = 'jpg'
432 if apic.mimeType == 'image/png':
433 extension = 'png'
434 cover_filename = '%s.cover.%s' (local_filename, extension)
436 cover_file = open(cover_filename, 'w')
437 cover_file.write(apic.imageData)
438 cover_file.close()
440 gpod.itdb_track_set_thumbnails(track, cover_filename)
441 return True
442 except:
443 log('Error getting cover using eyeD3', sender=self)
445 try:
446 cover_filename = os.path.join(os.path.dirname(local_filename), 'cover')
447 if os.path.isfile(cover_filename):
448 gpod.itdb_track_set_thumbnails(track, cover_filename)
449 return True
450 except:
451 log('Error getting cover using channel cover', sender=self)
453 return False
456 class MP3PlayerDevice(Device):
457 # if different players use other filenames besides
458 # .scrobbler.log, add them to this list
459 scrobbler_log_filenames = ['.scrobbler.log']
461 # This is the maximum length of a file name that is
462 # created on the MP3 player, because FAT32 has a
463 # 255-character limit for the whole path
464 MAX_FILENAME_LENGTH = gl.config.mp3_player_max_filename_length
466 def __init__(self):
467 Device.__init__(self)
468 self.destination = gl.config.mp3_player_folder
469 self.buffer_size = 1024*1024 # 1 MiB
470 self.scrobbler_log = []
472 def get_free_space(self):
473 return util.get_free_disk_space(self.destination)
475 def open(self):
476 Device.open(self)
477 self.notify('status', _('Opening MP3 player'))
478 if util.directory_is_writable(self.destination):
479 self.notify('status', _('MP3 player opened'))
480 # build the initial tracks_list
481 self.tracks_list = self.get_all_tracks()
482 if gl.config.mp3_player_use_scrobbler_log:
483 mp3_player_mount_point = util.find_mount_point(self.destination)
484 # If a moint point cannot be found look inside self.destination for scrobbler_log_filenames
485 # this prevents us from os.walk()'ing the entire / filesystem
486 if mp3_player_mount_point == '/':
487 mp3_player_mount_point = self.destination
488 log_location = self.find_scrobbler_log(mp3_player_mount_point)
489 if log_location is not None and self.load_audioscrobbler_log(log_location):
490 log('Using Audioscrobbler log data to mark tracks as played', sender=self)
491 return True
492 else:
493 return False
495 def add_track(self, episode):
496 self.notify('status', _('Adding %s') % episode.title)
498 if gl.config.fssync_channel_subfolders:
499 # Add channel title as subfolder
500 folder = episode.channel.title
501 # Clean up the folder name for use on limited devices
502 folder = util.sanitize_filename(folder, self.MAX_FILENAME_LENGTH)
503 folder = os.path.join(self.destination, folder)
504 else:
505 folder = self.destination
507 from_file = util.sanitize_encoding(episode.local_filename())
508 filename_base = util.sanitize_filename(episode.sync_filename(), self.MAX_FILENAME_LENGTH)
510 to_file = filename_base + os.path.splitext(from_file)[1].lower()
512 # dirty workaround: on bad (empty) episode titles,
513 # we simply use the from_file basename
514 # (please, podcast authors, FIX YOUR RSS FEEDS!)
515 if os.path.splitext(to_file)[0] == '':
516 to_file = os.path.basename(from_file)
518 to_file = os.path.join(folder, to_file)
520 if not os.path.exists(folder):
521 try:
522 os.makedirs(folder)
523 except:
524 log('Cannot create folder on MP3 player: %s', folder, sender=self)
525 return False
527 if (gl.config.mp3_player_use_scrobbler_log and not episode.is_played
528 and [episode.channel.title, episode.title] in self.scrobbler_log):
529 log('Marking "%s" from "%s" as played', episode.title, episode.channel.title, sender=self)
530 db.mark_episode(episode.url, is_played=True)
532 if gl.config.rockbox_copy_coverart and not os.path.exists(os.path.join(folder, 'cover.bmp')):
533 log('Creating Rockbox album art for "%s"', episode.channel.title, sender=self)
534 self.copy_rockbox_cover_art(folder, from_file)
536 if not os.path.exists(to_file):
537 log('Copying %s => %s', os.path.basename(from_file), to_file.decode(util.encoding), sender=self)
538 return self.copy_file_progress(from_file, to_file)
540 return True
542 def copy_file_progress(self, from_file, to_file):
543 try:
544 out_file = open(to_file, 'wb')
545 except IOError, ioerror:
546 self.errors.append(_('Error opening %s: %s') % (ioerror.filename, ioerror.strerror))
547 self.cancel()
548 return False
550 try:
551 in_file = open(from_file, 'rb')
552 except IOError, ioerror:
553 self.errors.append(_('Error opening %s: %s') % (ioerror.filename, ioerror.strerror))
554 self.cancel()
555 return False
557 in_file.seek(0, 2)
558 bytes = in_file.tell()
559 in_file.seek(0)
561 bytes_read = 0
562 s = in_file.read(self.buffer_size)
563 while s:
564 bytes_read += len(s)
565 try:
566 out_file.write(s)
567 except IOError, ioerror:
568 self.errors.append(ioerror.strerror)
569 try:
570 out_file.close()
571 except:
572 pass
573 try:
574 log('Trying to remove partially copied file: %s' % to_file, sender=self)
575 os.unlink( to_file)
576 log('Yeah! Unlinked %s at least..' % to_file, sender=self)
577 except:
578 log('Error while trying to unlink %s. OH MY!' % to_file, sender=self)
579 self.cancel()
580 return False
581 self.notify('sub-progress', int(min(100, 100*float(bytes_read)/float(bytes))))
582 s = in_file.read(self.buffer_size)
583 out_file.close()
584 in_file.close()
586 return True
588 def get_all_tracks(self):
589 tracks = []
591 if gl.config.fssync_channel_subfolders:
592 files = glob.glob(os.path.join(self.destination, '*', '*'))
593 else:
594 files = glob.glob(os.path.join(self.destination, '*'))
596 for filename in files:
597 (title, extension) = os.path.splitext(os.path.basename(filename))
598 length = util.calculate_size(filename)
600 age_in_days = util.file_age_in_days(filename)
601 modified = util.file_age_to_string(age_in_days)
602 if gl.config.fssync_channel_subfolders:
603 podcast_name = os.path.basename(os.path.dirname(filename))
604 else:
605 podcast_name = None
607 t = SyncTrack(title, length, modified, filename=filename, podcast=podcast_name)
608 tracks.append(t)
609 return tracks
611 def episode_on_device(self, episode):
612 e = util.sanitize_filename(episode.sync_filename(), gl.config.mp3_player_max_filename_length)
613 return self._track_on_device(e)
615 def remove_track(self, track):
616 self.notify('status', _('Removing %s') % track.title)
617 util.delete_file(track.filename)
618 directory = os.path.dirname(track.filename)
619 if self.directory_is_empty(directory) and gl.config.fssync_channel_subfolders:
620 try:
621 os.rmdir(directory)
622 except:
623 log('Cannot remove %s', directory, sender=self)
625 def directory_is_empty(self, directory):
626 files = glob.glob(os.path.join(directory, '*'))
627 dotfiles = glob.glob(os.path.join(directory, '.*'))
628 return len(files+dotfiles) == 0
630 def find_scrobbler_log(self, mount_point):
631 """ find an audioscrobbler log file from log_filenames in the mount_point dir """
632 for dirpath, dirnames, filenames in os.walk(mount_point):
633 for log_file in self.scrobbler_log_filenames:
634 filename = os.path.join(dirpath, log_file)
635 if os.path.isfile(filename):
636 return filename
638 # No scrobbler log on that device
639 return None
641 def copy_rockbox_cover_art(self, destination, local_filename):
643 Try to copy the channel cover to "cover.bmp" in the podcast
644 folder on the MP3 player. This makes Rockbox (rockbox.org) display
645 the cover art in its interface.
647 You need the Python Imaging Library (PIL) installed to be able to
648 convert the cover file to a Bitmap file, which Rockbox needs.
650 try:
651 cover_loc = os.path.join(os.path.dirname(local_filename), 'cover')
652 cover_dst = os.path.join(destination, 'cover.bmp')
653 if os.path.isfile(cover_loc):
654 size = (gl.config.rockbox_coverart_size, gl.config.rockbox_coverart_size)
655 try:
656 cover = Image.open(cover_loc)
657 cover.thumbnail(size)
658 cover.save(cover_dst, 'BMP')
659 except IOError:
660 log('Cannot create %s (PIL?)', cover_dst, traceback=True, sender=self)
661 return True
662 else:
663 log('No cover available to set as Rockbox cover', sender=self)
664 return True
665 except:
666 log('Error getting cover using channel cover', sender=self)
667 return False
670 def load_audioscrobbler_log(self, log_file):
671 """ Retrive track title and artist info for all the entries
672 in an audioscrobbler portable player format logfile
673 http://www.audioscrobbler.net/wiki/Portable_Player_Logging """
674 try:
675 log('Opening "%s" as AudioScrobbler log.', log_file, sender=self)
676 f = open(log_file, 'r')
677 entries = f.readlines()
678 f.close()
679 except IOError, ioerror:
680 log('Error: "%s" cannot be read.', log_file, sender=self)
681 return False
683 try:
684 # regex that can be used to get all the data from a scrobbler.log entry
685 entry_re = re.compile('^(.*)\t(.*)\t(.*)\t(.*)\t(.*)\t(.*)\t(.*)$')
686 for entry in entries:
687 match_obj = re.match(entry_re, entry)
688 # L means at least 50% of the track was listened to (S means < 50%)
689 if match_obj and match_obj.group(6).strip().lower() == 'l':
690 # append [artist_name, track_name]
691 self.scrobbler_log.append([match_obj.group(1), match_obj.group(3)])
692 except:
693 log('Error while parsing "%s".', log_file, sender=self)
695 return True
697 class MTPDevice(Device):
698 def __init__(self):
699 Device.__init__(self)
700 self.__model_name = None
701 self.__MTPDevice = pymtp.MTP()
703 def __callback(self, sent, total):
704 if self.cancelled:
705 return -1
706 percentage = round(float(sent)/float(total)*100)
707 text = ('%i%%' % percentage)
708 self.notify('progress', sent, total, text)
710 def __date_to_mtp(self, date):
712 this function format the given date and time to a string representation
713 according to MTP specifications: YYYYMMDDThhmmss.s
715 return
716 the string representation od the given date
718 if not date:
719 return ""
720 try:
721 d = time.gmtime(date)
722 return time.strftime("%Y%m%d-%H%M%S.0Z", d)
723 except Exception, exc:
724 log('ERROR: An error has happend while trying to convert date to an mtp string (%s)', exc, sender=self)
725 return None
727 def __mtp_to_date(self, mtp):
729 this parse the mtp's string representation for date
730 according to specifications (YYYYMMDDThhmmss.s) to
731 a python time object
734 if not mtp:
735 return None
737 try:
738 mtp = mtp.replace(" ", "0") # replace blank with 0 to fix some invalid string
739 d = time.strptime(mtp[:8] + mtp[9:13],"%Y%m%d%H%M%S")
740 _date = calendar.timegm(d)
741 if len(mtp)==20:
742 # TIME ZONE SHIFTING: the string contains a hour/min shift relative to a time zone
743 try:
744 shift_direction=mtp[15]
745 hour_shift = int(mtp[16:18])
746 minute_shift = int(mtp[18:20])
747 shift_in_sec = hour_shift * 3600 + minute_shift * 60
748 if shift_direction == "+":
749 _date += shift_in_sec
750 elif shift_direction == "-":
751 _date -= shift_in_sec
752 else:
753 raise ValueError("Expected + or -")
754 except Exception, exc:
755 log('WARNING: ignoring invalid time zone information for %s (%s)', mtp, exc, sender=self)
756 return max( 0, _date )
757 except Exception, exc:
758 log('WARNING: the mtp date "%s" can not be parsed against mtp specification (%s)', mtp, exc, sender=self)
759 return None
761 def get_name(self):
763 this function try to find a nice name for the device.
764 First, it tries to find a friendly (user assigned) name
765 (this name can be set by other application and is stored on the device).
766 if no friendly name was assign, it tries to get the model name (given by the vendor).
767 If no name is found at all, a generic one is returned.
769 Once found, the name is cached internaly to prevent reading again the device
771 return
772 the name of the device
775 if self.__model_name:
776 return self.__model_name
778 self.__model_name = self.__MTPDevice.get_devicename() # actually libmtp.Get_Friendlyname
779 if not self.__model_name or self.__model_name == "?????":
780 self.__model_name = self.__MTPDevice.get_modelname()
781 if not self.__model_name:
782 self.__model_name = "MTP device"
784 return self.__model_name
786 def open(self):
787 Device.open(self)
788 log("opening the MTP device", sender=self)
789 self.notify('status', _('Opening the MTP device'), )
791 try:
792 self.__MTPDevice.connect()
793 # build the initial tracks_list
794 self.tracks_list = self.get_all_tracks()
795 except Exception, exc:
796 log('unable to find an MTP device (%s)', exc, sender=self, traceback=True)
797 return False
799 self.notify('status', _('%s opened') % self.get_name())
800 return True
802 def close(self):
803 log("closing %s", self.get_name(), sender=self)
804 self.notify('status', _('Closing %s') % self.get_name())
806 try:
807 self.__MTPDevice.disconnect()
808 except Exception, exc:
809 log('unable to close %s (%s)', self.get_name(), exc, sender=self)
810 return False
812 self.notify('status', _('%s closed') % self.get_name())
813 Device.close(self)
814 return True
816 def add_track(self, episode):
817 self.notify('status', _('Adding %s...') % episode.title)
818 log("sending " + str(episode.local_filename()) + " (" + episode.title + ").", sender=self)
820 try:
821 # verify free space
822 needed = util.calculate_size(episode.local_filename())
823 free = self.get_free_space()
824 if needed > free:
825 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)
826 self.cancelled = True
827 return False
829 # fill metadata
830 metadata = pymtp.LIBMTP_Track()
831 metadata.title = str(episode.title)
832 metadata.artist = str(episode.channel.title)
833 metadata.album = str(episode.channel.title)
834 metadata.genre = "podcast"
835 metadata.date = self.__date_to_mtp(episode.pubDate)
836 metadata.duration = get_track_length(str(episode.local_filename()))
838 # send the file
839 self.__MTPDevice.send_track_from_file( str(episode.local_filename()), episode.basename, metadata, 0, callback=self.__callback)
840 except:
841 log('unable to add episode %s', episode.title, sender=self, traceback=True)
842 return False
844 return True
846 def remove_track(self, sync_track):
847 self.notify('status', _('Removing %s') % sync_track.mtptrack.title)
848 log("removing %s", sync_track.mtptrack.title, sender=self)
850 try:
851 self.__MTPDevice.delete_object(sync_track.mtptrack.item_id)
852 except Exception, exc:
853 log('unable remove file %s (%s)', sync_track.mtptrack.filename, exc, sender=self)
855 log('%s removed', sync_track.mtptrack.title , sender=self)
857 def get_all_tracks(self):
858 try:
859 listing = self.__MTPDevice.get_tracklisting(callback=self.__callback)
860 except Exception, exc:
861 log('unable to get file listing %s (%s)', exc, sender=self)
863 tracks = []
864 for track in listing:
865 title = track.title
866 if not title or title=="": title=track.filename
867 if len(title) > 50: title = title[0:49] + '...'
868 artist = track.artist
869 if artist and len(artist) > 50: artist = artist[0:49] + '...'
870 length = track.filesize
871 age_in_days = 0
872 date = self.__mtp_to_date(track.date)
873 if not date:
874 modified = track.date # not a valid mtp date. Display what mtp gave anyway
875 else:
876 modified = util.format_date(date)
878 t = SyncTrack(title, length, modified, mtptrack=track, podcast=artist)
879 tracks.append(t)
880 return tracks
882 def get_free_space(self):
883 return self.__MTPDevice.get_freespace()