Add setting to toggle sync filesize comparisons.
[gpodder.git] / src / gpodder / sync.py
blobf8f3bdb5a95c1d40690cf5eae611f7cde21a6934
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2018 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)
24 # Ported to gPodder 3 by Joseph Wickremasinghe in June 2012
26 import logging
27 import os.path
28 import threading
29 import time
31 import gpodder
32 from gpodder import download, services, util
34 import gi # isort:skip
35 gi.require_version('Gio', '2.0') # isort:skip
36 from gi.repository import GLib, Gio # isort:skip
39 logger = logging.getLogger(__name__)
42 _ = gpodder.gettext
44 gpod_available = True
45 try:
46 from gpodder import libgpod_ctypes
47 except:
48 logger.info('iPod sync not available')
49 gpod_available = False
51 mplayer_available = True if util.find_command('mplayer') is not None else False
53 eyed3mp3_available = True
54 try:
55 import eyed3.mp3
56 except:
57 logger.info('eyeD3 MP3 not available')
58 eyed3mp3_available = False
61 def open_device(gui):
62 config = gui._config
63 device_type = gui._config.device_sync.device_type
64 if device_type == 'ipod':
65 return iPodDevice(config,
66 gui.download_status_model,
67 gui.download_queue_manager)
68 elif device_type == 'filesystem':
69 return MP3PlayerDevice(config,
70 gui.download_status_model,
71 gui.download_queue_manager,
72 gui.mount_volume_for_file)
74 return None
77 def get_track_length(filename):
78 attempted = False
80 if mplayer_available:
81 try:
82 mplayer_output = os.popen('mplayer -msglevel all=-1 -identify -vo null -ao null -frames 0 "%s" 2>/dev/null' % filename).read()
83 return int(float(mplayer_output[mplayer_output.index('ID_LENGTH'):].splitlines()[0][10:]) * 1000)
84 except Exception:
85 logger.error('MPlayer could not determine length: %s', filename, exc_info=True)
86 attempted = True
88 if eyed3mp3_available:
89 try:
90 length = int(eyed3.mp3.Mp3AudioFile(filename).info.time_secs * 1000)
91 # Notify user on eyed3 success if mplayer failed.
92 # A warning is used to make it visible in gpo or on console.
93 if attempted:
94 logger.warning('eyed3.mp3 successfully determined length: %s', filename)
95 return length
96 except Exception:
97 logger.error('eyed3.mp3 could not determine length: %s', filename, exc_info=True)
98 attempted = True
100 if not attempted:
101 logger.warning('Could not determine length: %s', filename)
102 logger.warning('Please install MPlayer or the eyed3.mp3 module for track length detection.')
104 return int(60 * 60 * 1000 * 3)
105 # Default is three hours (to be on the safe side)
108 def episode_filename_on_device(config, episode):
110 :param gpodder.config.Config config: configuration (for sync options)
111 :param gpodder.model.PodcastEpisode episode: episode to get filename for
112 :return str: basename minus extension to use to save episode on device
114 # get the local file
115 from_file = episode.local_filename(create=False)
116 # get the formatted base name
117 filename_base = util.sanitize_filename(episode.sync_filename(
118 config.device_sync.custom_sync_name_enabled,
119 config.device_sync.custom_sync_name),
120 config.device_sync.max_filename_length)
121 # add the file extension
122 to_file = filename_base + os.path.splitext(from_file)[1].lower()
124 # dirty workaround: on bad (empty) episode titles,
125 # we simply use the from_file basename
126 # (please, podcast authors, FIX YOUR RSS FEEDS!)
127 if os.path.splitext(to_file)[0] == '':
128 to_file = os.path.basename(from_file)
129 return to_file
132 def episode_foldername_on_device(config, episode):
134 :param gpodder.config.Config config: configuration (for sync options)
135 :param gpodder.model.PodcastEpisode episode: episode to get folder name for
136 :return str: folder name to save episode to on device
138 if config.device_sync.one_folder_per_podcast:
139 # Add channel title as subfolder
140 folder = episode.channel.title
141 # Clean up the folder name for use on limited devices
142 folder = util.sanitize_filename(folder, config.device_sync.max_filename_length)
143 else:
144 folder = None
145 return folder
148 class SyncTrack(object):
150 This represents a track that is on a device. You need
151 to specify at least the following keyword arguments,
152 because these will be used to display the track in the
153 GUI. All other keyword arguments are optional and can
154 be used to reference internal objects, etc... See the
155 iPod synchronization code for examples.
157 Keyword arguments needed:
158 playcount (How often has the track been played?)
159 podcast (Which podcast is this track from? Or: Folder name)
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):
166 self.title = title
167 self.length = length
168 self.filesize = util.format_filesize(length)
169 self.modified = modified
171 # Set some (possible) keyword arguments to default values
172 self.playcount = 0
173 self.podcast = None
175 # Convert keyword arguments to object attributes
176 self.__dict__.update(kwargs)
178 def __repr__(self):
179 return 'SyncTrack(title={}, podcast={})'.format(self.title, self.podcast)
181 @property
182 def playcount_str(self):
183 return str(self.playcount)
186 class Device(services.ObservableService):
187 def __init__(self, config):
188 self._config = config
189 self.cancelled = False
190 self.allowed_types = ['audio', 'video']
191 self.errors = []
192 self.tracks_list = []
193 signals = ['progress', 'sub-progress', 'status', 'done', 'post-done']
194 services.ObservableService.__init__(self, signals)
196 def open(self):
197 pass
199 def cancel(self):
200 self.cancelled = True
201 self.notify('status', _('Cancelled by user'))
203 def close(self):
204 self.notify('status', _('Writing data to disk'))
205 if self._config.device_sync.after_sync.sync_disks and not gpodder.ui.win32:
206 os.system('sync')
207 else:
208 logger.warning('Not syncing disks. Unmount your device before unplugging.')
209 return True
211 def create_task(self, track):
212 return SyncTask(track)
214 def cancel_task(self, task):
215 pass
217 def cleanup_task(self, task):
218 pass
220 def add_sync_tasks(self, tracklist, force_played=False, done_callback=None):
221 for track in list(tracklist):
222 # Filter tracks that are not meant to be synchronized
223 does_not_exist = not track.was_downloaded(and_exists=True)
224 exclude_played = (not track.is_new
225 and self._config.device_sync.skip_played_episodes)
226 wrong_type = track.file_type() not in self.allowed_types
228 if does_not_exist:
229 tracklist.remove(track)
230 elif exclude_played or wrong_type:
231 logger.info('Excluding %s from sync', track.title)
232 tracklist.remove(track)
234 if tracklist:
235 for track in sorted(tracklist, key=lambda e: e.pubdate_prop):
236 if self.cancelled:
237 break
239 # XXX: need to check if track is added properly?
240 sync_task = self.create_task(track)
242 sync_task.status = sync_task.NEW
243 sync_task.device = self
244 # New Task, we must wait on the GTK Loop
245 self.download_status_model.register_task(sync_task)
246 # Executes after task has been registered
247 util.idle_add(self.download_queue_manager.queue_task, sync_task)
248 else:
249 logger.warning("No episodes to sync")
251 if done_callback:
252 done_callback()
254 def get_all_tracks(self):
255 pass
257 def add_track(self, track, reporthook=None):
258 pass
260 def remove_track(self, track):
261 pass
263 def get_free_space(self):
264 pass
266 def episode_on_device(self, episode):
267 return self._track_on_device(episode.title)
269 def _track_on_device(self, track_name):
270 for t in self.tracks_list:
271 title = t.title
272 if track_name == title:
273 return t
274 return None
277 class iPodDevice(Device):
278 def __init__(self, config,
279 download_status_model,
280 download_queue_manager):
281 Device.__init__(self, config)
283 self.mountpoint = self._config.device_sync.device_folder
284 self.download_status_model = download_status_model
285 self.download_queue_manager = download_queue_manager
287 self.ipod = None
289 def get_free_space(self):
290 # Reserve 10 MiB for iTunesDB writing (to be on the safe side)
291 RESERVED_FOR_ITDB = 1024 * 1024 * 10
292 result = util.get_free_disk_space(self.mountpoint)
293 if result == -1:
294 # Can't get free disk space
295 return -1
296 return result - RESERVED_FOR_ITDB
298 def open(self):
299 Device.open(self)
300 if not gpod_available:
301 logger.error('Please install libgpod 0.8.3 to sync with an iPod device.')
302 return False
303 if not os.path.isdir(self.mountpoint):
304 return False
306 self.notify('status', _('Opening iPod database'))
307 self.ipod = libgpod_ctypes.iPodDatabase(self.mountpoint)
309 if not self.ipod.itdb or not self.ipod.podcasts_playlist or not self.ipod.master_playlist:
310 return False
312 self.notify('status', _('iPod opened'))
314 # build the initial tracks_list
315 self.tracks_list = self.get_all_tracks()
317 return True
319 def close(self):
320 if self.ipod is not None:
321 self.notify('status', _('Saving iPod database'))
322 self.ipod.close()
323 self.ipod = None
325 Device.close(self)
326 return True
328 def get_all_tracks(self):
329 tracks = []
330 for track in self.ipod.get_podcast_tracks():
331 filename = track.filename_on_ipod
333 if filename is None:
334 length = 0
335 modified = ''
336 else:
337 length = util.calculate_size(filename)
338 timestamp = util.file_modification_timestamp(filename)
339 modified = util.format_date(timestamp)
341 t = SyncTrack(track.episode_title, length, modified,
342 ipod_track=track,
343 playcount=track.playcount,
344 podcast=track.podcast_title)
345 tracks.append(t)
346 return tracks
348 def episode_on_device(self, episode):
349 return next((track for track in self.tracks_list
350 if track.ipod_track.podcast_rss == episode.channel.url
351 and track.ipod_track.podcast_url == episode.url), None)
353 def remove_track(self, track):
354 self.notify('status', _('Removing %s') % track.title)
355 logger.info('Removing track from iPod: %r', track.title)
356 track.ipod_track.remove_from_device()
357 try:
358 self.tracks_list.remove(next((sync_track for sync_track in self.tracks_list
359 if sync_track.ipod_track == track), None))
360 except ValueError:
363 def add_track(self, task, reporthook=None):
364 episode = task.episode
365 self.notify('status', _('Adding %s') % episode.title)
366 tracklist = self.ipod.get_podcast_tracks()
367 episode_urls = [track.podcast_url for track in tracklist]
369 if episode.url in episode_urls:
370 # Mark as played on iPod if played locally (and set podcast flags)
371 self.update_from_episode(tracklist[episode_urls.index(episode.url)], episode)
372 return True
374 local_filename = episode.local_filename(create=False)
375 # The file has to exist, if we ought to transfer it, and therefore,
376 # local_filename(create=False) must never return None as filename
377 assert local_filename is not None
379 if util.calculate_size(local_filename) > self.get_free_space():
380 logger.error('Not enough space on %s, sync aborted...', self.mountpoint)
381 d = {'episode': episode.title, 'mountpoint': self.mountpoint}
382 message = _('Error copying %(episode)s: Not enough free space on %(mountpoint)s')
383 self.errors.append(message % d)
384 self.cancelled = True
385 return False
387 (fn, extension) = os.path.splitext(local_filename)
388 if extension.lower().endswith('ogg'):
389 # XXX: Proper file extension/format support check for iPod
390 logger.error('Cannot copy .ogg files to iPod.')
391 return False
393 track = self.ipod.add_track(local_filename, episode.title, episode.channel.title,
394 episode._text_description, episode.url, episode.channel.url,
395 episode.published, get_track_length(local_filename), episode.file_type() == 'audio')
397 self.update_from_episode(track, episode, initial=True)
399 reporthook(episode.file_size, 1, episode.file_size)
401 return True
403 def update_from_episode(self, track, episode, *, initial=False):
404 if initial:
405 # Set the initial bookmark on the device based on what we have locally
406 track.initialize_bookmark(episode.is_new, episode.current_position * 1000)
407 else:
408 # Copy updated status from iPod
409 if track.playcount > 0:
410 episode.is_new = False
412 if track.bookmark_time > 0:
413 logger.info('Playback position from iPod: %s', util.format_time(track.bookmark_time / 1000))
414 episode.is_new = False
415 episode.current_position = int(track.bookmark_time / 1000)
416 episode.current_position_updated = time.time()
418 episode.save()
421 class MP3PlayerDevice(Device):
422 def __init__(self, config,
423 download_status_model,
424 download_queue_manager,
425 mount_volume_for_file):
426 Device.__init__(self, config)
428 folder = self._config.device_sync.device_folder
429 self.destination = util.new_gio_file(folder)
430 self.mount_volume_for_file = mount_volume_for_file
431 self.download_status_model = download_status_model
432 self.download_queue_manager = download_queue_manager
434 def get_free_space(self):
435 info = self.destination.query_filesystem_info(Gio.FILE_ATTRIBUTE_FILESYSTEM_FREE, None)
436 return info.get_attribute_uint64(Gio.FILE_ATTRIBUTE_FILESYSTEM_FREE)
438 def open(self):
439 Device.open(self)
440 self.notify('status', _('Opening MP3 player'))
442 if not self.mount_volume_for_file(self.destination):
443 return False
445 try:
446 info = self.destination.query_info(
447 Gio.FILE_ATTRIBUTE_ACCESS_CAN_WRITE + ","
448 + Gio.FILE_ATTRIBUTE_STANDARD_TYPE,
449 Gio.FileQueryInfoFlags.NONE,
450 None)
451 except GLib.Error as err:
452 logger.error('querying destination info for %s failed with %s',
453 self.destination.get_uri(), err.message)
454 return False
456 if info.get_file_type() != Gio.FileType.DIRECTORY:
457 logger.error('destination %s is not a directory', self.destination.get_uri())
458 return False
460 # open is ok if the target is a directory, and it can be written to
461 # for smb, query_info doesn't return FILE_ATTRIBUTE_ACCESS_CAN_WRITE,
462 # -- if that's the case, just assume that it's writable
463 if (not info.has_attribute(Gio.FILE_ATTRIBUTE_ACCESS_CAN_WRITE)
464 or info.get_attribute_boolean(Gio.FILE_ATTRIBUTE_ACCESS_CAN_WRITE)):
465 self.notify('status', _('MP3 player opened'))
466 self.tracks_list = self.get_all_tracks()
467 return True
469 logger.error('destination %s is not writable', self.destination.get_uri())
470 return False
472 def get_episode_folder_on_device(self, episode):
473 folder = episode_foldername_on_device(self._config, episode)
474 if folder:
475 folder = self.destination.get_child(folder)
476 else:
477 folder = self.destination
479 return folder
481 def get_episode_file_on_device(self, episode):
482 return episode_filename_on_device(self._config, episode)
484 def create_task(self, track):
485 return GioSyncTask(track)
487 def cancel_task(self, task):
488 task.cancellable.cancel()
490 # called by the sync task when it is removed and needs partial files cleaning up
491 def cleanup_task(self, task):
492 episode = task.episode
493 folder = self.get_episode_folder_on_device(episode)
494 file = self.get_episode_file_on_device(episode)
495 file = folder.get_child(file)
496 self.remove_track_file(file)
498 def add_track(self, task, reporthook=None):
499 episode = task.episode
500 self.notify('status', _('Adding %s') % episode.title)
502 # get the folder on the device
503 folder = self.get_episode_folder_on_device(episode)
505 filename = episode.local_filename(create=False)
506 # The file has to exist, if we ought to transfer it, and therefore,
507 # local_filename(create=False) must never return None as filename
508 assert filename is not None
510 from_file = filename
512 # verify free space
513 needed = util.calculate_size(from_file)
514 free = self.get_free_space()
515 if free == -1:
516 logger.warning('Cannot determine free disk space on device')
517 elif needed > free:
518 d = {'path': self.destination, 'free': util.format_filesize(free), 'need': util.format_filesize(needed)}
519 message = _('Not enough space in %(path)s: %(free)s available, but need at least %(need)s')
520 raise SyncFailedException(message % d)
522 # get the filename that will be used on the device
523 to_file = self.get_episode_file_on_device(episode)
524 to_file = folder.get_child(to_file)
526 util.make_directory(folder)
528 to_file_exists = to_file.query_exists()
529 from_size = episode.file_size
530 to_size = episode.file_size
531 # An interrupted sync results in a partial file on the device that must be removed to fully sync it.
532 # Comparing file size would detect such files and finish uploading.
533 # However, some devices add metadata to files, increasing their size, and forcing an upload on every sync.
534 # File size and checksum can not be used.
535 if to_file_exists and self._config.device_sync.compare_episode_filesize:
536 try:
537 info = to_file.query_info(Gio.FILE_ATTRIBUTE_STANDARD_SIZE, Gio.FileQueryInfoFlags.NONE)
538 to_size = info.get_attribute_uint64(Gio.FILE_ATTRIBUTE_STANDARD_SIZE)
539 except GLib.Error:
540 # Assume same size and don't sync again
541 pass
542 if not to_file_exists or from_size != to_size:
543 logger.info('Copying %s (%d bytes) => %s (%d bytes)',
544 os.path.basename(from_file), from_size,
545 to_file.get_uri(), to_size)
546 from_file = Gio.File.new_for_path(from_file)
547 try:
548 def hookconvert(current_bytes, total_bytes, user_data):
549 return reporthook(current_bytes, 1, total_bytes)
550 from_file.copy(to_file, Gio.FileCopyFlags.OVERWRITE, task.cancellable, hookconvert, None)
551 except GLib.Error as err:
552 if err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.CANCELLED):
553 raise SyncCancelledException()
554 logger.error('Error copying %s to %s: %s', from_file.get_uri(), to_file.get_uri(), err.message)
555 d = {'from_file': from_file.get_uri(), 'to_file': to_file.get_uri(), 'message': err.message}
556 self.errors.append(_('Error copying %(from_file)s to %(to_file)s: %(message)s') % d)
557 return False
559 return True
561 def add_sync_track(self, tracks, file, info, podcast_name):
562 (title, extension) = os.path.splitext(info.get_name())
563 timestamp = info.get_modification_time()
564 modified = util.format_date(timestamp.tv_sec)
566 t = SyncTrack(title, info.get_size(), modified,
567 filename=file.get_uri(),
568 podcast=podcast_name)
569 tracks.append(t)
571 def get_all_tracks(self):
572 tracks = []
574 attributes = (
575 Gio.FILE_ATTRIBUTE_STANDARD_NAME + ","
576 + Gio.FILE_ATTRIBUTE_STANDARD_TYPE + ","
577 + Gio.FILE_ATTRIBUTE_STANDARD_SIZE + ","
578 + Gio.FILE_ATTRIBUTE_TIME_MODIFIED)
580 root_path = self.destination
581 for path_info in root_path.enumerate_children(attributes, Gio.FileQueryInfoFlags.NONE, None):
582 if self._config.one_folder_per_podcast:
583 if path_info.get_file_type() == Gio.FileType.DIRECTORY:
584 path_file = root_path.get_child(path_info.get_name())
585 for child_info in path_file.enumerate_children(attributes, Gio.FileQueryInfoFlags.NONE, None):
586 if child_info.get_file_type() == Gio.FileType.REGULAR:
587 child_file = path_file.get_child(child_info.get_name())
588 self.add_sync_track(tracks, child_file, child_info, path_info.get_name())
590 else:
591 if path_info.get_file_type() == Gio.FileTypeFlags.REGULAR:
592 path_file = root_path.get_child(path_info.get_name())
593 self.add_sync_track(tracks, path_file, path_info, None)
594 return tracks
596 def episode_on_device(self, episode):
597 e = util.sanitize_filename(episode.sync_filename(
598 self._config.device_sync.custom_sync_name_enabled,
599 self._config.device_sync.custom_sync_name),
600 self._config.device_sync.max_filename_length)
601 return self._track_on_device(e)
603 def remove_track_file(self, file):
604 folder = file.get_parent()
605 if file.query_exists():
606 try:
607 file.delete()
608 except GLib.Error as err:
609 # if the file went away don't worry about it
610 if not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND):
611 logger.error('deleting file %s failed: %s', file.get_uri(), err.message)
612 return
614 if self._config.one_folder_per_podcast:
615 try:
616 if self.directory_is_empty(folder):
617 folder.delete()
618 except GLib.Error as err:
619 # if the folder went away don't worry about it (multiple threads could
620 # make this happen if they both notice the folder is empty simultaneously)
621 if not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND):
622 logger.error('deleting folder %s failed: %s', folder.get_uri(), err.message)
624 def remove_track(self, track):
625 self.notify('status', _('Removing %s') % track.title)
627 # get the folder on the device
628 file = Gio.File.new_for_uri(track.filename)
629 self.remove_track_file(file)
631 def directory_is_empty(self, directory):
632 for child in directory.enumerate_children(Gio.FILE_ATTRIBUTE_STANDARD_NAME, Gio.FileQueryInfoFlags.NONE, None):
633 return False
634 return True
637 class SyncCancelledException(Exception): pass
640 class SyncFailedException(Exception): pass
643 class SyncTask(download.DownloadTask):
644 # An object representing the synchronization task of an episode
646 # Possible states this sync task can be in
647 STATUS_MESSAGE = (_('Queued'), _('Queued'), _('Syncing'),
648 _('Finished'), _('Failed'), _('Cancelling'), _('Cancelled'), _('Pausing'), _('Paused'))
649 (NEW, QUEUED, DOWNLOADING, DONE, FAILED, CANCELLING, CANCELLED, PAUSING, PAUSED) = list(range(9))
651 def __str__(self):
652 return self.__episode.title
654 def __get_status(self):
655 return self.__status
657 def __set_status(self, status):
658 if status != self.__status:
659 self.__status_changed = True
660 self.__status = status
662 status = property(fget=__get_status, fset=__set_status)
664 def __get_device(self):
665 return self.__device
667 def __set_device(self, device):
668 self.__device = device
670 device = property(fget=__get_device, fset=__set_device)
672 def __get_status_changed(self):
673 if self.__status_changed:
674 self.__status_changed = False
675 return True
676 else:
677 return False
679 status_changed = property(fget=__get_status_changed)
681 def __get_activity(self):
682 return self.__activity
684 def __set_activity(self, activity):
685 self.__activity = activity
687 activity = property(fget=__get_activity, fset=__set_activity)
689 def __get_empty_string(self):
690 return ''
692 url = property(fget=__get_empty_string)
693 podcast_url = property(fget=__get_empty_string)
695 def __get_episode(self):
696 return self.__episode
698 episode = property(fget=__get_episode)
700 def can_queue(self):
701 return self.status in (self.CANCELLED, self.PAUSED, self.FAILED)
703 def can_pause(self):
704 return self.status in (self.DOWNLOADING, self.QUEUED)
706 def pause(self):
707 with self:
708 # Pause a queued download
709 if self.status == self.QUEUED:
710 self.status = self.PAUSED
711 # Request pause of a running download
712 elif self.status == self.DOWNLOADING:
713 self.status = self.PAUSING
715 def can_cancel(self):
716 return self.status in (self.DOWNLOADING, self.QUEUED, self.PAUSED, self.FAILED)
718 def cancel(self):
719 with self:
720 # Cancelling directly is allowed if the task isn't currently downloading
721 if self.status in (self.QUEUED, self.PAUSED, self.FAILED):
722 self.status = self.CANCELLED
723 # Call run, so the partial file gets deleted
724 self.run()
725 self.recycle()
726 # Otherwise request cancellation
727 elif self.status == self.DOWNLOADING:
728 self.status = self.CANCELLING
729 self.device.cancel()
731 def can_remove(self):
732 return self.status in (self.CANCELLED, self.FAILED, self.DONE)
734 def removed_from_list(self):
735 if self.status != self.DONE:
736 self.device.cleanup_task(self)
738 def __init__(self, episode):
739 self.__lock = threading.RLock()
740 self.__status = SyncTask.NEW
741 self.__activity = SyncTask.ACTIVITY_SYNCHRONIZE
742 self.__status_changed = True
743 self.__episode = episode
745 # Create the target filename and save it in the database
746 self.filename = self.__episode.local_filename(create=False)
748 self.total_size = self.__episode.file_size
749 self.speed = 0.0
750 self.progress = 0.0
751 self.error_message = None
752 self.custom_downloader = None
754 # Have we already shown this task in a notification?
755 self._notification_shown = False
757 # Variables for speed limit and speed calculation
758 self.__start_time = 0
759 self.__start_blocks = 0
760 self.__limit_rate_value = 999
761 self.__limit_rate = 999
763 # Callbacks
764 self._progress_updated = lambda x: None
766 def __enter__(self):
767 return self.__lock.acquire()
769 def __exit__(self, type, value, traceback):
770 self.__lock.release()
772 def notify_as_finished(self):
773 if self.status == SyncTask.DONE:
774 if self._notification_shown:
775 return False
776 else:
777 self._notification_shown = True
778 return True
780 return False
782 def notify_as_failed(self):
783 if self.status == SyncTask.FAILED:
784 if self._notification_shown:
785 return False
786 else:
787 self._notification_shown = True
788 return True
790 return False
792 def add_progress_callback(self, callback):
793 self._progress_updated = callback
795 def status_updated(self, count, blockSize, totalSize):
796 # We see a different "total size" while downloading,
797 # so correct the total size variable in the thread
798 if totalSize != self.total_size and totalSize > 0:
799 self.total_size = float(totalSize)
801 if self.total_size > 0:
802 self.progress = max(0.0, min(1.0, (count * blockSize) / self.total_size))
803 self._progress_updated(self.progress)
805 if self.status in (SyncTask.CANCELLING, SyncTask.PAUSING):
806 self._signal_cancel_from_status()
808 # default implementation
809 def _signal_cancel_from_status(self):
810 raise SyncCancelledException()
812 def recycle(self):
813 self.episode.download_task = None
815 def run(self):
816 # Speed calculation (re-)starts here
817 self.__start_time = 0
818 self.__start_blocks = 0
820 # If the download has already been cancelled/paused, skip it
821 with self:
822 if self.status in (SyncTask.CANCELLING, SyncTask.CANCELLED):
823 self.progress = 0.0
824 self.speed = 0.0
825 self.status = SyncTask.CANCELLED
826 return False
828 if self.status == SyncTask.PAUSING:
829 self.status = SyncTask.PAUSED
830 return False
832 # We only start this download if its status is downloading
833 if self.status != SyncTask.DOWNLOADING:
834 return False
836 # We are syncing this file right now
837 self._notification_shown = False
839 sync_result = SyncTask.DOWNLOADING
840 try:
841 logger.info('Starting SyncTask')
842 self.device.add_track(self, reporthook=self.status_updated)
843 except SyncCancelledException as e:
844 sync_result = SyncTask.CANCELLED
845 except Exception as e:
846 sync_result = SyncTask.FAILED
847 logger.error('Sync failed: %s', str(e), exc_info=True)
848 self.error_message = _('Error: %s') % (str(e),)
850 with self:
851 if sync_result == SyncTask.DOWNLOADING:
852 # Everything went well - we're done
853 self.status = SyncTask.DONE
854 if self.total_size <= 0:
855 self.total_size = util.calculate_size(self.filename)
856 logger.info('Total size updated to %d', self.total_size)
857 self.progress = 1.0
858 gpodder.user_extensions.on_episode_synced(self.device, self.__episode)
859 return True
861 self.speed = 0.0
863 if sync_result == SyncTask.FAILED:
864 self.status = SyncTask.FAILED
866 # cancelled/paused -- update state to mark it as safe to manipulate this task again
867 elif self.status == SyncTask.PAUSING:
868 self.status = SyncTask.PAUSED
869 elif self.status == SyncTask.CANCELLING:
870 self.status = SyncTask.CANCELLED
872 # We finished, but not successfully (at least not really)
873 return False
876 class GioSyncTask(SyncTask):
877 def __init__(self, episode):
878 super().__init__(episode)
879 # For cancelling the copy
880 self.cancellable = Gio.Cancellable()
882 def _signal_cancel_from_status(self):
883 self.cancellable.cancel()