1
# -*- coding: utf-8 -*-
3
# gPodder - A media aggregator and podcast client
4
# Copyright (c) 2005-2011 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
47
import gpodder.gpopymtp as pymtp
49
pymtp_available = False
50
log('(gpodder.sync) Could not load gpopymtp (libmtp not installed?).')
55
log( '(gpodder.sync) Could not find eyeD3')
60
log('(gpodder.sync) Could not find Python Imaging Library (PIL)')
62
# Register our dependencies for the synchronization module
63
services.dependency_manager.depend_on(_('iPod synchronization'), _('Support synchronization of podcasts to Apple iPod devices via libgpod.'), ['gpod', 'gst'], [])
64
services.dependency_manager.depend_on(_('iPod OGG converter'), _('Convert OGG podcasts to MP3 files on synchronization to iPods using oggdec and LAME.'), [], ['oggdec', 'lame'])
65
services.dependency_manager.depend_on(_('iPod video podcasts'), _('Detect video lengths via MPlayer, to synchronize video podcasts to iPods.'), [], ['mplayer'])
66
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'], [])
78
pymtp.MTP.__init__(self)
82
pymtp.MTP.connect(self)
83
self.folders = self.unfold(self.mtp.LIBMTP_Get_Folder_List(self.device))
85
def get_folder_list(self):
88
def unfold(self, folder, path=''):
91
folder = folder.contents
92
name = self.sep.join([path, folder.name]).lstrip(self.sep)
93
result[name] = folder.folder_id
95
result.update(self.unfold(folder.child, name))
96
folder = folder.sibling
99
def mkdir(self, path):
102
parts = path.split(self.sep)
104
prefix.append(parts[0])
105
tmpath = self.sep.join(prefix)
106
if self.folders.has_key(tmpath):
107
folder_id = self.folders[tmpath]
109
folder_id = self.create_folder(parts[0], parent=folder_id)
110
# log('Creating subfolder %s in %s (id=%u)' % (parts[0], self.sep.join(prefix), folder_id))
111
tmpath = self.sep.join(prefix + [parts[0]])
112
self.folders[tmpath] = folder_id
113
# log(">>> %s = %s" % (tmpath, folder_id))
115
# log('MTP.mkdir: %s = %u' % (path, folder_id))
118
def open_device(config):
119
device_type = config.device_type
120
if device_type == 'ipod':
121
return iPodDevice(config)
122
elif device_type == 'filesystem':
123
return MP3PlayerDevice(config)
124
elif device_type == 'mtp':
125
return MTPDevice(config)
129
def get_track_length(filename):
130
if util.find_command('mplayer') is not None:
132
mplayer_output = os.popen('mplayer -msglevel all=-1 -identify -vo null -ao null -frames 0 "%s" 2>/dev/null' % filename).read()
133
return int(float(mplayer_output[mplayer_output.index('ID_LENGTH'):].splitlines()[0][10:])*1000)
137
log('Please install MPlayer for track length detection.')
140
eyed3_info = eyeD3.Mp3AudioFile(filename)
141
return int(eyed3_info.getPlayTime()*1000)
145
return int(60*60*1000*3) # Default is three hours (to be on the safe side)
147
class SyncTrack(object):
149
This represents a track that is on a device. You need
150
to specify at least the following keyword arguments,
151
because these will be used to display the track in the
152
GUI. All other keyword arguments are optional and can
153
be used to reference internal objects, etc... See the
154
iPod synchronization code for examples.
156
Keyword arguments needed:
157
playcount (How often has the track been played?)
158
podcast (Which podcast is this track from? Or: Folder name)
159
released (The release date of the episode)
161
If any of these fields is unknown, it should not be
162
passed to the function (the values will default to None
163
for all required fields).
165
def __init__(self, title, length, modified, **kwargs):
168
self.filesize = util.format_filesize(length)
169
self.modified = modified
171
# Set some (possible) keyword arguments to default values
172
self.playcount = None
176
# Convert keyword arguments to object attributes
177
self.__dict__.update(kwargs)
180
class Device(services.ObservableService):
181
def __init__(self, config):
182
self._config = config
183
self.cancelled = False
184
self.allowed_types = ['audio', 'video']
186
self.tracks_list = []
187
signals = ['progress', 'sub-progress', 'status', 'done', 'post-done']
188
services.ObservableService.__init__(self, signals)
194
self.cancelled = True
195
self.notify('status', _('Cancelled by user'))
198
self.notify('status', _('Writing data to disk'))
199
if self._config.sync_disks_after_transfer and not gpodder.win32:
200
successful_sync = (os.system('sync') == 0)
202
log('Not syncing disks. Unmount your device before unplugging.', sender=self)
203
successful_sync = True
205
self.notify('post-done', self, successful_sync)
208
def add_tracks(self, tracklist=[], force_played=False):
209
for track in list(tracklist):
210
# Filter tracks that are not meant to be synchronized
211
does_not_exist = not track.was_downloaded(and_exists=True)
212
exclude_played = track.is_played and not force_played and \
213
self._config.only_sync_not_played
214
wrong_type = track.file_type() not in self.allowed_types
216
if does_not_exist or exclude_played or wrong_type:
217
log('Excluding %s from sync', track.title, sender=self)
218
tracklist.remove(track)
220
for id, track in enumerate(sorted(tracklist, key=lambda e: e.pubDate)):
224
self.notify('progress', id+1, len(tracklist))
226
added = self.add_track(track)
228
if self._config.on_sync_mark_played:
229
log('Marking as played on transfer: %s', track.url, sender=self)
230
track.mark(is_played=True)
232
if added and self._config.on_sync_delete and not track.is_locked:
233
log('Removing episode after transfer: %s', track.url, sender=self)
234
track.delete_from_disk()
237
def convert_track(self, episode):
238
filename = episode.local_filename(create=False)
239
# The file has to exist, if we ought to transfer it, and therefore,
240
# local_filename(create=False) must never return None as filename
241
assert filename is not None
242
(fn, extension) = os.path.splitext(filename)
243
if libconverter.converters.has_converter(extension):
244
if self._config.disable_pre_sync_conversion:
245
log('Pre-sync conversion is not enabled, set disable_pre_sync_conversion to "False" to enable')
248
log('Converting: %s', filename, sender=self)
249
callback_status = lambda percentage: self.notify('sub-progress', int(percentage))
250
local_filename = libconverter.converters.convert(filename, callback=callback_status)
252
if local_filename is None:
253
log('Cannot convert %s', filename, sender=self)
256
return str(local_filename)
260
def remove_tracks(self, tracklist=[]):
261
for id, track in enumerate(tracklist):
264
self.notify('progress', id, len(tracklist))
265
self.remove_track(track)
268
def get_all_tracks(self):
271
def add_track(self, track):
274
def remove_track(self, track):
277
def get_free_space(self):
280
def episode_on_device(self, episode):
281
return self._track_on_device(episode.title)
283
def _track_on_device(self, track_name):
284
for t in self.tracks_list:
286
if track_name == title:
290
class iPodDevice(Device):
291
def __init__(self, config):
292
Device.__init__(self, config)
294
self.mountpoint = str(self._config.ipod_mount)
297
self.podcast_playlist = None
300
def get_free_space(self):
301
# Reserve 10 MiB for iTunesDB writing (to be on the safe side)
302
RESERVED_FOR_ITDB = 1024*1024*10
303
return util.get_free_disk_space(self.mountpoint) - RESERVED_FOR_ITDB
307
if not gpod_available or not os.path.isdir(self.mountpoint):
310
self.notify('status', _('Opening iPod database'))
311
self.itdb = gpod.itdb_parse(self.mountpoint, None)
312
if self.itdb is None:
315
self.itdb.mountpoint = self.mountpoint
316
self.podcasts_playlist = gpod.itdb_playlist_podcasts(self.itdb)
317
self.master_playlist = gpod.itdb_playlist_mpl(self.itdb)
319
if self.podcasts_playlist:
320
self.notify('status', _('iPod opened'))
322
# build the initial tracks_list
323
self.tracks_list = self.get_all_tracks()
330
if self.itdb is not None:
331
self.notify('status', _('Saving iPod database'))
332
gpod.itdb_write(self.itdb, None)
335
if self._config.ipod_write_gtkpod_extended:
336
self.notify('status', _('Writing extended gtkpod database'))
337
ext_filename = os.path.join(self.mountpoint, 'iPod_Control', 'iTunes', 'iTunesDB.ext')
338
idb_filename = os.path.join(self.mountpoint, 'iPod_Control', 'iTunes', 'iTunesDB')
339
if os.path.exists(ext_filename) and os.path.exists(idb_filename):
341
db = gpod.ipod.Database(self.mountpoint)
342
gpod.gtkpod.parse(ext_filename, db, idb_filename)
343
gpod.gtkpod.write(ext_filename, db, idb_filename)
346
log('Error when writing iTunesDB.ext', sender=self, traceback=True)
348
log('I could not find %s or %s. Will not update extended gtkpod DB.', ext_filename, idb_filename, sender=self)
350
log('Not writing extended gtkpod DB. Set "ipod_write_gpod_extended" to True if I should write it.', sender=self)
356
def update_played_or_delete(self, channel, episodes, delete_from_db):
358
Check whether episodes on ipod are played and update as played
359
and delete if required.
361
for episode in episodes:
362
track = self.episode_on_device(episode)
364
gtrack = track.libgpodtrack
365
if gtrack.playcount > 0:
366
if delete_from_db and not gtrack.rating:
367
log('Deleting episode from db %s', gtrack.title, sender=self)
368
channel.delete_episode(episode)
370
log('Marking episode as played %s', gtrack.title, sender=self)
371
episode.mark(is_played=True)
374
for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
375
if gpod.itdb_filename_on_ipod(track) is None:
376
log('Episode has no file: %s', track.title, sender=self)
377
# self.remove_track_gpod(track)
378
elif track.playcount > 0 and not track.rating:
379
log('Purging episode: %s', track.title, sender=self)
380
self.remove_track_gpod(track)
382
def get_all_tracks(self):
384
for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
385
filename = gpod.itdb_filename_on_ipod(track)
388
# This can happen if the episode is deleted on the device
389
log('Episode has no file: %s', track.title, sender=self)
390
self.remove_track_gpod(track)
393
length = util.calculate_size(filename)
394
timestamp = util.file_modification_timestamp(filename)
395
modified = util.format_date(timestamp)
397
released = gpod.itdb_time_mac_to_host(track.time_released)
398
released = util.format_date(released)
399
except ValueError, ve:
400
# timestamp out of range for platform time_t (bug 418)
401
log('Cannot convert track time: %s', ve, sender=self)
404
t = SyncTrack(track.title, length, modified, modified_sort=timestamp, libgpodtrack=track, playcount=track.playcount, released=released, podcast=track.artist)
408
def remove_track(self, track):
409
self.notify('status', _('Removing %s') % track.title)
410
self.remove_track_gpod(track.libgpodtrack)
412
def remove_track_gpod(self, track):
413
filename = gpod.itdb_filename_on_ipod(track)
416
gpod.itdb_playlist_remove_track(self.podcasts_playlist, track)
418
log('Track %s not in playlist', track.title, sender=self)
420
gpod.itdb_track_unlink(track)
421
util.delete_file(filename)
423
def add_track(self, episode):
424
self.notify('status', _('Adding %s') % episode.title)
425
for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
426
if episode.url == track.podcasturl:
427
if track.playcount > 0:
428
episode.mark(is_played=True)
429
# Mark as played on iPod if played locally (and set podcast flags)
430
self.set_podcast_flags(track, episode)
433
original_filename = episode.local_filename(create=False)
434
# The file has to exist, if we ought to transfer it, and therefore,
435
# local_filename(create=False) must never return None as filename
436
assert original_filename is not None
437
local_filename = original_filename
439
if util.calculate_size(original_filename) > self.get_free_space():
440
log('Not enough space on %s, sync aborted...', self.mountpoint, sender = self)
441
d = {'episode': episode.title, 'mountpoint': self.mountpoint}
442
message =_('Error copying %(episode)s: Not enough free space on %(mountpoint)s')
443
self.errors.append(message % d)
444
self.cancelled = True
447
local_filename = self.convert_track(episode)
449
(fn, extension) = os.path.splitext(local_filename)
450
if extension.lower().endswith('ogg'):
451
log('Cannot copy .ogg files to iPod.', sender=self)
454
track = gpod.itdb_track_new()
456
# Add release time to track if pubDate has a valid value
457
if episode.pubDate > 0:
459
# libgpod>= 0.5.x uses a new timestamp format
460
track.time_released = gpod.itdb_time_host_to_mac(int(episode.pubDate))
462
# old (pre-0.5.x) libgpod versions expect mactime, so
463
# we're going to manually build a good mactime timestamp here :)
465
# + 2082844800 for unixtime => mactime (1970 => 1904)
466
track.time_released = int(episode.pubDate + 2082844800)
468
track.title = str(episode.title)
469
track.album = str(episode.channel.title)
470
track.artist = str(episode.channel.title)
471
track.description = str(util.remove_html_tags(episode.description))
473
track.podcasturl = str(episode.url)
474
track.podcastrss = str(episode.channel.url)
476
track.tracklen = get_track_length(local_filename)
477
track.size = os.path.getsize(local_filename)
479
if episode.file_type() == 'audio':
480
track.filetype = 'mp3'
481
track.mediatype = 0x00000004
482
elif episode.file_type() == 'video':
483
track.filetype = 'm4v'
484
track.mediatype = 0x00000006
486
self.set_podcast_flags(track, episode)
487
self.set_cover_art(track, local_filename)
489
gpod.itdb_track_add(self.itdb, track, -1)
490
gpod.itdb_playlist_add_track(self.master_playlist, track, -1)
491
gpod.itdb_playlist_add_track(self.podcasts_playlist, track, -1)
492
copied = gpod.itdb_cp_track_to_ipod(track, str(local_filename), None)
494
if copied and gpodder.user_hooks is not None:
495
gpodder.user_hooks.on_file_copied_to_ipod(self, local_filename)
497
# If the file has been converted, delete the temporary file here
498
if local_filename != original_filename:
499
util.delete_file(local_filename)
503
def set_podcast_flags(self, track, episode):
505
# Set blue bullet for unplayed tracks on 5G iPods
506
if episode.is_played:
507
track.mark_unplayed = 0x01
508
if track.playcount == 0:
511
if track.playcount > 0 or track.bookmark_time > 0:
512
#track is partially played so no blue bullet
513
track.mark_unplayed = 0x01
516
track.mark_unplayed = 0x02
518
# Set several flags for to podcast values
519
track.remember_playback_position = 0x01
525
log('Seems like your python-gpod is out-of-date.', sender=self)
527
def set_cover_art(self, track, local_filename):
530
if tag.link(local_filename):
531
if 'APIC' in tag.frames and len(tag.frames['APIC']) > 0:
532
apic = tag.frames['APIC'][0]
535
if apic.mimeType == 'image/png':
537
cover_filename = '%s.cover.%s' (local_filename, extension)
539
cover_file = open(cover_filename, 'w')
540
cover_file.write(apic.imageData)
543
gpod.itdb_track_set_thumbnails(track, cover_filename)
546
log('Error getting cover using eyeD3', sender=self)
549
cover_filename = os.path.join(os.path.dirname(local_filename), 'folder.jpg')
551
if os.path.isfile(cover_filename):
552
gpod.itdb_track_set_thumbnails(track, cover_filename)
555
log('Error getting cover using channel cover', sender=self)
560
class MP3PlayerDevice(Device):
561
# if different players use other filenames besides
562
# .scrobbler.log, add them to this list
563
scrobbler_log_filenames = ['.scrobbler.log']
565
def __init__(self, config):
566
Device.__init__(self, config)
567
self.destination = self._config.mp3_player_folder
568
self.buffer_size = 1024*1024 # 1 MiB
569
self.scrobbler_log = []
571
def get_free_space(self):
572
return util.get_free_disk_space(self.destination)
576
self.notify('status', _('Opening MP3 player'))
577
if util.directory_is_writable(self.destination):
578
self.notify('status', _('MP3 player opened'))
579
# build the initial tracks_list
580
self.tracks_list = self.get_all_tracks()
581
if self._config.mp3_player_use_scrobbler_log:
582
mp3_player_mount_point = util.find_mount_point(self.destination)
583
# If a moint point cannot be found look inside self.destination for scrobbler_log_filenames
584
# this prevents us from os.walk()'ing the entire / filesystem
585
if mp3_player_mount_point == '/':
586
mp3_player_mount_point = self.destination
587
log_location = self.find_scrobbler_log(mp3_player_mount_point)
588
if log_location is not None and self.load_audioscrobbler_log(log_location):
589
log('Using Audioscrobbler log data to mark tracks as played', sender=self)
594
def add_track(self, episode):
595
self.notify('status', _('Adding %s') % episode.title.decode('utf-8', 'ignore'))
597
if self._config.fssync_channel_subfolders:
598
# Add channel title as subfolder
599
folder = episode.channel.title
600
# Clean up the folder name for use on limited devices
601
folder = util.sanitize_filename(folder, self._config.mp3_player_max_filename_length)
602
folder = os.path.join(self.destination, folder)
604
folder = self.destination
606
folder = util.sanitize_encoding(folder)
608
from_file = util.sanitize_encoding(self.convert_track(episode))
609
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)
611
to_file = filename_base + os.path.splitext(from_file)[1].lower()
613
# dirty workaround: on bad (empty) episode titles,
614
# we simply use the from_file basename
615
# (please, podcast authors, FIX YOUR RSS FEEDS!)
616
if os.path.splitext(to_file)[0] == '':
617
to_file = os.path.basename(from_file)
619
to_file = util.sanitize_encoding(os.path.join(folder, to_file))
621
if not os.path.exists(folder):
625
log('Cannot create folder on MP3 player: %s', folder, sender=self)
628
if self._config.mp3_player_use_scrobbler_log and not episode.is_played:
629
# FIXME: This misses some things when channel.title<>album tag which is what
630
# the scrobbling entity will be using.
631
if [episode.channel.title, episode.title] in self.scrobbler_log:
632
log('Marking "%s" from "%s" as played', episode.title, episode.channel.title, sender=self)
633
episode.mark(is_played=True)
635
if self._config.rockbox_copy_coverart and not os.path.exists(os.path.join(folder, 'cover.bmp')):
636
log('Creating Rockbox album art for "%s"', episode.channel.title, sender=self)
637
self.copy_player_cover_art(folder, from_file, \
638
'cover.bmp', 'BMP', self._config.rockbox_coverart_size)
640
if self._config.custom_player_copy_coverart \
641
and not os.path.exists(os.path.join(folder, \
642
self._config.custom_player_coverart_name)):
643
log('Creating custom player album art for "%s"',
644
episode.channel.title, sender=self)
645
self.copy_player_cover_art(folder, from_file, \
646
self._config.custom_player_coverart_name, \
647
self._config.custom_player_coverart_format, \
648
self._config.custom_player_coverart_size)
650
if not os.path.exists(to_file):
651
log('Copying %s => %s', os.path.basename(from_file), to_file.decode(util.encoding), sender=self)
652
copied = self.copy_file_progress(from_file, to_file)
653
if copied and gpodder.user_hooks is not None:
654
gpodder.user_hooks.on_file_copied_to_filesystem(self, from_file, to_file)
659
def copy_file_progress(self, from_file, to_file):
661
out_file = open(to_file, 'wb')
662
except IOError, ioerror:
663
d = {'filename': ioerror.filename, 'message': ioerror.strerror}
664
self.errors.append(_('Error opening %(filename)s: %(message)s') % d)
669
in_file = open(from_file, 'rb')
670
except IOError, ioerror:
671
d = {'filename': ioerror.filename, 'message': ioerror.strerror}
672
self.errors.append(_('Error opening %(filename)s: %(message)s') % d)
677
bytes = in_file.tell()
681
s = in_file.read(self.buffer_size)
686
except IOError, ioerror:
687
self.errors.append(ioerror.strerror)
693
log('Trying to remove partially copied file: %s' % to_file, sender=self)
695
log('Yeah! Unlinked %s at least..' % to_file, sender=self)
697
log('Error while trying to unlink %s. OH MY!' % to_file, sender=self)
700
self.notify('sub-progress', int(min(100, 100*float(bytes_read)/float(bytes))))
701
s = in_file.read(self.buffer_size)
707
def get_all_tracks(self):
710
if self._config.fssync_channel_subfolders:
711
files = glob.glob(os.path.join(self.destination, '*', '*'))
713
files = glob.glob(os.path.join(self.destination, '*'))
715
for filename in files:
716
(title, extension) = os.path.splitext(os.path.basename(filename))
717
length = util.calculate_size(filename)
719
timestamp = util.file_modification_timestamp(filename)
720
modified = util.format_date(timestamp)
721
if self._config.fssync_channel_subfolders:
722
podcast_name = os.path.basename(os.path.dirname(filename))
726
t = SyncTrack(title, length, modified, modified_sort=timestamp, filename=filename, podcast=podcast_name)
730
def episode_on_device(self, episode):
731
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)
732
return self._track_on_device(e)
734
def remove_track(self, track):
735
self.notify('status', _('Removing %s') % track.title)
736
util.delete_file(track.filename)
737
directory = os.path.dirname(track.filename)
738
if self.directory_is_empty(directory) and self._config.fssync_channel_subfolders:
742
log('Cannot remove %s', directory, sender=self)
744
def directory_is_empty(self, directory):
745
files = glob.glob(os.path.join(directory, '*'))
746
dotfiles = glob.glob(os.path.join(directory, '.*'))
747
return len(files+dotfiles) == 0
749
def find_scrobbler_log(self, mount_point):
750
""" find an audioscrobbler log file from log_filenames in the mount_point dir """
751
for dirpath, dirnames, filenames in os.walk(mount_point):
752
for log_file in self.scrobbler_log_filenames:
753
filename = os.path.join(dirpath, log_file)
754
if os.path.isfile(filename):
757
# No scrobbler log on that device
760
def copy_player_cover_art(self, destination, local_filename, \
761
cover_dst_name, cover_dst_format, \
764
Try to copy the channel cover to the podcast folder on the MP3
765
player. This makes the player, e.g. Rockbox (rockbox.org), display the
766
cover art in its interface.
768
You need the Python Imaging Library (PIL) installed to be able to
769
convert the cover file to a Bitmap file, which Rockbox needs.
772
cover_loc = os.path.join(os.path.dirname(local_filename), 'folder.jpg')
773
cover_dst = os.path.join(destination, cover_dst_name)
774
if os.path.isfile(cover_loc):
775
log('Creating cover art file on player', sender=self)
776
log('Cover art size is %s', cover_dst_size, sender=self)
777
size = (cover_dst_size, cover_dst_size)
779
cover = Image.open(cover_loc)
780
cover.thumbnail(size)
781
cover.save(cover_dst, cover_dst_format)
783
log('Cannot create %s (PIL?)', cover_dst, traceback=True, sender=self)
786
log('No cover available to set as player cover', sender=self)
789
log('Error getting cover using channel cover', sender=self)
793
def load_audioscrobbler_log(self, log_file):
794
""" Retrive track title and artist info for all the entries
795
in an audioscrobbler portable player format logfile
796
http://www.audioscrobbler.net/wiki/Portable_Player_Logging """
798
log('Opening "%s" as AudioScrobbler log.', log_file, sender=self)
799
f = open(log_file, 'r')
800
entries = f.readlines()
802
except IOError, ioerror:
803
log('Error: "%s" cannot be read.', log_file, sender=self)
807
# Scrobble Log Format: http://www.audioscrobbler.net/wiki/Portable_Player_Logging
808
# Notably some fields are optional so will appear as \t\t.
809
# Conforming scrobblers should strip any \t's from the actual fields.
810
for entry in entries:
811
entry = entry.split('\t')
813
artist, album, track, pos, length, rating = entry[:6]
814
# L means at least 50% of the track was listened to (S means < 50%)
816
# Whatever is writing the logs will only have the taginfo in the
817
# file to work from. Mostly album~=channel name
819
self.scrobbler_log.append([album, track])
821
log('Skipping logging of %s (missing track)', album)
823
log('Skipping scrobbler entry: %d elements %s', len(entry), entry)
826
log('Error while parsing "%s".', log_file, sender=self)
830
class MTPDevice(Device):
831
def __init__(self, config):
832
Device.__init__(self, config)
833
self.__model_name = None
835
self.__MTPDevice = MTP()
837
# pymtp not available / not installed (see bug 924)
838
log('pymtp not found: %s', str(e), sender=self)
839
self.__MTPDevice = None
841
def __callback(self, sent, total):
844
percentage = round(float(sent)/float(total)*100)
845
text = ('%i%%' % percentage)
846
self.notify('progress', sent, total, text)
848
def __date_to_mtp(self, date):
850
this function format the given date and time to a string representation
851
according to MTP specifications: YYYYMMDDThhmmss.s
854
the string representation od the given date
859
d = time.gmtime(date)
860
return time.strftime("%Y%m%d-%H%M%S.0Z", d)
861
except Exception, exc:
862
log('ERROR: An error has happend while trying to convert date to an mtp string (%s)', exc, sender=self)
865
def __mtp_to_date(self, mtp):
867
this parse the mtp's string representation for date
868
according to specifications (YYYYMMDDThhmmss.s) to
876
mtp = mtp.replace(" ", "0") # replace blank with 0 to fix some invalid string
877
d = time.strptime(mtp[:8] + mtp[9:13],"%Y%m%d%H%M%S")
878
_date = calendar.timegm(d)
880
# TIME ZONE SHIFTING: the string contains a hour/min shift relative to a time zone
882
shift_direction=mtp[15]
883
hour_shift = int(mtp[16:18])
884
minute_shift = int(mtp[18:20])
885
shift_in_sec = hour_shift * 3600 + minute_shift * 60
886
if shift_direction == "+":
887
_date += shift_in_sec
888
elif shift_direction == "-":
889
_date -= shift_in_sec
891
raise ValueError("Expected + or -")
892
except Exception, exc:
893
log('WARNING: ignoring invalid time zone information for %s (%s)', mtp, exc, sender=self)
894
return max( 0, _date )
895
except Exception, exc:
896
log('WARNING: the mtp date "%s" can not be parsed against mtp specification (%s)', mtp, exc, sender=self)
901
this function try to find a nice name for the device.
902
First, it tries to find a friendly (user assigned) name
903
(this name can be set by other application and is stored on the device).
904
if no friendly name was assign, it tries to get the model name (given by the vendor).
905
If no name is found at all, a generic one is returned.
907
Once found, the name is cached internaly to prevent reading again the device
910
the name of the device
913
if self.__model_name:
914
return self.__model_name
916
if self.__MTPDevice is None:
917
return _('MTP device')
919
self.__model_name = self.__MTPDevice.get_devicename() # actually libmtp.Get_Friendlyname
920
if not self.__model_name or self.__model_name == "?????":
921
self.__model_name = self.__MTPDevice.get_modelname()
922
if not self.__model_name:
923
self.__model_name = _('MTP device')
925
return self.__model_name
929
log("opening the MTP device", sender=self)
930
self.notify('status', _('Opening the MTP device'), )
933
self.__MTPDevice.connect()
934
# build the initial tracks_list
935
self.tracks_list = self.get_all_tracks()
936
except Exception, exc:
937
log('unable to find an MTP device (%s)', exc, sender=self, traceback=True)
940
self.notify('status', _('%s opened') % self.get_name())
944
log("closing %s", self.get_name(), sender=self)
945
self.notify('status', _('Closing %s') % self.get_name())
948
self.__MTPDevice.disconnect()
949
except Exception, exc:
950
log('unable to close %s (%s)', self.get_name(), exc, sender=self)
953
self.notify('status', _('%s closed') % self.get_name())
957
def add_track(self, episode):
958
self.notify('status', _('Adding %s...') % episode.title)
959
filename = str(self.convert_track(episode))
960
log("sending %s (%s).", filename, episode.title, sender=self)
964
needed = util.calculate_size(filename)
965
free = self.get_free_space()
967
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)
968
self.cancelled = True
972
metadata = pymtp.LIBMTP_Track()
973
metadata.title = str(episode.title)
974
metadata.artist = str(episode.channel.title)
975
metadata.album = str(episode.channel.title)
976
metadata.genre = "podcast"
977
metadata.date = self.__date_to_mtp(episode.pubDate)
978
metadata.duration = get_track_length(str(filename))
981
if episode.mimetype.startswith('audio/') and self._config.mtp_audio_folder:
982
folder_name = self._config.mtp_audio_folder
983
if episode.mimetype.startswith('video/') and self._config.mtp_video_folder:
984
folder_name = self._config.mtp_video_folder
985
if episode.mimetype.startswith('image/') and self._config.mtp_image_folder:
986
folder_name = self._config.mtp_image_folder
988
if folder_name != '' and self._config.mtp_podcast_folders:
989
folder_name += os.path.sep + str(episode.channel.title)
991
# log('Target MTP folder: %s' % folder_name)
993
if folder_name == '':
996
folder_id = self.__MTPDevice.mkdir(folder_name)
999
to_file = util.sanitize_filename(metadata.title) + episode.extension()
1000
self.__MTPDevice.send_track_from_file(filename, to_file,
1001
metadata, folder_id, callback=self.__callback)
1002
if gpodder.user_hooks is not None:
1003
gpodder.user_hooks.on_file_copied_to_mtp(self, filename, to_file)
1005
log('unable to add episode %s', episode.title, sender=self, traceback=True)
1010
def remove_track(self, sync_track):
1011
self.notify('status', _('Removing %s') % sync_track.mtptrack.title)
1012
log("removing %s", sync_track.mtptrack.title, sender=self)
1015
self.__MTPDevice.delete_object(sync_track.mtptrack.item_id)
1016
except Exception, exc:
1017
log('unable remove file %s (%s)', sync_track.mtptrack.filename, exc, sender=self)
1019
log('%s removed', sync_track.mtptrack.title , sender=self)
1021
def get_all_tracks(self):
1023
listing = self.__MTPDevice.get_tracklisting(callback=self.__callback)
1024
except Exception, exc:
1025
log('unable to get file listing %s (%s)', exc, sender=self)
1028
for track in listing:
1030
if not title or title=="": title=track.filename
1031
if len(title) > 50: title = title[0:49] + '...'
1032
artist = track.artist
1033
if artist and len(artist) > 50: artist = artist[0:49] + '...'
1034
length = track.filesize
1036
date = self.__mtp_to_date(track.date)
1038
modified = track.date # not a valid mtp date. Display what mtp gave anyway
1039
modified_sort = -1 # no idea how to sort invalid date
1041
modified = util.format_date(date)
1042
modified_sort = date
1044
t = SyncTrack(title, length, modified, modified_sort=modified_sort, mtptrack=track, podcast=artist)
1048
def get_free_space(self):
1049
if self.__MTPDevice is not None:
1050
return self.__MTPDevice.get_freespace()