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
42 gpod_available
= False
43 log('(gpodder.sync) Could not find gpod')
45 pymtp_available
= True
49 pymtp_available
= False
50 log('(gpodder.sync) Could not find pymtp.')
55 log('(gpodder.sync) Could not find pymad')
60 log( '(gpodder.sync) Could not find eyeD3')
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'], [])
86 device_type
= gl
.config
.device_type
87 if device_type
== 'ipod':
89 elif device_type
== 'filesystem':
90 return MP3PlayerDevice()
91 elif device_type
== 'mtp':
96 def get_track_length(filename
):
97 if util
.find_command('mplayer') is not None:
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)
104 log('Please install MPlayer for track length detection.')
107 mad_info
= mad
.MadFile(filename
)
108 return int(mad_info
.total_time())
113 eyed3_info
= eyeD3
.Mp3AudioFile(filename
)
114 return int(eyed3_info
.getPlayTime()*1000)
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
):
142 self
.filesize
= util
.format_filesize(length
, gl
.config
.use_si_units
)
143 self
.modified
= modified
145 # Set some (possible) keyword arguments to default values
146 self
.playcount
= None
150 # Convert keyword arguments to object attributes
151 self
.__dict
__.update(kwargs
)
154 class Device(services
.ObservableService
):
156 self
.cancelled
= False
157 self
.allowed_types
= ['audio', 'video']
159 self
.tracks_list
= []
160 signals
= ['progress', 'sub-progress', 'status', 'done', 'post-done']
161 services
.ObservableService
.__init
__(self
, signals
)
167 self
.cancelled
= True
168 self
.notify('status', _('Cancelled by user'))
171 self
.notify('status', _('Writing data to disk'))
172 successful_sync
= not os
.system('sync')
174 self
.notify('post-done', self
, successful_sync
)
177 def add_tracks(self
, tracklist
=[], force_played
=False):
178 for id, track
in enumerate(tracklist
):
182 self
.notify('progress', id+1, len(tracklist
))
184 if not track
.was_downloaded(and_exists
=True):
187 if track
.is_played
and gl
.config
.only_sync_not_played
and not force_played
:
190 if track
.file_type() not in self
.allowed_types
:
193 added
= self
.add_track(track
)
195 if gl
.config
.on_sync_mark_played
:
196 log('Marking as played on transfer: %s', track
.url
, sender
=self
)
197 db
.mark_episode(track
.url
, is_played
=True)
199 if added
and gl
.config
.on_sync_delete
:
200 log('Removing episode after transfer: %s', track
.url
, sender
=self
)
201 track
.delete_from_disk()
204 def remove_tracks(self
, tracklist
=[]):
205 for id, track
in enumerate(tracklist
):
208 self
.notify('progress', id, len(tracklist
))
209 self
.remove_track(track
)
212 def get_all_tracks(self
):
215 def add_track(self
, track
):
218 def remove_track(self
, track
):
221 def get_free_space(self
):
224 def episode_on_device(self
, episode
):
225 return self
._track
_on
_device
(episode
.title
)
227 def _track_on_device( self
, track_name
):
228 for t
in self
.tracks_list
:
229 if track_name
== t
.title
:
233 class iPodDevice(Device
):
235 Device
.__init
__(self
)
237 self
.mountpoint
= str(gl
.config
.ipod_mount
)
240 self
.podcast_playlist
= None
243 def get_free_space(self
):
244 # Reserve 10 MiB for iTunesDB writing (to be on the safe side)
245 RESERVED_FOR_ITDB
= 1024*1024*10
246 return util
.get_free_disk_space(self
.mountpoint
) - RESERVED_FOR_ITDB
250 if not gpod_available
or not os
.path
.isdir(self
.mountpoint
):
253 self
.notify('status', _('Opening iPod database'))
254 self
.itdb
= gpod
.itdb_parse(self
.mountpoint
, None)
255 if self
.itdb
is None:
258 self
.itdb
.mountpoint
= self
.mountpoint
259 self
.podcasts_playlist
= gpod
.itdb_playlist_podcasts(self
.itdb
)
261 if self
.podcasts_playlist
:
262 self
.notify('status', _('iPod opened'))
264 # build the initial tracks_list
265 self
.tracks_list
= self
.get_all_tracks()
272 if self
.itdb
is not None:
273 self
.notify('status', _('Saving iPod database'))
274 gpod
.itdb_write(self
.itdb
, None)
277 if gl
.config
.ipod_write_gtkpod_extended
:
278 # Fix up iTunesDB.ext (gtkpod extended database),
279 # so gtkpod will not complain about a wrong sha1sum
280 self
.notify('status', _('Writing extended gtkpod database'))
281 ext_filename
= os
.path
.join(self
.mountpoint
, 'iPod_Control', 'iTunes', 'iTunesDB.ext')
282 idb_filename
= os
.path
.join(self
.mountpoint
, 'iPod_Control', 'iTunes', 'iTunesDB')
283 if os
.path
.exists(ext_filename
) and os
.path
.exists(idb_filename
):
285 db
= gpod
.ipod
.Database(self
.mountpoint
)
286 gpod
.gtkpod
.parse(ext_filename
, db
, idb_filename
)
287 gpod
.gtkpod
.write(ext_filename
, db
, idb_filename
)
290 log('Error when writing iTunesDB.ext', sender
=self
, traceback
=True)
292 log('I could not find %s or %s. Will not update extended gtkpod DB.', ext_filename
, idb_filename
, sender
=self
)
294 log('Not writing extended gtkpod DB. Set "ipod_write_gpod_extended" to True if I should write it.', sender
=self
)
300 for track
in gpod
.sw_get_playlist_tracks(self
.podcasts_playlist
):
301 if gpod
.itdb_filename_on_ipod(track
) is None:
302 log('Episode has no file: %s', track
.title
, sender
=self
)
303 # self.remove_track_gpod(track)
304 elif track
.mark_unplayed
== 1 and not track
.rating
:
305 log('Purging episode: %s', track
.title
, sender
=self
)
306 self
.remove_track_gpod(track
)
308 def get_all_tracks(self
):
310 for track
in gpod
.sw_get_playlist_tracks(self
.podcasts_playlist
):
311 filename
= gpod
.itdb_filename_on_ipod(track
)
312 length
= util
.calculate_size(filename
)
314 age_in_days
= util
.file_age_in_days(filename
)
315 modified
= util
.file_age_to_string(age_in_days
)
316 released
= gpod
.itdb_time_mac_to_host(track
.time_released
)
317 released
= util
.format_date(released
)
319 t
= SyncTrack(track
.title
, length
, modified
, libgpodtrack
=track
, playcount
=track
.playcount
, released
=released
, podcast
=track
.artist
)
323 def remove_track(self
, track
):
324 self
.notify('status', _('Removing %s') % track
.title
)
325 self
.remove_track_gpod(track
.libgpodtrack
)
327 def remove_track_gpod(self
, track
):
328 filename
= gpod
.itdb_filename_on_ipod(track
)
331 gpod
.itdb_playlist_remove_track(self
.podcasts_playlist
, track
)
333 log('Track %s not in playlist', track
.title
, sender
=self
)
335 gpod
.itdb_track_unlink(track
)
336 util
.delete_file(filename
)
338 def add_track(self
, episode
):
339 self
.notify('status', _('Adding %s') % episode
.title
)
340 for track
in gpod
.sw_get_playlist_tracks(self
.podcasts_playlist
):
341 if episode
.url
== track
.podcasturl
:
342 if track
.playcount
> 0:
343 db
.mark_episode(track
.podcasturl
, is_played
=True)
344 # Mark as played on iPod if played locally (and set podcast flags)
345 self
.set_podcast_flags(track
)
348 original_filename
= str(episode
.local_filename())
349 local_filename
= original_filename
351 if util
.calculate_size(original_filename
) > self
.get_free_space():
352 log('Not enough space on %s, sync aborted...', self
.mountpoint
, sender
= self
)
353 self
.errors
.append( _('Error copying %s: Not enough free disk space on %s') % (episode
.title
, self
.mountpoint
))
354 self
.cancelled
= True
357 (fn
, extension
) = os
.path
.splitext(original_filename
)
358 if libconverter
.converters
.has_converter(extension
):
359 log('Converting: %s', original_filename
, sender
=self
)
360 callback_status
= lambda percentage
: self
.notify('sub-progress', int(percentage
))
361 local_filename
= libconverter
.converters
.convert(original_filename
, callback
=callback_status
)
363 if not libtagupdate
.update_metadata_on_file(local_filename
, title
=episode
.title
, artist
=episode
.channel
.title
):
364 log('Could not set metadata on converted file %s', local_filename
, sender
=self
)
366 if local_filename
is None:
367 log('Cannot convert %s', original_filename
, sender
=self
)
370 local_filename
= str(local_filename
)
372 (fn
, extension
) = os
.path
.splitext(local_filename
)
373 if extension
.lower().endswith('ogg'):
374 log('Cannot copy .ogg files to iPod.', sender
=self
)
377 track
= gpod
.itdb_track_new()
379 # Add release time to track if pubDate has a valid value
380 if episode
.pubDate
> 0:
382 # libgpod>= 0.5.x uses a new timestamp format
383 track
.time_released
= gpod
.itdb_time_host_to_mac(int(episode
.pubDate
))
385 # old (pre-0.5.x) libgpod versions expect mactime, so
386 # we're going to manually build a good mactime timestamp here :)
388 # + 2082844800 for unixtime => mactime (1970 => 1904)
389 track
.time_released
= int(episode
.pubDate
+ 2082844800)
391 track
.title
= str(episode
.title
)
392 track
.album
= str(episode
.channel
.title
)
393 track
.artist
= str(episode
.channel
.title
)
394 track
.description
= str(util
.remove_html_tags(episode
.description
))
396 track
.podcasturl
= str(episode
.url
)
397 track
.podcastrss
= str(episode
.channel
.url
)
399 track
.tracklen
= get_track_length(local_filename
)
400 track
.size
= os
.path
.getsize(local_filename
)
402 if episode
.file_type() == 'audio':
403 track
.filetype
= 'mp3'
404 track
.mediatype
= 0x00000004
405 elif episode
.file_type() == 'video':
406 track
.filetype
= 'm4v'
407 track
.mediatype
= 0x00000006
409 self
.set_podcast_flags(track
)
410 self
.set_cover_art(track
, local_filename
)
412 gpod
.itdb_track_add(self
.itdb
, track
, -1)
413 gpod
.itdb_playlist_add_track(self
.podcasts_playlist
, track
, -1)
414 gpod
.itdb_cp_track_to_ipod( track
, local_filename
, None)
416 # If the file has been converted, delete the temporary file here
417 if local_filename
!= original_filename
:
418 util
.delete_file(local_filename
)
422 def set_podcast_flags(self
, track
):
424 # Set blue bullet for unplayed tracks on 5G iPods
425 episode
= db
.load_episode(track
.podcasturl
)
426 if episode
['is_played']:
427 track
.mark_unplayed
= 0x01
428 if track
.playcount
== 0:
431 track
.mark_unplayed
= 0x02
433 # Set several flags for to podcast values
434 track
.remember_playback_position
= 0x01
440 log('Seems like your python-gpod is out-of-date.', sender
=self
)
442 def set_cover_art(self
, track
, local_filename
):
445 if tag
.link(local_filename
):
446 if 'APIC' in tag
.frames
and len(tag
.frames
['APIC']) > 0:
447 apic
= tag
.frames
['APIC'][0]
450 if apic
.mimeType
== 'image/png':
452 cover_filename
= '%s.cover.%s' (local_filename
, extension
)
454 cover_file
= open(cover_filename
, 'w')
455 cover_file
.write(apic
.imageData
)
458 gpod
.itdb_track_set_thumbnails(track
, cover_filename
)
461 log('Error getting cover using eyeD3', sender
=self
)
464 cover_filename
= os
.path
.join(os
.path
.dirname(local_filename
), 'cover')
465 if os
.path
.isfile(cover_filename
):
466 gpod
.itdb_track_set_thumbnails(track
, cover_filename
)
469 log('Error getting cover using channel cover', sender
=self
)
474 class MP3PlayerDevice(Device
):
475 # if different players use other filenames besides
476 # .scrobbler.log, add them to this list
477 scrobbler_log_filenames
= ['.scrobbler.log']
479 # This is the maximum length of a file name that is
480 # created on the MP3 player, because FAT32 has a
481 # 255-character limit for the whole path
482 MAX_FILENAME_LENGTH
= gl
.config
.mp3_player_max_filename_length
485 Device
.__init
__(self
)
486 self
.destination
= gl
.config
.mp3_player_folder
487 self
.buffer_size
= 1024*1024 # 1 MiB
488 self
.scrobbler_log
= []
490 def get_free_space(self
):
491 return util
.get_free_disk_space(self
.destination
)
495 self
.notify('status', _('Opening MP3 player'))
496 if util
.directory_is_writable(self
.destination
):
497 self
.notify('status', _('MP3 player opened'))
498 # build the initial tracks_list
499 self
.tracks_list
= self
.get_all_tracks()
500 if gl
.config
.mp3_player_use_scrobbler_log
:
501 mp3_player_mount_point
= util
.find_mount_point(self
.destination
)
502 # If a moint point cannot be found look inside self.destination for scrobbler_log_filenames
503 # this prevents us from os.walk()'ing the entire / filesystem
504 if mp3_player_mount_point
== '/':
505 mp3_player_mount_point
= self
.destination
506 log_location
= self
.find_scrobbler_log(mp3_player_mount_point
)
507 if log_location
is not None and self
.load_audioscrobbler_log(log_location
):
508 log('Using Audioscrobbler log data to mark tracks as played', sender
=self
)
513 def add_track(self
, episode
):
514 self
.notify('status', _('Adding %s') % episode
.title
)
516 if gl
.config
.fssync_channel_subfolders
:
517 # Add channel title as subfolder
518 folder
= episode
.channel
.title
519 # Clean up the folder name for use on limited devices
520 folder
= util
.sanitize_filename(folder
, self
.MAX_FILENAME_LENGTH
)
521 folder
= os
.path
.join(self
.destination
, folder
)
523 folder
= self
.destination
525 from_file
= util
.sanitize_encoding(episode
.local_filename())
526 filename_base
= util
.sanitize_filename(episode
.sync_filename(), self
.MAX_FILENAME_LENGTH
)
528 to_file
= filename_base
+ os
.path
.splitext(from_file
)[1].lower()
530 # dirty workaround: on bad (empty) episode titles,
531 # we simply use the from_file basename
532 # (please, podcast authors, FIX YOUR RSS FEEDS!)
533 if os
.path
.splitext(to_file
)[0] == '':
534 to_file
= os
.path
.basename(from_file
)
536 to_file
= os
.path
.join(folder
, to_file
)
538 if not os
.path
.exists(folder
):
542 log('Cannot create folder on MP3 player: %s', folder
, sender
=self
)
545 if (gl
.config
.mp3_player_use_scrobbler_log
and not episode
.is_played
546 and [episode
.channel
.title
, episode
.title
] in self
.scrobbler_log
):
547 log('Marking "%s" from "%s" as played', episode
.title
, episode
.channel
.title
, sender
=self
)
548 db
.mark_episode(episode
.url
, is_played
=True)
550 if gl
.config
.rockbox_copy_coverart
and not os
.path
.exists(os
.path
.join(folder
, 'cover.bmp')):
551 log('Creating Rockbox album art for "%s"', episode
.channel
.title
, sender
=self
)
552 self
.copy_player_cover_art(folder
, from_file
, \
553 'cover.bmp', 'BMP', gl
.config
.rockbox_coverart_size
)
555 if gl
.config
.custom_player_copy_coverart \
556 and not os
.path
.exists(os
.path
.join(folder
, \
557 gl
.config
.custom_player_coverart_name
)):
558 log('Creating custom player album art for "%s"',
559 episode
.channel
.title
, sender
=self
)
560 self
.copy_player_cover_art(folder
, from_file
, \
561 gl
.config
.custom_player_coverart_name
, \
562 gl
.config
.custom_player_coverart_format
, \
563 gl
.config
.custom_player_coverart_size
)
565 if not os
.path
.exists(to_file
):
566 log('Copying %s => %s', os
.path
.basename(from_file
), to_file
.decode(util
.encoding
), sender
=self
)
567 return self
.copy_file_progress(from_file
, to_file
)
571 def copy_file_progress(self
, from_file
, to_file
):
573 out_file
= open(to_file
, 'wb')
574 except IOError, ioerror
:
575 self
.errors
.append(_('Error opening %s: %s') % (ioerror
.filename
, ioerror
.strerror
))
580 in_file
= open(from_file
, 'rb')
581 except IOError, ioerror
:
582 self
.errors
.append(_('Error opening %s: %s') % (ioerror
.filename
, ioerror
.strerror
))
587 bytes
= in_file
.tell()
591 s
= in_file
.read(self
.buffer_size
)
596 except IOError, ioerror
:
597 self
.errors
.append(ioerror
.strerror
)
603 log('Trying to remove partially copied file: %s' % to_file
, sender
=self
)
605 log('Yeah! Unlinked %s at least..' % to_file
, sender
=self
)
607 log('Error while trying to unlink %s. OH MY!' % to_file
, sender
=self
)
610 self
.notify('sub-progress', int(min(100, 100*float(bytes_read
)/float(bytes
))))
611 s
= in_file
.read(self
.buffer_size
)
617 def get_all_tracks(self
):
620 if gl
.config
.fssync_channel_subfolders
:
621 files
= glob
.glob(os
.path
.join(self
.destination
, '*', '*'))
623 files
= glob
.glob(os
.path
.join(self
.destination
, '*'))
625 for filename
in files
:
626 (title
, extension
) = os
.path
.splitext(os
.path
.basename(filename
))
627 length
= util
.calculate_size(filename
)
629 age_in_days
= util
.file_age_in_days(filename
)
630 modified
= util
.file_age_to_string(age_in_days
)
631 if gl
.config
.fssync_channel_subfolders
:
632 podcast_name
= os
.path
.basename(os
.path
.dirname(filename
))
636 t
= SyncTrack(title
, length
, modified
, filename
=filename
, podcast
=podcast_name
)
640 def episode_on_device(self
, episode
):
641 e
= util
.sanitize_filename(episode
.sync_filename(), gl
.config
.mp3_player_max_filename_length
)
642 return self
._track
_on
_device
(e
)
644 def remove_track(self
, track
):
645 self
.notify('status', _('Removing %s') % track
.title
)
646 util
.delete_file(track
.filename
)
647 directory
= os
.path
.dirname(track
.filename
)
648 if self
.directory_is_empty(directory
) and gl
.config
.fssync_channel_subfolders
:
652 log('Cannot remove %s', directory
, sender
=self
)
654 def directory_is_empty(self
, directory
):
655 files
= glob
.glob(os
.path
.join(directory
, '*'))
656 dotfiles
= glob
.glob(os
.path
.join(directory
, '.*'))
657 return len(files
+dotfiles
) == 0
659 def find_scrobbler_log(self
, mount_point
):
660 """ find an audioscrobbler log file from log_filenames in the mount_point dir """
661 for dirpath
, dirnames
, filenames
in os
.walk(mount_point
):
662 for log_file
in self
.scrobbler_log_filenames
:
663 filename
= os
.path
.join(dirpath
, log_file
)
664 if os
.path
.isfile(filename
):
667 # No scrobbler log on that device
670 def copy_player_cover_art(self
, destination
, local_filename
, \
671 cover_dst_name
, cover_dst_format
, \
674 Try to copy the channel cover to the podcast folder on the MP3
675 player. This makes the player, e.g. Rockbox (rockbox.org), display the
676 cover art in its interface.
678 You need the Python Imaging Library (PIL) installed to be able to
679 convert the cover file to a Bitmap file, which Rockbox needs.
682 cover_loc
= os
.path
.join(os
.path
.dirname(local_filename
), 'cover')
683 cover_dst
= os
.path
.join(destination
, cover_dst_name
)
684 if os
.path
.isfile(cover_loc
):
685 log('Creating cover art file on player', sender
=self
)
686 log('Cover art size is %s', cover_dst_size
, sender
=self
)
687 size
= (cover_dst_size
, cover_dst_size
)
689 cover
= Image
.open(cover_loc
)
690 cover
.thumbnail(size
)
691 cover
.save(cover_dst
, cover_dst_format
)
693 log('Cannot create %s (PIL?)', cover_dst
, traceback
=True, sender
=self
)
696 log('No cover available to set as player cover', sender
=self
)
699 log('Error getting cover using channel cover', sender
=self
)
703 def load_audioscrobbler_log(self
, log_file
):
704 """ Retrive track title and artist info for all the entries
705 in an audioscrobbler portable player format logfile
706 http://www.audioscrobbler.net/wiki/Portable_Player_Logging """
708 log('Opening "%s" as AudioScrobbler log.', log_file
, sender
=self
)
709 f
= open(log_file
, 'r')
710 entries
= f
.readlines()
712 except IOError, ioerror
:
713 log('Error: "%s" cannot be read.', log_file
, sender
=self
)
717 # regex that can be used to get all the data from a scrobbler.log entry
718 entry_re
= re
.compile('^(.*)\t(.*)\t(.*)\t(.*)\t(.*)\t(.*)\t(.*)$')
719 for entry
in entries
:
720 match_obj
= re
.match(entry_re
, entry
)
721 # L means at least 50% of the track was listened to (S means < 50%)
722 if match_obj
and match_obj
.group(6).strip().lower() == 'l':
723 # append [artist_name, track_name]
724 self
.scrobbler_log
.append([match_obj
.group(1), match_obj
.group(3)])
726 log('Error while parsing "%s".', log_file
, sender
=self
)
730 class MTPDevice(Device
):
732 Device
.__init
__(self
)
733 self
.__model
_name
= None
734 self
.__MTPDevice
= pymtp
.MTP()
736 def __callback(self
, sent
, total
):
739 percentage
= round(float(sent
)/float(total
)*100)
740 text
= ('%i%%' % percentage
)
741 self
.notify('progress', sent
, total
, text
)
743 def __date_to_mtp(self
, date
):
745 this function format the given date and time to a string representation
746 according to MTP specifications: YYYYMMDDThhmmss.s
749 the string representation od the given date
754 d
= time
.gmtime(date
)
755 return time
.strftime("%Y%m%d-%H%M%S.0Z", d
)
756 except Exception, exc
:
757 log('ERROR: An error has happend while trying to convert date to an mtp string (%s)', exc
, sender
=self
)
760 def __mtp_to_date(self
, mtp
):
762 this parse the mtp's string representation for date
763 according to specifications (YYYYMMDDThhmmss.s) to
771 mtp
= mtp
.replace(" ", "0") # replace blank with 0 to fix some invalid string
772 d
= time
.strptime(mtp
[:8] + mtp
[9:13],"%Y%m%d%H%M%S")
773 _date
= calendar
.timegm(d
)
775 # TIME ZONE SHIFTING: the string contains a hour/min shift relative to a time zone
777 shift_direction
=mtp
[15]
778 hour_shift
= int(mtp
[16:18])
779 minute_shift
= int(mtp
[18:20])
780 shift_in_sec
= hour_shift
* 3600 + minute_shift
* 60
781 if shift_direction
== "+":
782 _date
+= shift_in_sec
783 elif shift_direction
== "-":
784 _date
-= shift_in_sec
786 raise ValueError("Expected + or -")
787 except Exception, exc
:
788 log('WARNING: ignoring invalid time zone information for %s (%s)', mtp
, exc
, sender
=self
)
789 return max( 0, _date
)
790 except Exception, exc
:
791 log('WARNING: the mtp date "%s" can not be parsed against mtp specification (%s)', mtp
, exc
, sender
=self
)
796 this function try to find a nice name for the device.
797 First, it tries to find a friendly (user assigned) name
798 (this name can be set by other application and is stored on the device).
799 if no friendly name was assign, it tries to get the model name (given by the vendor).
800 If no name is found at all, a generic one is returned.
802 Once found, the name is cached internaly to prevent reading again the device
805 the name of the device
808 if self
.__model
_name
:
809 return self
.__model
_name
811 self
.__model
_name
= self
.__MTPDevice
.get_devicename() # actually libmtp.Get_Friendlyname
812 if not self
.__model
_name
or self
.__model
_name
== "?????":
813 self
.__model
_name
= self
.__MTPDevice
.get_modelname()
814 if not self
.__model
_name
:
815 self
.__model
_name
= "MTP device"
817 return self
.__model
_name
821 log("opening the MTP device", sender
=self
)
822 self
.notify('status', _('Opening the MTP device'), )
825 self
.__MTPDevice
.connect()
826 # build the initial tracks_list
827 self
.tracks_list
= self
.get_all_tracks()
828 except Exception, exc
:
829 log('unable to find an MTP device (%s)', exc
, sender
=self
, traceback
=True)
832 self
.notify('status', _('%s opened') % self
.get_name())
836 log("closing %s", self
.get_name(), sender
=self
)
837 self
.notify('status', _('Closing %s') % self
.get_name())
840 self
.__MTPDevice
.disconnect()
841 except Exception, exc
:
842 log('unable to close %s (%s)', self
.get_name(), exc
, sender
=self
)
845 self
.notify('status', _('%s closed') % self
.get_name())
849 def add_track(self
, episode
):
850 self
.notify('status', _('Adding %s...') % episode
.title
)
851 log("sending " + str(episode
.local_filename()) + " (" + episode
.title
+ ").", sender
=self
)
855 needed
= util
.calculate_size(episode
.local_filename())
856 free
= self
.get_free_space()
858 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
)
859 self
.cancelled
= True
863 metadata
= pymtp
.LIBMTP_Track()
864 metadata
.title
= str(episode
.title
)
865 metadata
.artist
= str(episode
.channel
.title
)
866 metadata
.album
= str(episode
.channel
.title
)
867 metadata
.genre
= "podcast"
868 metadata
.date
= self
.__date
_to
_mtp
(episode
.pubDate
)
869 metadata
.duration
= get_track_length(str(episode
.local_filename()))
872 self
.__MTPDevice
.send_track_from_file( str(episode
.local_filename()), episode
.basename
, metadata
, 0, callback
=self
.__callback
)
874 log('unable to add episode %s', episode
.title
, sender
=self
, traceback
=True)
879 def remove_track(self
, sync_track
):
880 self
.notify('status', _('Removing %s') % sync_track
.mtptrack
.title
)
881 log("removing %s", sync_track
.mtptrack
.title
, sender
=self
)
884 self
.__MTPDevice
.delete_object(sync_track
.mtptrack
.item_id
)
885 except Exception, exc
:
886 log('unable remove file %s (%s)', sync_track
.mtptrack
.filename
, exc
, sender
=self
)
888 log('%s removed', sync_track
.mtptrack
.title
, sender
=self
)
890 def get_all_tracks(self
):
892 listing
= self
.__MTPDevice
.get_tracklisting(callback
=self
.__callback
)
893 except Exception, exc
:
894 log('unable to get file listing %s (%s)', exc
, sender
=self
)
897 for track
in listing
:
899 if not title
or title
=="": title
=track
.filename
900 if len(title
) > 50: title
= title
[0:49] + '...'
901 artist
= track
.artist
902 if artist
and len(artist
) > 50: artist
= artist
[0:49] + '...'
903 length
= track
.filesize
905 date
= self
.__mtp
_to
_date
(track
.date
)
907 modified
= track
.date
# not a valid mtp date. Display what mtp gave anyway
909 modified
= util
.format_date(date
)
911 t
= SyncTrack(title
, length
, modified
, mtptrack
=track
, podcast
=artist
)
915 def get_free_space(self
):
916 return self
.__MTPDevice
.get_freespace()