1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
21 # sync.py -- Device synchronization
22 # Thomas Perl <thp@perli.net> 2007-12-06
23 # based on libipodsync.py (2006-04-05 Thomas Perl)
27 from gpodder
import util
28 from gpodder
import services
29 from gpodder
import libconverter
31 from gpodder
.liblogger
import log
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'], [])
85 def open_device(config
):
86 device_type
= config
.device_type
87 if device_type
== 'ipod':
88 return iPodDevice(config
)
89 elif device_type
== 'filesystem':
90 return MP3PlayerDevice(config
)
91 elif device_type
== 'mtp':
92 return MTPDevice(config
)
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
)
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
):
155 def __init__(self
, config
):
156 self
._config
= config
157 self
.cancelled
= False
158 self
.allowed_types
= ['audio', 'video']
160 self
.tracks_list
= []
161 signals
= ['progress', 'sub-progress', 'status', 'done', 'post-done']
162 services
.ObservableService
.__init
__(self
, signals
)
168 self
.cancelled
= True
169 self
.notify('status', _('Cancelled by user'))
172 self
.notify('status', _('Writing data to disk'))
173 if self
._config
.sync_disks_after_transfer
:
174 successful_sync
= (os
.system('sync') == 0)
176 log('Not syncing disks. Unmount your device before unplugging.', sender
=self
)
177 successful_sync
= True
179 self
.notify('post-done', self
, successful_sync
)
182 def add_tracks(self
, tracklist
=[], force_played
=False):
183 for track
in list(tracklist
):
184 # Filter tracks that are not meant to be synchronized
185 does_not_exist
= not track
.was_downloaded(and_exists
=True)
186 exclude_played
= track
.is_played
and not force_played
and \
187 self
._config
.only_sync_not_played
188 wrong_type
= track
.file_type() not in self
.allowed_types
190 if does_not_exist
or exclude_played
or wrong_type
:
191 log('Excluding %s from sync', track
.title
, sender
=self
)
192 tracklist
.remove(track
)
194 compare_episodes
= lambda a
, b
: cmp(a
.pubDate
, b
.pubDate
)
195 for id, track
in enumerate(sorted(tracklist
, cmp=compare_episodes
)):
199 self
.notify('progress', id+1, len(tracklist
))
201 added
= self
.add_track(track
)
203 if self
._config
.on_sync_mark_played
:
204 log('Marking as played on transfer: %s', track
.url
, sender
=self
)
205 track
.mark(is_played
=True)
207 if added
and self
._config
.on_sync_delete
:
208 log('Removing episode after transfer: %s', track
.url
, sender
=self
)
209 track
.delete_from_disk()
212 def convert_track(self
, episode
):
213 filename
= episode
.local_filename(create
=False)
214 # The file has to exist, if we ought to transfer it, and therefore,
215 # local_filename(create=False) must never return None as filename
216 assert filename
is not None
217 (fn
, extension
) = os
.path
.splitext(filename
)
218 if libconverter
.converters
.has_converter(extension
):
219 if self
._config
.disable_pre_sync_conversion
:
220 log('Pre-sync conversion is not enabled, set disable_pre_sync_conversion to "False" to enable')
223 log('Converting: %s', filename
, sender
=self
)
224 callback_status
= lambda percentage
: self
.notify('sub-progress', int(percentage
))
225 local_filename
= libconverter
.converters
.convert(filename
, callback
=callback_status
)
227 if local_filename
is None:
228 log('Cannot convert %s', original_filename
, sender
=self
)
231 return str(local_filename
)
235 def remove_tracks(self
, tracklist
=[]):
236 for id, track
in enumerate(tracklist
):
239 self
.notify('progress', id, len(tracklist
))
240 self
.remove_track(track
)
243 def get_all_tracks(self
):
246 def add_track(self
, track
):
249 def remove_track(self
, track
):
252 def get_free_space(self
):
255 def episode_on_device(self
, episode
):
256 return self
._track
_on
_device
(episode
.title
)
258 def _track_on_device(self
, track_name
):
259 for t
in self
.tracks_list
:
261 if track_name
== title
:
265 class iPodDevice(Device
):
266 def __init__(self
, config
):
267 Device
.__init
__(self
, config
)
269 self
.mountpoint
= str(self
._config
.ipod_mount
)
272 self
.podcast_playlist
= None
275 def get_free_space(self
):
276 # Reserve 10 MiB for iTunesDB writing (to be on the safe side)
277 RESERVED_FOR_ITDB
= 1024*1024*10
278 return util
.get_free_disk_space(self
.mountpoint
) - RESERVED_FOR_ITDB
282 if not gpod_available
or not os
.path
.isdir(self
.mountpoint
):
285 self
.notify('status', _('Opening iPod database'))
286 self
.itdb
= gpod
.itdb_parse(self
.mountpoint
, None)
287 if self
.itdb
is None:
290 self
.itdb
.mountpoint
= self
.mountpoint
291 self
.podcasts_playlist
= gpod
.itdb_playlist_podcasts(self
.itdb
)
293 if self
.podcasts_playlist
:
294 self
.notify('status', _('iPod opened'))
296 # build the initial tracks_list
297 self
.tracks_list
= self
.get_all_tracks()
304 if self
.itdb
is not None:
305 self
.notify('status', _('Saving iPod database'))
306 gpod
.itdb_write(self
.itdb
, None)
312 def update_played_or_delete(self
, channel
, episodes
, delete_from_db
):
314 Check whether episodes on ipod are played and update as played
315 and delete if required.
317 for episode
in episodes
:
318 track
= self
.episode_on_device(episode
)
320 gtrack
= track
.libgpodtrack
321 if gtrack
.playcount
> 0:
322 if delete_from_db
and not gtrack
.rating
:
323 log('Deleting episode from db %s', gtrack
.title
, sender
=self
)
324 channel
.delete_episode_by_url(gtrack
.podcasturl
)
326 log('Marking episode as played %s', gtrack
.title
, sender
=self
)
327 episode
.mark(is_played
=True)
330 for track
in gpod
.sw_get_playlist_tracks(self
.podcasts_playlist
):
331 if gpod
.itdb_filename_on_ipod(track
) is None:
332 log('Episode has no file: %s', track
.title
, sender
=self
)
333 # self.remove_track_gpod(track)
334 elif track
.playcount
> 0 and not track
.rating
:
335 log('Purging episode: %s', track
.title
, sender
=self
)
336 self
.remove_track_gpod(track
)
338 def get_all_tracks(self
):
340 for track
in gpod
.sw_get_playlist_tracks(self
.podcasts_playlist
):
341 filename
= gpod
.itdb_filename_on_ipod(track
)
342 length
= util
.calculate_size(filename
)
344 timestamp
= util
.file_modification_timestamp(filename
)
345 modified
= util
.format_date(timestamp
)
346 released
= gpod
.itdb_time_mac_to_host(track
.time_released
)
347 released
= util
.format_date(released
)
349 t
= SyncTrack(track
.title
, length
, modified
, modified_sort
=timestamp
, libgpodtrack
=track
, playcount
=track
.playcount
, released
=released
, podcast
=track
.artist
)
353 def remove_track(self
, track
):
354 self
.notify('status', _('Removing %s') % track
.title
)
355 self
.remove_track_gpod(track
.libgpodtrack
)
357 def remove_track_gpod(self
, track
):
358 filename
= gpod
.itdb_filename_on_ipod(track
)
361 gpod
.itdb_playlist_remove_track(self
.podcasts_playlist
, track
)
363 log('Track %s not in playlist', track
.title
, sender
=self
)
365 gpod
.itdb_track_unlink(track
)
366 util
.delete_file(filename
)
368 def add_track(self
, episode
):
369 self
.notify('status', _('Adding %s') % episode
.title
)
370 for track
in gpod
.sw_get_playlist_tracks(self
.podcasts_playlist
):
371 if episode
.url
== track
.podcasturl
:
372 if track
.playcount
> 0:
373 episode
.mark(is_played
=True)
374 # Mark as played on iPod if played locally (and set podcast flags)
375 self
.set_podcast_flags(track
, episode
)
378 original_filename
= episode
.local_filename(create
=False)
379 # The file has to exist, if we ought to transfer it, and therefore,
380 # local_filename(create=False) must never return None as filename
381 assert original_filename
is not None
382 local_filename
= original_filename
384 if util
.calculate_size(original_filename
) > self
.get_free_space():
385 log('Not enough space on %s, sync aborted...', self
.mountpoint
, sender
= self
)
386 self
.errors
.append( _('Error copying %s: Not enough free disk space on %s') % (episode
.title
, self
.mountpoint
))
387 self
.cancelled
= True
390 local_filename
= self
.convert_track(episode
)
392 (fn
, extension
) = os
.path
.splitext(local_filename
)
393 if extension
.lower().endswith('ogg'):
394 log('Cannot copy .ogg files to iPod.', sender
=self
)
397 track
= gpod
.itdb_track_new()
399 # Add release time to track if pubDate has a valid value
400 if episode
.pubDate
> 0:
402 # libgpod>= 0.5.x uses a new timestamp format
403 track
.time_released
= gpod
.itdb_time_host_to_mac(int(episode
.pubDate
))
405 # old (pre-0.5.x) libgpod versions expect mactime, so
406 # we're going to manually build a good mactime timestamp here :)
408 # + 2082844800 for unixtime => mactime (1970 => 1904)
409 track
.time_released
= int(episode
.pubDate
+ 2082844800)
411 track
.title
= str(episode
.title
)
412 track
.album
= str(episode
.channel
.title
)
413 track
.artist
= str(episode
.channel
.title
)
414 track
.description
= str(util
.remove_html_tags(episode
.description
))
416 track
.podcasturl
= str(episode
.url
)
417 track
.podcastrss
= str(episode
.channel
.url
)
419 track
.tracklen
= get_track_length(local_filename
)
420 track
.size
= os
.path
.getsize(local_filename
)
422 if episode
.file_type() == 'audio':
423 track
.filetype
= 'mp3'
424 track
.mediatype
= 0x00000004
425 elif episode
.file_type() == 'video':
426 track
.filetype
= 'm4v'
427 track
.mediatype
= 0x00000006
429 self
.set_podcast_flags(track
, episode
)
430 self
.set_cover_art(track
, local_filename
)
432 gpod
.itdb_track_add(self
.itdb
, track
, -1)
433 gpod
.itdb_playlist_add_track(self
.podcasts_playlist
, track
, -1)
434 gpod
.itdb_cp_track_to_ipod(track
, str(local_filename
), None)
436 # If the file has been converted, delete the temporary file here
437 if local_filename
!= original_filename
:
438 util
.delete_file(local_filename
)
442 def set_podcast_flags(self
, track
, episode
):
444 # Set blue bullet for unplayed tracks on 5G iPods
445 if episode
.is_played
:
446 track
.mark_unplayed
= 0x01
447 if track
.playcount
== 0:
450 if track
.playcount
> 0 or track
.bookmark_time
> 0:
451 #track is partially played so no blue bullet
452 track
.mark_unplayed
= 0x01
455 track
.mark_unplayed
= 0x02
457 # Set several flags for to podcast values
458 track
.remember_playback_position
= 0x01
464 log('Seems like your python-gpod is out-of-date.', sender
=self
)
466 def set_cover_art(self
, track
, local_filename
):
469 if tag
.link(local_filename
):
470 if 'APIC' in tag
.frames
and len(tag
.frames
['APIC']) > 0:
471 apic
= tag
.frames
['APIC'][0]
474 if apic
.mimeType
== 'image/png':
476 cover_filename
= '%s.cover.%s' (local_filename
, extension
)
478 cover_file
= open(cover_filename
, 'w')
479 cover_file
.write(apic
.imageData
)
482 gpod
.itdb_track_set_thumbnails(track
, cover_filename
)
485 log('Error getting cover using eyeD3', sender
=self
)
488 cover_filename
= os
.path
.join(os
.path
.dirname(local_filename
), 'folder.jpg')
490 if os
.path
.isfile(cover_filename
):
491 gpod
.itdb_track_set_thumbnails(track
, cover_filename
)
494 log('Error getting cover using channel cover', sender
=self
)
499 class MP3PlayerDevice(Device
):
500 # if different players use other filenames besides
501 # .scrobbler.log, add them to this list
502 scrobbler_log_filenames
= ['.scrobbler.log']
504 def __init__(self
, config
):
505 Device
.__init
__(self
, config
)
506 self
.destination
= self
._config
.mp3_player_folder
507 self
.buffer_size
= 1024*1024 # 1 MiB
508 self
.scrobbler_log
= []
510 def get_free_space(self
):
511 return util
.get_free_disk_space(self
.destination
)
515 self
.notify('status', _('Opening MP3 player'))
516 if util
.directory_is_writable(self
.destination
):
517 self
.notify('status', _('MP3 player opened'))
518 # build the initial tracks_list
519 self
.tracks_list
= self
.get_all_tracks()
520 if self
._config
.mp3_player_use_scrobbler_log
:
521 mp3_player_mount_point
= util
.find_mount_point(self
.destination
)
522 # If a moint point cannot be found look inside self.destination for scrobbler_log_filenames
523 # this prevents us from os.walk()'ing the entire / filesystem
524 if mp3_player_mount_point
== '/':
525 mp3_player_mount_point
= self
.destination
526 log_location
= self
.find_scrobbler_log(mp3_player_mount_point
)
527 if log_location
is not None and self
.load_audioscrobbler_log(log_location
):
528 log('Using Audioscrobbler log data to mark tracks as played', sender
=self
)
533 def add_track(self
, episode
):
534 self
.notify('status', _('Adding %s') % episode
.title
.decode('utf-8', 'ignore'))
536 if self
._config
.fssync_channel_subfolders
:
537 # Add channel title as subfolder
538 folder
= episode
.channel
.title
539 # Clean up the folder name for use on limited devices
540 folder
= util
.sanitize_filename(folder
, self
._config
.mp3_player_max_filename_length
)
541 folder
= os
.path
.join(self
.destination
, folder
)
543 folder
= self
.destination
545 from_file
= util
.sanitize_encoding(self
.convert_track(episode
))
546 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
)
548 to_file
= filename_base
+ os
.path
.splitext(from_file
)[1].lower()
550 # dirty workaround: on bad (empty) episode titles,
551 # we simply use the from_file basename
552 # (please, podcast authors, FIX YOUR RSS FEEDS!)
553 if os
.path
.splitext(to_file
)[0] == '':
554 to_file
= os
.path
.basename(from_file
)
556 to_file
= os
.path
.join(folder
, to_file
)
558 if not os
.path
.exists(folder
):
562 log('Cannot create folder on MP3 player: %s', folder
, sender
=self
)
565 if self
._config
.mp3_player_use_scrobbler_log
and not episode
.is_played
:
566 # FIXME: This misses some things when channel.title<>album tag which is what
567 # the scrobbling entity will be using.
568 if [episode
.channel
.title
, episode
.title
] in self
.scrobbler_log
:
569 log('Marking "%s" from "%s" as played', episode
.title
, episode
.channel
.title
, sender
=self
)
570 episode
.mark(is_played
=True)
572 if self
._config
.rockbox_copy_coverart
and not os
.path
.exists(os
.path
.join(folder
, 'cover.bmp')):
573 log('Creating Rockbox album art for "%s"', episode
.channel
.title
, sender
=self
)
574 self
.copy_player_cover_art(folder
, from_file
, \
575 'cover.bmp', 'BMP', self
._config
.rockbox_coverart_size
)
577 if self
._config
.custom_player_copy_coverart \
578 and not os
.path
.exists(os
.path
.join(folder
, \
579 self
._config
.custom_player_coverart_name
)):
580 log('Creating custom player album art for "%s"',
581 episode
.channel
.title
, sender
=self
)
582 self
.copy_player_cover_art(folder
, from_file
, \
583 self
._config
.custom_player_coverart_name
, \
584 self
._config
.custom_player_coverart_format
, \
585 self
._config
.custom_player_coverart_size
)
587 if not os
.path
.exists(to_file
):
588 log('Copying %s => %s', os
.path
.basename(from_file
), to_file
.decode(util
.encoding
), sender
=self
)
589 return self
.copy_file_progress(from_file
, to_file
)
593 def copy_file_progress(self
, from_file
, to_file
):
595 out_file
= open(to_file
, 'wb')
596 except IOError, ioerror
:
597 self
.errors
.append(_('Error opening %s: %s') % (ioerror
.filename
, ioerror
.strerror
))
602 in_file
= open(from_file
, 'rb')
603 except IOError, ioerror
:
604 self
.errors
.append(_('Error opening %s: %s') % (ioerror
.filename
, ioerror
.strerror
))
609 bytes
= in_file
.tell()
613 s
= in_file
.read(self
.buffer_size
)
618 except IOError, ioerror
:
619 self
.errors
.append(ioerror
.strerror
)
625 log('Trying to remove partially copied file: %s' % to_file
, sender
=self
)
627 log('Yeah! Unlinked %s at least..' % to_file
, sender
=self
)
629 log('Error while trying to unlink %s. OH MY!' % to_file
, sender
=self
)
632 self
.notify('sub-progress', int(min(100, 100*float(bytes_read
)/float(bytes
))))
633 s
= in_file
.read(self
.buffer_size
)
639 def get_all_tracks(self
):
642 if self
._config
.fssync_channel_subfolders
:
643 files
= glob
.glob(os
.path
.join(self
.destination
, '*', '*'))
645 files
= glob
.glob(os
.path
.join(self
.destination
, '*'))
647 for filename
in files
:
648 (title
, extension
) = os
.path
.splitext(os
.path
.basename(filename
))
649 length
= util
.calculate_size(filename
)
651 timestamp
= util
.file_modification_timestamp(filename
)
652 modified
= util
.format_date(timestamp
)
653 if self
._config
.fssync_channel_subfolders
:
654 podcast_name
= os
.path
.basename(os
.path
.dirname(filename
))
658 t
= SyncTrack(title
, length
, modified
, modified_sort
=timestamp
, filename
=filename
, podcast
=podcast_name
)
662 def episode_on_device(self
, episode
):
663 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
)
664 return self
._track
_on
_device
(e
)
666 def remove_track(self
, track
):
667 self
.notify('status', _('Removing %s') % track
.title
)
668 util
.delete_file(track
.filename
)
669 directory
= os
.path
.dirname(track
.filename
)
670 if self
.directory_is_empty(directory
) and self
._config
.fssync_channel_subfolders
:
674 log('Cannot remove %s', directory
, sender
=self
)
676 def directory_is_empty(self
, directory
):
677 files
= glob
.glob(os
.path
.join(directory
, '*'))
678 dotfiles
= glob
.glob(os
.path
.join(directory
, '.*'))
679 return len(files
+dotfiles
) == 0
681 def find_scrobbler_log(self
, mount_point
):
682 """ find an audioscrobbler log file from log_filenames in the mount_point dir """
683 for dirpath
, dirnames
, filenames
in os
.walk(mount_point
):
684 for log_file
in self
.scrobbler_log_filenames
:
685 filename
= os
.path
.join(dirpath
, log_file
)
686 if os
.path
.isfile(filename
):
689 # No scrobbler log on that device
692 def copy_player_cover_art(self
, destination
, local_filename
, \
693 cover_dst_name
, cover_dst_format
, \
696 Try to copy the channel cover to the podcast folder on the MP3
697 player. This makes the player, e.g. Rockbox (rockbox.org), display the
698 cover art in its interface.
700 You need the Python Imaging Library (PIL) installed to be able to
701 convert the cover file to a Bitmap file, which Rockbox needs.
704 cover_loc
= os
.path
.join(os
.path
.dirname(local_filename
), 'cover')
705 if not os
.path
.exists(cover_loc
):
706 cover_loc
= os
.path
.join(os
.path
.dirname(local_filename
), '.cover')
707 cover_dst
= os
.path
.join(destination
, cover_dst_name
)
708 if os
.path
.isfile(cover_loc
):
709 log('Creating cover art file on player', sender
=self
)
710 log('Cover art size is %s', cover_dst_size
, sender
=self
)
711 size
= (cover_dst_size
, cover_dst_size
)
713 cover
= Image
.open(cover_loc
)
714 cover
.thumbnail(size
)
715 cover
.save(cover_dst
, cover_dst_format
)
717 log('Cannot create %s (PIL?)', cover_dst
, traceback
=True, sender
=self
)
720 log('No cover available to set as player cover', sender
=self
)
723 log('Error getting cover using channel cover', sender
=self
)
727 def load_audioscrobbler_log(self
, log_file
):
728 """ Retrive track title and artist info for all the entries
729 in an audioscrobbler portable player format logfile
730 http://www.audioscrobbler.net/wiki/Portable_Player_Logging """
732 log('Opening "%s" as AudioScrobbler log.', log_file
, sender
=self
)
733 f
= open(log_file
, 'r')
734 entries
= f
.readlines()
736 except IOError, ioerror
:
737 log('Error: "%s" cannot be read.', log_file
, sender
=self
)
741 # Scrobble Log Format: http://www.audioscrobbler.net/wiki/Portable_Player_Logging
742 # Notably some fields are optional so will appear as \t\t.
743 # Conforming scrobblers should strip any \t's from the actual fields.
744 for entry
in entries
:
745 entry
= entry
.split('\t')
747 artist
, album
, track
, pos
, length
, rating
= entry
[:6]
748 # L means at least 50% of the track was listened to (S means < 50%)
750 # Whatever is writing the logs will only have the taginfo in the
751 # file to work from. Mostly album~=channel name
753 self
.scrobbler_log
.append([album
, track
])
755 log('Skipping logging of %s (missing track)', album
)
757 log('Skipping scrobbler entry: %d elements %s', len(entry
), entry
)
760 log('Error while parsing "%s".', log_file
, sender
=self
)
764 class MTPDevice(Device
):
765 def __init__(self
, config
):
766 Device
.__init
__(self
, config
)
767 self
.__model
_name
= None
768 self
.__MTPDevice
= pymtp
.MTP()
770 def __callback(self
, sent
, total
):
773 percentage
= round(float(sent
)/float(total
)*100)
774 text
= ('%i%%' % percentage
)
775 self
.notify('progress', sent
, total
, text
)
777 def __date_to_mtp(self
, date
):
779 this function format the given date and time to a string representation
780 according to MTP specifications: YYYYMMDDThhmmss.s
783 the string representation od the given date
788 d
= time
.gmtime(date
)
789 return time
.strftime("%Y%m%d-%H%M%S.0Z", d
)
790 except Exception, exc
:
791 log('ERROR: An error has happend while trying to convert date to an mtp string (%s)', exc
, sender
=self
)
794 def __mtp_to_date(self
, mtp
):
796 this parse the mtp's string representation for date
797 according to specifications (YYYYMMDDThhmmss.s) to
805 mtp
= mtp
.replace(" ", "0") # replace blank with 0 to fix some invalid string
806 d
= time
.strptime(mtp
[:8] + mtp
[9:13],"%Y%m%d%H%M%S")
807 _date
= calendar
.timegm(d
)
809 # TIME ZONE SHIFTING: the string contains a hour/min shift relative to a time zone
811 shift_direction
=mtp
[15]
812 hour_shift
= int(mtp
[16:18])
813 minute_shift
= int(mtp
[18:20])
814 shift_in_sec
= hour_shift
* 3600 + minute_shift
* 60
815 if shift_direction
== "+":
816 _date
+= shift_in_sec
817 elif shift_direction
== "-":
818 _date
-= shift_in_sec
820 raise ValueError("Expected + or -")
821 except Exception, exc
:
822 log('WARNING: ignoring invalid time zone information for %s (%s)', mtp
, exc
, sender
=self
)
823 return max( 0, _date
)
824 except Exception, exc
:
825 log('WARNING: the mtp date "%s" can not be parsed against mtp specification (%s)', mtp
, exc
, sender
=self
)
830 this function try to find a nice name for the device.
831 First, it tries to find a friendly (user assigned) name
832 (this name can be set by other application and is stored on the device).
833 if no friendly name was assign, it tries to get the model name (given by the vendor).
834 If no name is found at all, a generic one is returned.
836 Once found, the name is cached internaly to prevent reading again the device
839 the name of the device
842 if self
.__model
_name
:
843 return self
.__model
_name
845 self
.__model
_name
= self
.__MTPDevice
.get_devicename() # actually libmtp.Get_Friendlyname
846 if not self
.__model
_name
or self
.__model
_name
== "?????":
847 self
.__model
_name
= self
.__MTPDevice
.get_modelname()
848 if not self
.__model
_name
:
849 self
.__model
_name
= "MTP device"
851 return self
.__model
_name
855 log("opening the MTP device", sender
=self
)
856 self
.notify('status', _('Opening the MTP device'), )
859 self
.__MTPDevice
.connect()
860 # build the initial tracks_list
861 self
.tracks_list
= self
.get_all_tracks()
862 except Exception, exc
:
863 log('unable to find an MTP device (%s)', exc
, sender
=self
, traceback
=True)
866 self
.notify('status', _('%s opened') % self
.get_name())
870 log("closing %s", self
.get_name(), sender
=self
)
871 self
.notify('status', _('Closing %s') % self
.get_name())
874 self
.__MTPDevice
.disconnect()
875 except Exception, exc
:
876 log('unable to close %s (%s)', self
.get_name(), exc
, sender
=self
)
879 self
.notify('status', _('%s closed') % self
.get_name())
883 def add_track(self
, episode
):
884 self
.notify('status', _('Adding %s...') % episode
.title
)
885 filename
= str(self
.convert_track(episode
))
886 log("sending " + filename
+ " (" + episode
.title
+ ").", sender
=self
)
890 needed
= util
.calculate_size(filename
)
891 free
= self
.get_free_space()
893 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
)
894 self
.cancelled
= True
898 metadata
= pymtp
.LIBMTP_Track()
899 metadata
.title
= str(episode
.title
)
900 metadata
.artist
= str(episode
.channel
.title
)
901 metadata
.album
= str(episode
.channel
.title
)
902 metadata
.genre
= "podcast"
903 metadata
.date
= self
.__date
_to
_mtp
(episode
.pubDate
)
904 metadata
.duration
= get_track_length(str(filename
))
907 self
.__MTPDevice
.send_track_from_file(filename
,
908 episode
.basename
+ episode
.extension(),
909 metadata
, 0, callback
=self
.__callback
)
911 log('unable to add episode %s', episode
.title
, sender
=self
, traceback
=True)
916 def remove_track(self
, sync_track
):
917 self
.notify('status', _('Removing %s') % sync_track
.mtptrack
.title
)
918 log("removing %s", sync_track
.mtptrack
.title
, sender
=self
)
921 self
.__MTPDevice
.delete_object(sync_track
.mtptrack
.item_id
)
922 except Exception, exc
:
923 log('unable remove file %s (%s)', sync_track
.mtptrack
.filename
, exc
, sender
=self
)
925 log('%s removed', sync_track
.mtptrack
.title
, sender
=self
)
927 def get_all_tracks(self
):
929 listing
= self
.__MTPDevice
.get_tracklisting(callback
=self
.__callback
)
930 except Exception, exc
:
931 log('unable to get file listing %s (%s)', exc
, sender
=self
)
934 for track
in listing
:
936 if not title
or title
=="": title
=track
.filename
937 if len(title
) > 50: title
= title
[0:49] + '...'
938 artist
= track
.artist
939 if artist
and len(artist
) > 50: artist
= artist
[0:49] + '...'
940 length
= track
.filesize
942 date
= self
.__mtp
_to
_date
(track
.date
)
944 modified
= track
.date
# not a valid mtp date. Display what mtp gave anyway
945 modified_sort
= -1 # no idea how to sort invalid date
947 modified
= util
.format_date(date
)
950 t
= SyncTrack(title
, length
, modified
, modified_sort
=modified_sort
, mtptrack
=track
, podcast
=artist
)
954 def get_free_space(self
):
955 return self
.__MTPDevice
.get_freespace()