~ubuntu-branches/ubuntu/vivid/gpodder/vivid

« back to all changes in this revision

Viewing changes to .pc/debian-changes-2.15-1/src/gpodder/sync.py

  • Committer: Bazaar Package Importer
  • Author(s): tony mancill
  • Date: 2011-05-31 22:05:31 UTC
  • mfrom: (5.2.18 sid)
  • Revision ID: james.westby@ubuntu.com-20110531220531-f3gt49fypbmuair8
Tags: 2.15-2
This time without a patch that reverts the source to 2.14.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# -*- coding: utf-8 -*-
2
 
#
3
 
# gPodder - A media aggregator and podcast client
4
 
# Copyright (c) 2005-2011 Thomas Perl and the gPodder Team
5
 
#
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.
10
 
#
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.
15
 
#
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/>.
18
 
#
19
 
 
20
 
 
21
 
# sync.py -- Device synchronization
22
 
# Thomas Perl <thp@perli.net> 2007-12-06
23
 
# based on libipodsync.py (2006-04-05 Thomas Perl)
24
 
 
25
 
import gpodder
26
 
 
27
 
from gpodder import util
28
 
from gpodder import services
29
 
from gpodder import libconverter
30
 
 
31
 
from gpodder.liblogger import log
32
 
 
33
 
import time
34
 
import calendar
35
 
 
36
 
_ = gpodder.gettext
37
 
 
38
 
gpod_available = True
39
 
try:
40
 
    import gpod
41
 
except:
42
 
    gpod_available = False
43
 
    log('(gpodder.sync) Could not find gpod')
44
 
 
45
 
pymtp_available = True
46
 
try:
47
 
    import gpodder.gpopymtp as pymtp
48
 
except:
49
 
    pymtp_available = False
50
 
    log('(gpodder.sync) Could not load gpopymtp (libmtp not installed?).')
51
 
 
52
 
try:
53
 
    import eyeD3
54
 
except:
55
 
    log( '(gpodder.sync) Could not find eyeD3')
56
 
 
57
 
try:
58
 
    import Image
59
 
except:
60
 
    log('(gpodder.sync) Could not find Python Imaging Library (PIL)')
61
 
 
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'], [])
67
 
 
68
 
import os
69
 
import os.path
70
 
import glob
71
 
import time
72
 
 
73
 
if pymtp_available:
74
 
    class MTP(pymtp.MTP):
75
 
        sep = os.path.sep
76
 
 
77
 
        def __init__(self):
78
 
            pymtp.MTP.__init__(self)
79
 
            self.folders = {}
80
 
 
81
 
        def connect(self):
82
 
            pymtp.MTP.connect(self)
83
 
            self.folders = self.unfold(self.mtp.LIBMTP_Get_Folder_List(self.device))
84
 
 
85
 
        def get_folder_list(self):
86
 
            return self.folders
87
 
 
88
 
        def unfold(self, folder, path=''):
89
 
            result = {}
90
 
            while folder:
91
 
                folder = folder.contents
92
 
                name = self.sep.join([path, folder.name]).lstrip(self.sep)
93
 
                result[name] = folder.folder_id
94
 
                if folder.child:
95
 
                    result.update(self.unfold(folder.child, name))
96
 
                folder = folder.sibling
97
 
            return result
98
 
 
99
 
        def mkdir(self, path):
100
 
            folder_id = 0
101
 
            prefix = []
102
 
            parts = path.split(self.sep)
103
 
            while parts:
104
 
                prefix.append(parts[0])
105
 
                tmpath = self.sep.join(prefix)
106
 
                if self.folders.has_key(tmpath):
107
 
                    folder_id = self.folders[tmpath]
108
 
                else:
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))
114
 
                del parts[0]
115
 
            # log('MTP.mkdir: %s = %u' % (path, folder_id))
116
 
            return folder_id
117
 
 
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)
126
 
    else:
127
 
        return None
128
 
 
129
 
def get_track_length(filename):
130
 
    if util.find_command('mplayer') is not None:
131
 
        try:
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)
134
 
        except:
135
 
            pass
136
 
    else:
137
 
        log('Please install MPlayer for track length detection.')
138
 
 
139
 
    try:
140
 
        eyed3_info = eyeD3.Mp3AudioFile(filename)
141
 
        return int(eyed3_info.getPlayTime()*1000)
142
 
    except:
143
 
        pass
144
 
 
145
 
    return int(60*60*1000*3) # Default is three hours (to be on the safe side)
146
 
 
147
 
class SyncTrack(object):
148
 
    """
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.
155
 
 
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)
160
 
 
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).
164
 
    """
165
 
    def __init__(self, title, length, modified, **kwargs):
166
 
        self.title = title
167
 
        self.length = length
168
 
        self.filesize = util.format_filesize(length)
169
 
        self.modified = modified
170
 
 
171
 
        # Set some (possible) keyword arguments to default values
172
 
        self.playcount = None
173
 
        self.podcast = None
174
 
        self.released = None
175
 
 
176
 
        # Convert keyword arguments to object attributes
177
 
        self.__dict__.update(kwargs)
178
 
 
179
 
 
180
 
class Device(services.ObservableService):
181
 
    def __init__(self, config):
182
 
        self._config = config
183
 
        self.cancelled = False
184
 
        self.allowed_types = ['audio', 'video']
185
 
        self.errors = []
186
 
        self.tracks_list = []
187
 
        signals = ['progress', 'sub-progress', 'status', 'done', 'post-done']
188
 
        services.ObservableService.__init__(self, signals)
189
 
 
190
 
    def open(self):
191
 
        pass
192
 
 
193
 
    def cancel(self):
194
 
        self.cancelled = True
195
 
        self.notify('status', _('Cancelled by user'))
196
 
 
197
 
    def close(self):
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)
201
 
        else:
202
 
            log('Not syncing disks. Unmount your device before unplugging.', sender=self)
203
 
            successful_sync = True
204
 
        self.notify('done')
205
 
        self.notify('post-done', self, successful_sync)
206
 
        return True
207
 
 
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
215
 
 
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)
219
 
 
220
 
        for id, track in enumerate(sorted(tracklist, key=lambda e: e.pubDate)):
221
 
            if self.cancelled:
222
 
                return False
223
 
 
224
 
            self.notify('progress', id+1, len(tracklist))
225
 
 
226
 
            added = self.add_track(track)
227
 
 
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)
231
 
 
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()
235
 
        return True
236
 
 
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')
246
 
                return filename
247
 
 
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)
251
 
 
252
 
            if local_filename is None:
253
 
                log('Cannot convert %s', filename, sender=self)
254
 
                return filename
255
 
 
256
 
            return str(local_filename)
257
 
 
258
 
        return filename
259
 
 
260
 
    def remove_tracks(self, tracklist=[]):
261
 
        for id, track in enumerate(tracklist):
262
 
            if self.cancelled:
263
 
                return False
264
 
            self.notify('progress', id, len(tracklist))
265
 
            self.remove_track(track)
266
 
        return True
267
 
 
268
 
    def get_all_tracks(self):
269
 
        pass
270
 
 
271
 
    def add_track(self, track):
272
 
        pass
273
 
 
274
 
    def remove_track(self, track):
275
 
        pass
276
 
 
277
 
    def get_free_space(self):
278
 
        pass
279
 
 
280
 
    def episode_on_device(self, episode):
281
 
        return self._track_on_device(episode.title)
282
 
 
283
 
    def _track_on_device(self, track_name):
284
 
        for t in self.tracks_list:
285
 
            title = t.title
286
 
            if track_name == title:
287
 
                return t
288
 
        return None
289
 
 
290
 
class iPodDevice(Device):
291
 
    def __init__(self, config):
292
 
        Device.__init__(self, config)
293
 
 
294
 
        self.mountpoint = str(self._config.ipod_mount)
295
 
 
296
 
        self.itdb = None
297
 
        self.podcast_playlist = None
298
 
        
299
 
 
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
304
 
 
305
 
    def open(self):
306
 
        Device.open(self)
307
 
        if not gpod_available or not os.path.isdir(self.mountpoint):
308
 
            return False
309
 
 
310
 
        self.notify('status', _('Opening iPod database'))
311
 
        self.itdb = gpod.itdb_parse(self.mountpoint, None)
312
 
        if self.itdb is None:
313
 
            return False
314
 
 
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)
318
 
 
319
 
        if self.podcasts_playlist:
320
 
            self.notify('status', _('iPod opened'))
321
 
 
322
 
            # build the initial tracks_list
323
 
            self.tracks_list = self.get_all_tracks()
324
 
 
325
 
            return True
326
 
        else:
327
 
            return False
328
 
 
329
 
    def close(self):
330
 
        if self.itdb is not None:
331
 
            self.notify('status', _('Saving iPod database'))
332
 
            gpod.itdb_write(self.itdb, None)
333
 
            self.itdb = None
334
 
            
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):
340
 
                    try:
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)
344
 
                        db.close()
345
 
                    except:
346
 
                        log('Error when writing iTunesDB.ext', sender=self, traceback=True)
347
 
                else:
348
 
                    log('I could not find %s or %s. Will not update extended gtkpod DB.', ext_filename, idb_filename, sender=self)
349
 
            else:
350
 
                log('Not writing extended gtkpod DB. Set "ipod_write_gpod_extended" to True if I should write it.', sender=self)            
351
 
            
352
 
            
353
 
        Device.close(self)
354
 
        return True
355
 
 
356
 
    def update_played_or_delete(self, channel, episodes, delete_from_db):
357
 
        """
358
 
        Check whether episodes on ipod are played and update as played
359
 
        and delete if required.
360
 
        """
361
 
        for episode in episodes:
362
 
            track = self.episode_on_device(episode)
363
 
            if track:
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)
369
 
                    else:
370
 
                        log('Marking episode as played %s', gtrack.title, sender=self)
371
 
                        episode.mark(is_played=True)
372
 
 
373
 
    def purge(self):
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)
381
 
 
382
 
    def get_all_tracks(self):
383
 
        tracks = []
384
 
        for track in gpod.sw_get_playlist_tracks(self.podcasts_playlist):
385
 
            filename = gpod.itdb_filename_on_ipod(track)
386
 
 
387
 
            if filename is None:
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)
391
 
                continue
392
 
 
393
 
            length = util.calculate_size(filename)
394
 
            timestamp = util.file_modification_timestamp(filename)
395
 
            modified = util.format_date(timestamp)
396
 
            try:
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)
402
 
                released = 0
403
 
 
404
 
            t = SyncTrack(track.title, length, modified, modified_sort=timestamp, libgpodtrack=track, playcount=track.playcount, released=released, podcast=track.artist)
405
 
            tracks.append(t)
406
 
        return tracks        
407
 
 
408
 
    def remove_track(self, track):
409
 
        self.notify('status', _('Removing %s') % track.title)
410
 
        self.remove_track_gpod(track.libgpodtrack)
411
 
 
412
 
    def remove_track_gpod(self, track):
413
 
        filename = gpod.itdb_filename_on_ipod(track)
414
 
 
415
 
        try:
416
 
            gpod.itdb_playlist_remove_track(self.podcasts_playlist, track)
417
 
        except:
418
 
            log('Track %s not in playlist', track.title, sender=self)
419
 
 
420
 
        gpod.itdb_track_unlink(track)
421
 
        util.delete_file(filename)
422
 
 
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)
431
 
                return True
432
 
 
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
438
 
 
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
445
 
            return False
446
 
 
447
 
        local_filename = self.convert_track(episode)
448
 
 
449
 
        (fn, extension) = os.path.splitext(local_filename)
450
 
        if extension.lower().endswith('ogg'):
451
 
            log('Cannot copy .ogg files to iPod.', sender=self)
452
 
            return False
453
 
 
454
 
        track = gpod.itdb_track_new()
455
 
        
456
 
        # Add release time to track if pubDate has a valid value
457
 
        if episode.pubDate > 0:
458
 
            try:
459
 
                # libgpod>= 0.5.x uses a new timestamp format
460
 
                track.time_released = gpod.itdb_time_host_to_mac(int(episode.pubDate))
461
 
            except:
462
 
                # old (pre-0.5.x) libgpod versions expect mactime, so
463
 
                # we're going to manually build a good mactime timestamp here :)
464
 
                #
465
 
                # + 2082844800 for unixtime => mactime (1970 => 1904)
466
 
                track.time_released = int(episode.pubDate + 2082844800)
467
 
        
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))
472
 
 
473
 
        track.podcasturl = str(episode.url)
474
 
        track.podcastrss = str(episode.channel.url)
475
 
 
476
 
        track.tracklen = get_track_length(local_filename)
477
 
        track.size = os.path.getsize(local_filename)
478
 
 
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
485
 
 
486
 
        self.set_podcast_flags(track, episode)
487
 
        self.set_cover_art(track, local_filename)
488
 
 
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)
493
 
 
494
 
        if copied and gpodder.user_hooks is not None:
495
 
            gpodder.user_hooks.on_file_copied_to_ipod(self, local_filename)
496
 
 
497
 
        # If the file has been converted, delete the temporary file here
498
 
        if local_filename != original_filename:
499
 
            util.delete_file(local_filename)
500
 
 
501
 
        return True
502
 
 
503
 
    def set_podcast_flags(self, track, episode):
504
 
        try:
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:
509
 
                    track.playcount = 1
510
 
            else:
511
 
                if track.playcount > 0 or track.bookmark_time > 0:
512
 
                    #track is partially played so no blue bullet
513
 
                    track.mark_unplayed = 0x01
514
 
                else:
515
 
                    #totally unplayed
516
 
                    track.mark_unplayed = 0x02
517
 
 
518
 
            # Set several flags for to podcast values
519
 
            track.remember_playback_position = 0x01
520
 
            track.flag1 = 0x02
521
 
            track.flag2 = 0x01
522
 
            track.flag3 = 0x01
523
 
            track.flag4 = 0x01
524
 
        except:
525
 
            log('Seems like your python-gpod is out-of-date.', sender=self)
526
 
    
527
 
    def set_cover_art(self, track, local_filename):
528
 
        try:
529
 
            tag = eyeD3.Tag()
530
 
            if tag.link(local_filename):
531
 
                if 'APIC' in tag.frames and len(tag.frames['APIC']) > 0:
532
 
                    apic = tag.frames['APIC'][0]
533
 
 
534
 
                    extension = 'jpg'
535
 
                    if apic.mimeType == 'image/png':
536
 
                        extension = 'png'
537
 
                    cover_filename = '%s.cover.%s' (local_filename, extension)
538
 
 
539
 
                    cover_file = open(cover_filename, 'w')
540
 
                    cover_file.write(apic.imageData)
541
 
                    cover_file.close()
542
 
 
543
 
                    gpod.itdb_track_set_thumbnails(track, cover_filename)
544
 
                    return True
545
 
        except:
546
 
            log('Error getting cover using eyeD3', sender=self)
547
 
 
548
 
        try:
549
 
            cover_filename = os.path.join(os.path.dirname(local_filename), 'folder.jpg')
550
 
 
551
 
            if os.path.isfile(cover_filename):
552
 
                gpod.itdb_track_set_thumbnails(track, cover_filename)
553
 
                return True
554
 
        except:
555
 
            log('Error getting cover using channel cover', sender=self)
556
 
 
557
 
        return False
558
 
 
559
 
 
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']
564
 
 
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 = []
570
 
 
571
 
    def get_free_space(self):
572
 
        return util.get_free_disk_space(self.destination)
573
 
 
574
 
    def open(self):
575
 
        Device.open(self)
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)
590
 
            return True
591
 
        else:
592
 
            return False
593
 
 
594
 
    def add_track(self, episode):
595
 
        self.notify('status', _('Adding %s') % episode.title.decode('utf-8', 'ignore'))
596
 
 
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)
603
 
        else:
604
 
            folder = self.destination
605
 
 
606
 
        folder = util.sanitize_encoding(folder)
607
 
 
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)
610
 
 
611
 
        to_file = filename_base + os.path.splitext(from_file)[1].lower()
612
 
 
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)
618
 
 
619
 
        to_file = util.sanitize_encoding(os.path.join(folder, to_file))
620
 
 
621
 
        if not os.path.exists(folder):
622
 
            try:
623
 
                os.makedirs(folder)
624
 
            except:
625
 
                log('Cannot create folder on MP3 player: %s', folder, sender=self)
626
 
                return False
627
 
 
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)
634
 
 
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)
639
 
 
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)
649
 
 
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)
655
 
            return copied
656
 
 
657
 
        return True
658
 
 
659
 
    def copy_file_progress(self, from_file, to_file):
660
 
        try:
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)
665
 
            self.cancel()
666
 
            return False
667
 
 
668
 
        try:
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)
673
 
            self.cancel()
674
 
            return False
675
 
 
676
 
        in_file.seek(0, 2)
677
 
        bytes = in_file.tell()
678
 
        in_file.seek(0)
679
 
 
680
 
        bytes_read = 0
681
 
        s = in_file.read(self.buffer_size)
682
 
        while s:
683
 
            bytes_read += len(s)
684
 
            try:
685
 
                out_file.write(s)
686
 
            except IOError, ioerror:
687
 
                self.errors.append(ioerror.strerror)
688
 
                try:
689
 
                    out_file.close()
690
 
                except:
691
 
                    pass
692
 
                try:
693
 
                    log('Trying to remove partially copied file: %s' % to_file, sender=self)
694
 
                    os.unlink( to_file)
695
 
                    log('Yeah! Unlinked %s at least..' % to_file, sender=self)
696
 
                except:
697
 
                    log('Error while trying to unlink %s. OH MY!' % to_file, sender=self)
698
 
                self.cancel()
699
 
                return False
700
 
            self.notify('sub-progress', int(min(100, 100*float(bytes_read)/float(bytes))))
701
 
            s = in_file.read(self.buffer_size)
702
 
        out_file.close()
703
 
        in_file.close()
704
 
 
705
 
        return True
706
 
    
707
 
    def get_all_tracks(self):
708
 
        tracks = []
709
 
 
710
 
        if self._config.fssync_channel_subfolders:
711
 
            files = glob.glob(os.path.join(self.destination, '*', '*'))
712
 
        else:
713
 
            files = glob.glob(os.path.join(self.destination, '*'))
714
 
 
715
 
        for filename in files:
716
 
            (title, extension) = os.path.splitext(os.path.basename(filename))
717
 
            length = util.calculate_size(filename)
718
 
 
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))
723
 
            else:
724
 
                podcast_name = None
725
 
 
726
 
            t = SyncTrack(title, length, modified, modified_sort=timestamp, filename=filename, podcast=podcast_name)
727
 
            tracks.append(t)
728
 
        return tracks
729
 
 
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)
733
 
 
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:
739
 
            try:
740
 
                os.rmdir(directory)
741
 
            except:
742
 
                log('Cannot remove %s', directory, sender=self)
743
 
 
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
748
 
 
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):
755
 
                    return filename
756
 
 
757
 
        # No scrobbler log on that device
758
 
        return None
759
 
 
760
 
    def copy_player_cover_art(self, destination, local_filename, \
761
 
                                  cover_dst_name, cover_dst_format, \
762
 
                                  cover_dst_size):
763
 
        """
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.
767
 
 
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.
770
 
        """
771
 
        try:
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)
778
 
                try:
779
 
                    cover = Image.open(cover_loc)
780
 
                    cover.thumbnail(size)
781
 
                    cover.save(cover_dst, cover_dst_format)
782
 
                except IOError:
783
 
                    log('Cannot create %s (PIL?)', cover_dst, traceback=True, sender=self)
784
 
                return True
785
 
            else:
786
 
                log('No cover available to set as player cover', sender=self)
787
 
                return True
788
 
        except:
789
 
            log('Error getting cover using channel cover', sender=self)
790
 
        return False
791
 
 
792
 
 
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 """
797
 
        try:
798
 
            log('Opening "%s" as AudioScrobbler log.', log_file, sender=self)
799
 
            f = open(log_file, 'r')
800
 
            entries = f.readlines()
801
 
            f.close()
802
 
        except IOError, ioerror:
803
 
            log('Error: "%s" cannot be read.', log_file, sender=self)
804
 
            return False
805
 
 
806
 
        try:
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')
812
 
                if len(entry)>=5:
813
 
                    artist, album, track, pos, length, rating = entry[:6]
814
 
                    # L means at least 50% of the track was listened to (S means < 50%)
815
 
                    if 'L' in rating:
816
 
                        # Whatever is writing the logs will only have the taginfo in the
817
 
                        # file to work from. Mostly album~=channel name
818
 
                        if len(track):
819
 
                            self.scrobbler_log.append([album, track])
820
 
                        else:
821
 
                            log('Skipping logging of %s (missing track)', album)
822
 
                else:
823
 
                    log('Skipping scrobbler entry: %d elements %s', len(entry), entry)
824
 
                    
825
 
        except:
826
 
            log('Error while parsing "%s".', log_file, sender=self)
827
 
 
828
 
        return True
829
 
 
830
 
class MTPDevice(Device):
831
 
    def __init__(self, config):
832
 
        Device.__init__(self, config)
833
 
        self.__model_name = None
834
 
        try:
835
 
            self.__MTPDevice = MTP()
836
 
        except NameError, e:
837
 
            # pymtp not available / not installed (see bug 924)
838
 
            log('pymtp not found: %s', str(e), sender=self)
839
 
            self.__MTPDevice = None
840
 
 
841
 
    def __callback(self, sent, total):
842
 
        if self.cancelled:
843
 
            return -1
844
 
        percentage = round(float(sent)/float(total)*100)
845
 
        text = ('%i%%' % percentage)
846
 
        self.notify('progress', sent, total, text)
847
 
 
848
 
    def __date_to_mtp(self, date):
849
 
        """
850
 
        this function format the given date and time to a string representation
851
 
        according to MTP specifications: YYYYMMDDThhmmss.s
852
 
 
853
 
        return
854
 
            the string representation od the given date
855
 
        """
856
 
        if not date:
857
 
            return ""
858
 
        try:
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)
863
 
            return None
864
 
 
865
 
    def __mtp_to_date(self, mtp):
866
 
        """
867
 
        this parse the mtp's string representation for date
868
 
        according to specifications (YYYYMMDDThhmmss.s) to
869
 
        a python time object
870
 
 
871
 
        """
872
 
        if not mtp:
873
 
            return None
874
 
 
875
 
        try:
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)
879
 
            if len(mtp)==20:
880
 
                # TIME ZONE SHIFTING: the string contains a hour/min shift relative to a time zone
881
 
                try:
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
890
 
                    else:
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)
897
 
            return None
898
 
 
899
 
    def get_name(self):
900
 
        """
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.
906
 
 
907
 
        Once found, the name is cached internaly to prevent reading again the device
908
 
 
909
 
        return
910
 
            the name of the device
911
 
        """
912
 
 
913
 
        if self.__model_name:
914
 
            return self.__model_name
915
 
 
916
 
        if self.__MTPDevice is None:
917
 
            return _('MTP device')
918
 
 
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')
924
 
 
925
 
        return self.__model_name
926
 
 
927
 
    def open(self):
928
 
        Device.open(self)
929
 
        log("opening the MTP device", sender=self)
930
 
        self.notify('status', _('Opening the MTP device'), )
931
 
 
932
 
        try:
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)
938
 
            return False
939
 
 
940
 
        self.notify('status', _('%s opened') % self.get_name())
941
 
        return True
942
 
 
943
 
    def close(self):
944
 
        log("closing %s", self.get_name(), sender=self)
945
 
        self.notify('status', _('Closing %s') % self.get_name())
946
 
 
947
 
        try:
948
 
            self.__MTPDevice.disconnect()
949
 
        except Exception, exc:
950
 
            log('unable to close %s (%s)', self.get_name(), exc, sender=self)
951
 
            return False
952
 
 
953
 
        self.notify('status', _('%s closed') % self.get_name())
954
 
        Device.close(self)
955
 
        return True
956
 
 
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)
961
 
 
962
 
        try:
963
 
            # verify free space
964
 
            needed = util.calculate_size(filename)
965
 
            free = self.get_free_space()
966
 
            if needed > free:
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
969
 
                return False
970
 
 
971
 
            # fill metadata
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))
979
 
 
980
 
            folder_name = ''
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
987
 
 
988
 
            if folder_name != '' and self._config.mtp_podcast_folders:
989
 
                folder_name += os.path.sep + str(episode.channel.title)
990
 
 
991
 
            # log('Target MTP folder: %s' % folder_name)
992
 
 
993
 
            if folder_name == '':
994
 
                folder_id = 0
995
 
            else:
996
 
                folder_id = self.__MTPDevice.mkdir(folder_name)
997
 
 
998
 
            # send the file
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)
1004
 
        except:
1005
 
            log('unable to add episode %s', episode.title, sender=self, traceback=True)
1006
 
            return False
1007
 
 
1008
 
        return True
1009
 
 
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)
1013
 
 
1014
 
        try:
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)
1018
 
 
1019
 
        log('%s removed', sync_track.mtptrack.title , sender=self)
1020
 
 
1021
 
    def get_all_tracks(self):
1022
 
        try:
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)
1026
 
 
1027
 
        tracks = []
1028
 
        for track in listing:
1029
 
            title = track.title
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
1035
 
            age_in_days = 0
1036
 
            date = self.__mtp_to_date(track.date)
1037
 
            if not 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
1040
 
            else:
1041
 
                modified = util.format_date(date)
1042
 
                modified_sort = date
1043
 
 
1044
 
            t = SyncTrack(title, length, modified, modified_sort=modified_sort, mtptrack=track, podcast=artist)
1045
 
            tracks.append(t)
1046
 
        return tracks
1047
 
 
1048
 
    def get_free_space(self):
1049
 
        if self.__MTPDevice is not None:
1050
 
            return self.__MTPDevice.get_freespace()
1051
 
        else:
1052
 
            return 0
1053