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)
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
43 gpod_available
= False
44 log('(gpodder.sync) Could not find gpod')
46 pymtp_available
= True
50 pymtp_available
= False
51 log('(gpodder.sync) Could not find pymtp.')
56 log('(gpodder.sync) Could not find pymad')
61 log( '(gpodder.sync) Could not find eyeD3')
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'], [])
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
)
97 def get_track_length(filename
):
98 length
= gstreamer
.get_track_length(filename
)
99 if length
is not None:
102 if util
.find_command('mplayer') is not None:
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)
109 log('Please install MPlayer for track length detection.')
112 mad_info
= mad
.MadFile(filename
)
113 return int(mad_info
.total_time())
118 eyed3_info
= eyeD3
.Mp3AudioFile(filename
)
119 return int(eyed3_info
.getPlayTime()*1000)
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
):
147 self
.filesize
= util
.format_filesize(length
)
148 self
.modified
= modified
150 # Set some (possible) keyword arguments to default values
151 self
.playcount
= 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']
165 self
.tracks_list
= []
166 signals
= ['progress', 'sub-progress', 'status', 'done', 'post-done']
167 services
.ObservableService
.__init
__(self
, signals
)
173 self
.cancelled
= True
174 self
.notify('status', _('Cancelled by user'))
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)
181 log('Not syncing disks. Unmount your device before unplugging.', sender
=self
)
182 successful_sync
= True
184 self
.notify('post-done', self
, successful_sync
)
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
)):
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()
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')
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
)
236 return str(local_filename
)
240 def remove_tracks(self
, tracklist
=[]):
241 for id, track
in enumerate(tracklist
):
244 self
.notify('progress', id, len(tracklist
))
245 self
.remove_track(track
)
248 def get_all_tracks(self
):
251 def add_track(self
, track
):
254 def remove_track(self
, track
):
257 def get_free_space(self
):
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
:
266 if track_name
== title
:
270 class iPodDevice(Device
):
271 def __init__(self
, config
):
272 Device
.__init
__(self
, config
)
274 self
.mountpoint
= str(self
._config
.ipod_mount
)
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
287 if not gpod_available
or not os
.path
.isdir(self
.mountpoint
):
290 self
.notify('status', _('Opening iPod database'))
291 self
.itdb
= gpod
.itdb_parse(self
.mountpoint
, None)
292 if self
.itdb
is None:
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()
309 if self
.itdb
is not None:
310 self
.notify('status', _('Saving iPod database'))
311 gpod
.itdb_write(self
.itdb
, None)
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
)
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
)
331 log('Marking episode as played %s', gtrack
.title
, sender
=self
)
332 episode
.mark(is_played
=True)
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
):
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
)
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
)
366 gpod
.itdb_playlist_remove_track(self
.podcasts_playlist
, track
)
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
)
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
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
)
404 track
= gpod
.itdb_track_new()
406 # Add release time to track if pubDate has a valid value
407 if episode
.pubDate
> 0:
409 # libgpod>= 0.5.x uses a new timestamp format
410 track
.time_released
= gpod
.itdb_time_host_to_mac(int(episode
.pubDate
))
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
)
449 def set_podcast_flags(self
, track
, episode
):
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:
457 if track
.playcount
> 0 or track
.bookmark_time
> 0:
458 #track is partially played so no blue bullet
459 track
.mark_unplayed
= 0x01
462 track
.mark_unplayed
= 0x02
464 # Set several flags for to podcast values
465 track
.remember_playback_position
= 0x01
471 log('Seems like your python-gpod is out-of-date.', sender
=self
)
473 def set_cover_art(self
, track
, local_filename
):
476 if tag
.link(local_filename
):
477 if 'APIC' in tag
.frames
and len(tag
.frames
['APIC']) > 0:
478 apic
= tag
.frames
['APIC'][0]
481 if apic
.mimeType
== 'image/png':
483 cover_filename
= '%s.cover.%s' (local_filename
, extension
)
485 cover_file
= open(cover_filename
, 'w')
486 cover_file
.write(apic
.imageData
)
489 gpod
.itdb_track_set_thumbnails(track
, cover_filename
)
492 log('Error getting cover using eyeD3', sender
=self
)
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
)
501 log('Error getting cover using channel cover', sender
=self
)
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
)
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
)
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
)
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
):
569 log('Cannot create folder on MP3 player: %s', folder
, sender
=self
)
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
)
600 def copy_file_progress(self
, from_file
, to_file
):
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
)
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
)
618 bytes
= in_file
.tell()
622 s
= in_file
.read(self
.buffer_size
)
627 except IOError, ioerror
:
628 self
.errors
.append(ioerror
.strerror
)
634 log('Trying to remove partially copied file: %s' % to_file
, sender
=self
)
636 log('Yeah! Unlinked %s at least..' % to_file
, sender
=self
)
638 log('Error while trying to unlink %s. OH MY!' % to_file
, sender
=self
)
641 self
.notify('sub-progress', int(min(100, 100*float(bytes_read
)/float(bytes
))))
642 s
= in_file
.read(self
.buffer_size
)
648 def get_all_tracks(self
):
651 if self
._config
.fssync_channel_subfolders
:
652 files
= glob
.glob(os
.path
.join(self
.destination
, '*', '*'))
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
))
667 t
= SyncTrack(title
, length
, modified
, modified_sort
=timestamp
, filename
=filename
, podcast
=podcast_name
)
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
:
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
):
698 # No scrobbler log on that device
701 def copy_player_cover_art(self
, destination
, local_filename
, \
702 cover_dst_name
, cover_dst_format
, \
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.
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
)
722 cover
= Image
.open(cover_loc
)
723 cover
.thumbnail(size
)
724 cover
.save(cover_dst
, cover_dst_format
)
726 log('Cannot create %s (PIL?)', cover_dst
, traceback
=True, sender
=self
)
729 log('No cover available to set as player cover', sender
=self
)
732 log('Error getting cover using channel cover', sender
=self
)
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 """
741 log('Opening "%s" as AudioScrobbler log.', log_file
, sender
=self
)
742 f
= open(log_file
, 'r')
743 entries
= f
.readlines()
745 except IOError, ioerror
:
746 log('Error: "%s" cannot be read.', log_file
, sender
=self
)
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')
756 artist
, album
, track
, pos
, length
, rating
= entry
[:6]
757 # L means at least 50% of the track was listened to (S means < 50%)
759 # Whatever is writing the logs will only have the taginfo in the
760 # file to work from. Mostly album~=channel name
762 self
.scrobbler_log
.append([album
, track
])
764 log('Skipping logging of %s (missing track)', album
)
766 log('Skipping scrobbler entry: %d elements %s', len(entry
), entry
)
769 log('Error while parsing "%s".', log_file
, sender
=self
)
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
):
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
792 the string representation od the given date
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
)
803 def __mtp_to_date(self
, mtp
):
805 this parse the mtp's string representation for date
806 according to specifications (YYYYMMDDThhmmss.s) to
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
)
818 # TIME ZONE SHIFTING: the string contains a hour/min shift relative to a time zone
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
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
)
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
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
864 log("opening the MTP device", sender
=self
)
865 self
.notify('status', _('Opening the MTP device'), )
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)
875 self
.notify('status', _('%s opened') % self
.get_name())
879 log("closing %s", self
.get_name(), sender
=self
)
880 self
.notify('status', _('Closing %s') % self
.get_name())
883 self
.__MTPDevice
.disconnect()
884 except Exception, exc
:
885 log('unable to close %s (%s)', self
.get_name(), exc
, sender
=self
)
888 self
.notify('status', _('%s closed') % self
.get_name())
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
)
899 needed
= util
.calculate_size(filename
)
900 free
= self
.get_free_space()
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
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
))
916 self
.__MTPDevice
.send_track_from_file(filename
,
917 util
.sanitize_filename(metadata
.title
),
918 metadata
, 0, callback
=self
.__callback
)
920 log('unable to add episode %s', episode
.title
, sender
=self
, traceback
=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
)
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
):
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
)
943 for track
in listing
:
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
951 date
= self
.__mtp
_to
_date
(track
.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
956 modified
= util
.format_date(date
)
959 t
= SyncTrack(title
, length
, modified
, modified_sort
=modified_sort
, mtptrack
=track
, podcast
=artist
)
963 def get_free_space(self
):
964 return self
.__MTPDevice
.get_freespace()