Optionally remove old episodes from iPod
[gpodder.git] / src / gpodder / sync.py
blob5245ae022b0e8db40f56e5c6101d995e82565b1e
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2008 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
21 # sync.py -- Device synchronization
22 # Thomas Perl <thp@perli.net> 2007-12-06
23 # based on libipodsync.py (2006-04-05 Thomas Perl)
26 from gpodder import util
27 from gpodder import services
28 from gpodder import libconverter
29 from gpodder import libtagupdate
31 from gpodder.liblogger import log
32 from gpodder.libgpodder import gl
33 from gpodder.dbsqlite import db
35 import time
36 import calendar
38 gpod_available = True
39 try:
40 import gpod
41 except:
42 gpod_available = False
43 log('(gpodder.sync) Could not find gpod')
45 pymtp_available = True
46 try:
47 import pymtp
48 except:
49 pymtp_available = False
50 log('(gpodder.sync) Could not find pymtp.')
52 try:
53 import mad
54 except:
55 log('(gpodder.sync) Could not find pymad')
57 try:
58 import eyeD3
59 except:
60 log( '(gpodder.sync) Could not find eyeD3')
62 try:
63 import Image
64 except:
65 log('(gpodder.sync) Could not find Python Imaging Library (PIL)')
67 # 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'], [])
74 import os
75 import os.path
76 import glob
77 import shutil
78 import sys
79 import time
80 import string
81 import email.Utils
82 import re
85 def open_device():
86 device_type = gl.config.device_type
87 if device_type == 'ipod':
88 return iPodDevice()
89 elif device_type == 'filesystem':
90 return MP3PlayerDevice()
91 elif device_type == 'mtp':
92 return MTPDevice()
93 else:
94 return None
96 def get_track_length(filename):
97 if util.find_command('mplayer') is not None:
98 try:
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)
101 except:
102 pass
103 else:
104 log('Please install MPlayer for track length detection.')
106 try:
107 mad_info = mad.MadFile(filename)
108 return int(mad_info.total_time())
109 except:
110 pass
112 try:
113 eyed3_info = eyeD3.Mp3AudioFile(filename)
114 return int(eyed3_info.getPlayTime()*1000)
115 except:
116 pass
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):
140 self.title = title
141 self.length = length
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
147 self.podcast = None
148 self.released = None
150 # Convert keyword arguments to object attributes
151 self.__dict__.update(kwargs)
154 class Device(services.ObservableService):
155 def __init__(self):
156 self.cancelled = False
157 self.allowed_types = ['audio', 'video']
158 self.errors = []
159 self.tracks_list = []
160 signals = ['progress', 'sub-progress', 'status', 'done', 'post-done']
161 services.ObservableService.__init__(self, signals)
163 def open(self):
164 pass
166 def cancel(self):
167 self.cancelled = True
168 self.notify('status', _('Cancelled by user'))
170 def close(self):
171 self.notify('status', _('Writing data to disk'))
172 successful_sync = not os.system('sync')
173 self.notify('done')
174 self.notify('post-done', self, successful_sync)
175 return True
177 def add_tracks(self, tracklist=[], force_played=False):
178 for id, track in enumerate(tracklist):
179 if self.cancelled:
180 return False
182 self.notify('progress', id+1, len(tracklist))
184 if not track.was_downloaded(and_exists=True):
185 continue
187 if track.is_played and gl.config.only_sync_not_played and not force_played:
188 continue
190 if track.file_type() not in self.allowed_types:
191 continue
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()
202 return True
204 def remove_tracks(self, tracklist=[]):
205 for id, track in enumerate(tracklist):
206 if self.cancelled:
207 return False
208 self.notify('progress', id, len(tracklist))
209 self.remove_track(track)
210 return True
212 def get_all_tracks(self):
213 pass
215 def add_track(self, track):
216 pass
218 def remove_track(self, track):
219 pass
221 def get_free_space(self):
222 pass
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:
230 return t
231 return False
233 class iPodDevice(Device):
234 def __init__(self):
235 Device.__init__(self)
237 self.mountpoint = str(gl.config.ipod_mount)
239 self.itdb = None
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
248 def open(self):
249 Device.open(self)
250 if not gpod_available or not os.path.isdir(self.mountpoint):
251 return False
253 self.notify('status', _('Opening iPod database'))
254 self.itdb = gpod.itdb_parse(self.mountpoint, None)
255 if self.itdb is None:
256 return False
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()
267 return True
268 else:
269 return False
271 def close(self):
272 if self.itdb is not None:
273 self.notify('status', _('Saving iPod database'))
274 gpod.itdb_write(self.itdb, None)
275 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):
284 try:
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)
288 db.close()
289 except:
290 log('Error when writing iTunesDB.ext', sender=self, traceback=True)
291 else:
292 log('I could not find %s or %s. Will not update extended gtkpod DB.', ext_filename, idb_filename, sender=self)
293 else:
294 log('Not writing extended gtkpod DB. Set "ipod_write_gpod_extended" to True if I should write it.', sender=self)
296 Device.close(self)
297 return True
299 def purge(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):
309 tracks = []
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)
320 tracks.append(t)
321 return tracks
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)
330 try:
331 gpod.itdb_playlist_remove_track(self.podcasts_playlist, track)
332 except:
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)
346 return True
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
355 return False
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)
368 return False
369 else:
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)
375 return False
377 track = gpod.itdb_track_new()
379 # Add release time to track if pubDate has a valid value
380 if episode.pubDate > 0:
381 try:
382 # libgpod>= 0.5.x uses a new timestamp format
383 track.time_released = gpod.itdb_time_host_to_mac(int(episode.pubDate))
384 except:
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)
420 return True
422 def set_podcast_flags(self, track):
423 try:
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:
429 track.playcount = 1
430 else:
431 track.mark_unplayed = 0x02
433 # Set several flags for to podcast values
434 track.remember_playback_position = 0x01
435 track.flag1 = 0x02
436 track.flag2 = 0x01
437 track.flag3 = 0x01
438 track.flag4 = 0x01
439 except:
440 log('Seems like your python-gpod is out-of-date.', sender=self)
442 def set_cover_art(self, track, local_filename):
443 try:
444 tag = eyeD3.Tag()
445 if tag.link(local_filename):
446 if 'APIC' in tag.frames and len(tag.frames['APIC']) > 0:
447 apic = tag.frames['APIC'][0]
449 extension = 'jpg'
450 if apic.mimeType == 'image/png':
451 extension = 'png'
452 cover_filename = '%s.cover.%s' (local_filename, extension)
454 cover_file = open(cover_filename, 'w')
455 cover_file.write(apic.imageData)
456 cover_file.close()
458 gpod.itdb_track_set_thumbnails(track, cover_filename)
459 return True
460 except:
461 log('Error getting cover using eyeD3', sender=self)
463 try:
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)
467 return True
468 except:
469 log('Error getting cover using channel cover', sender=self)
471 return False
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
484 def __init__(self):
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)
493 def open(self):
494 Device.open(self)
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)
509 return True
510 else:
511 return False
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)
522 else:
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):
539 try:
540 os.makedirs(folder)
541 except:
542 log('Cannot create folder on MP3 player: %s', folder, sender=self)
543 return False
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)
569 return True
571 def copy_file_progress(self, from_file, to_file):
572 try:
573 out_file = open(to_file, 'wb')
574 except IOError, ioerror:
575 self.errors.append(_('Error opening %s: %s') % (ioerror.filename, ioerror.strerror))
576 self.cancel()
577 return False
579 try:
580 in_file = open(from_file, 'rb')
581 except IOError, ioerror:
582 self.errors.append(_('Error opening %s: %s') % (ioerror.filename, ioerror.strerror))
583 self.cancel()
584 return False
586 in_file.seek(0, 2)
587 bytes = in_file.tell()
588 in_file.seek(0)
590 bytes_read = 0
591 s = in_file.read(self.buffer_size)
592 while s:
593 bytes_read += len(s)
594 try:
595 out_file.write(s)
596 except IOError, ioerror:
597 self.errors.append(ioerror.strerror)
598 try:
599 out_file.close()
600 except:
601 pass
602 try:
603 log('Trying to remove partially copied file: %s' % to_file, sender=self)
604 os.unlink( to_file)
605 log('Yeah! Unlinked %s at least..' % to_file, sender=self)
606 except:
607 log('Error while trying to unlink %s. OH MY!' % to_file, sender=self)
608 self.cancel()
609 return False
610 self.notify('sub-progress', int(min(100, 100*float(bytes_read)/float(bytes))))
611 s = in_file.read(self.buffer_size)
612 out_file.close()
613 in_file.close()
615 return True
617 def get_all_tracks(self):
618 tracks = []
620 if gl.config.fssync_channel_subfolders:
621 files = glob.glob(os.path.join(self.destination, '*', '*'))
622 else:
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))
633 else:
634 podcast_name = None
636 t = SyncTrack(title, length, modified, filename=filename, podcast=podcast_name)
637 tracks.append(t)
638 return tracks
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:
649 try:
650 os.rmdir(directory)
651 except:
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):
665 return filename
667 # No scrobbler log on that device
668 return None
670 def copy_player_cover_art(self, destination, local_filename, \
671 cover_dst_name, cover_dst_format, \
672 cover_dst_size):
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.
681 try:
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)
688 try:
689 cover = Image.open(cover_loc)
690 cover.thumbnail(size)
691 cover.save(cover_dst, cover_dst_format)
692 except IOError:
693 log('Cannot create %s (PIL?)', cover_dst, traceback=True, sender=self)
694 return True
695 else:
696 log('No cover available to set as player cover', sender=self)
697 return True
698 except:
699 log('Error getting cover using channel cover', sender=self)
700 return False
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 """
707 try:
708 log('Opening "%s" as AudioScrobbler log.', log_file, sender=self)
709 f = open(log_file, 'r')
710 entries = f.readlines()
711 f.close()
712 except IOError, ioerror:
713 log('Error: "%s" cannot be read.', log_file, sender=self)
714 return False
716 try:
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)])
725 except:
726 log('Error while parsing "%s".', log_file, sender=self)
728 return True
730 class MTPDevice(Device):
731 def __init__(self):
732 Device.__init__(self)
733 self.__model_name = None
734 self.__MTPDevice = pymtp.MTP()
736 def __callback(self, sent, total):
737 if self.cancelled:
738 return -1
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
748 return
749 the string representation od the given date
751 if not date:
752 return ""
753 try:
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)
758 return None
760 def __mtp_to_date(self, mtp):
762 this parse the mtp's string representation for date
763 according to specifications (YYYYMMDDThhmmss.s) to
764 a python time object
767 if not mtp:
768 return None
770 try:
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)
774 if len(mtp)==20:
775 # TIME ZONE SHIFTING: the string contains a hour/min shift relative to a time zone
776 try:
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
785 else:
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)
792 return None
794 def get_name(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
804 return
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
819 def open(self):
820 Device.open(self)
821 log("opening the MTP device", sender=self)
822 self.notify('status', _('Opening the MTP device'), )
824 try:
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)
830 return False
832 self.notify('status', _('%s opened') % self.get_name())
833 return True
835 def close(self):
836 log("closing %s", self.get_name(), sender=self)
837 self.notify('status', _('Closing %s') % self.get_name())
839 try:
840 self.__MTPDevice.disconnect()
841 except Exception, exc:
842 log('unable to close %s (%s)', self.get_name(), exc, sender=self)
843 return False
845 self.notify('status', _('%s closed') % self.get_name())
846 Device.close(self)
847 return True
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)
853 try:
854 # verify free space
855 needed = util.calculate_size(episode.local_filename())
856 free = self.get_free_space()
857 if needed > free:
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
860 return False
862 # fill metadata
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()))
871 # send the file
872 self.__MTPDevice.send_track_from_file( str(episode.local_filename()), episode.basename, metadata, 0, callback=self.__callback)
873 except:
874 log('unable to add episode %s', episode.title, sender=self, traceback=True)
875 return False
877 return 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)
883 try:
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):
891 try:
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)
896 tracks = []
897 for track in listing:
898 title = track.title
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
904 age_in_days = 0
905 date = self.__mtp_to_date(track.date)
906 if not date:
907 modified = track.date # not a valid mtp date. Display what mtp gave anyway
908 else:
909 modified = util.format_date(date)
911 t = SyncTrack(title, length, modified, mtptrack=track, podcast=artist)
912 tracks.append(t)
913 return tracks
915 def get_free_space(self):
916 return self.__MTPDevice.get_freespace()