SendTo automatic rename filename (#1620)
[gpodder.git] / src / gpodder / sync.py
blob1750876d46abb8e98e83fc1c2800c10fc178e3d5
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 try:
586 for child_info in path_file.enumerate_children(attributes, Gio.FileQueryInfoFlags.NONE, None):
587 if child_info.get_file_type() == Gio.FileType.REGULAR:
588 child_file = path_file.get_child(child_info.get_name())
589 self.add_sync_track(tracks, child_file, child_info, path_info.get_name())
590 except GLib.Error as err:
591 logger.error('get all tracks for %s failed: %s', path_file.get_uri(), err.message)
593 else:
594 if path_info.get_file_type() == Gio.FileTypeFlags.REGULAR:
595 path_file = root_path.get_child(path_info.get_name())
596 self.add_sync_track(tracks, path_file, path_info, None)
597 return tracks
599 def episode_on_device(self, episode):
600 e = util.sanitize_filename(episode.sync_filename(
601 self._config.device_sync.custom_sync_name_enabled,
602 self._config.device_sync.custom_sync_name),
603 self._config.device_sync.max_filename_length)
604 return self._track_on_device(e)
606 def remove_track_file(self, file):
607 folder = file.get_parent()
608 if file.query_exists():
609 try:
610 file.delete()
611 except GLib.Error as err:
612 # if the file went away don't worry about it
613 if not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND):
614 logger.error('deleting file %s failed: %s', file.get_uri(), err.message)
615 return
617 if self._config.one_folder_per_podcast:
618 try:
619 if self.directory_is_empty(folder):
620 folder.delete()
621 except GLib.Error as err:
622 # if the folder went away don't worry about it (multiple threads could
623 # make this happen if they both notice the folder is empty simultaneously)
624 if not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND):
625 logger.error('deleting folder %s failed: %s', folder.get_uri(), err.message)
627 def remove_track(self, track):
628 self.notify('status', _('Removing %s') % track.title)
630 # get the folder on the device
631 file = Gio.File.new_for_uri(track.filename)
632 self.remove_track_file(file)
634 def directory_is_empty(self, directory):
635 for child in directory.enumerate_children(Gio.FILE_ATTRIBUTE_STANDARD_NAME, Gio.FileQueryInfoFlags.NONE, None):
636 return False
637 return True
640 class SyncCancelledException(Exception):
641 pass
644 class SyncFailedException(Exception):
645 pass
648 class SyncTask(download.DownloadTask):
649 # An object representing the synchronization task of an episode
651 # Possible states this sync task can be in
652 STATUS_MESSAGE = (_('Queued'), _('Queued'), _('Syncing'),
653 _('Finished'), _('Failed'), _('Cancelling'), _('Cancelled'), _('Pausing'), _('Paused'))
654 (NEW, QUEUED, DOWNLOADING, DONE, FAILED, CANCELLING, CANCELLED, PAUSING, PAUSED) = list(range(9))
656 def __str__(self):
657 return self.__episode.title
659 def __get_status(self):
660 return self.__status
662 def __set_status(self, status):
663 if status != self.__status:
664 self.__status_changed = True
665 self.__status = status
667 status = property(fget=__get_status, fset=__set_status)
669 def __get_device(self):
670 return self.__device
672 def __set_device(self, device):
673 self.__device = device
675 device = property(fget=__get_device, fset=__set_device)
677 def __get_status_changed(self):
678 if self.__status_changed:
679 self.__status_changed = False
680 return True
681 else:
682 return False
684 status_changed = property(fget=__get_status_changed)
686 def __get_activity(self):
687 return self.__activity
689 def __set_activity(self, activity):
690 self.__activity = activity
692 activity = property(fget=__get_activity, fset=__set_activity)
694 def __get_empty_string(self):
695 return ''
697 url = property(fget=__get_empty_string)
698 podcast_url = property(fget=__get_empty_string)
700 def __get_episode(self):
701 return self.__episode
703 episode = property(fget=__get_episode)
705 def can_queue(self):
706 return self.status in (self.CANCELLED, self.PAUSED, self.FAILED)
708 def can_pause(self):
709 return self.status in (self.DOWNLOADING, self.QUEUED)
711 def pause(self):
712 with self:
713 # Pause a queued download
714 if self.status == self.QUEUED:
715 self.status = self.PAUSED
716 # Request pause of a running download
717 elif self.status == self.DOWNLOADING:
718 self.status = self.PAUSING
720 def can_cancel(self):
721 return self.status in (self.DOWNLOADING, self.QUEUED, self.PAUSED, self.FAILED)
723 def cancel(self):
724 with self:
725 # Cancelling directly is allowed if the task isn't currently downloading
726 if self.status in (self.QUEUED, self.PAUSED, self.FAILED):
727 self.status = self.CANCELLED
728 # Call run, so the partial file gets deleted
729 self.run()
730 self.recycle()
731 # Otherwise request cancellation
732 elif self.status == self.DOWNLOADING:
733 self.status = self.CANCELLING
734 self.device.cancel()
736 def can_remove(self):
737 return self.status in (self.CANCELLED, self.FAILED, self.DONE)
739 def removed_from_list(self):
740 if self.status != self.DONE:
741 self.device.cleanup_task(self)
743 def __init__(self, episode):
744 self.__lock = threading.RLock()
745 self.__status = SyncTask.NEW
746 self.__activity = SyncTask.ACTIVITY_SYNCHRONIZE
747 self.__status_changed = True
748 self.__episode = episode
750 # Create the target filename and save it in the database
751 self.filename = self.__episode.local_filename(create=False)
753 self.total_size = self.__episode.file_size
754 self.speed = 0.0
755 self.progress = 0.0
756 self.error_message = None
757 self.custom_downloader = None
759 # Have we already shown this task in a notification?
760 self._notification_shown = False
762 # Variables for speed limit and speed calculation
763 self.__start_time = 0
764 self.__start_blocks = 0
765 self.__limit_rate_value = 999
766 self.__limit_rate = 999
768 # Callbacks
769 self._progress_updated = lambda x: None
771 def __enter__(self):
772 return self.__lock.acquire()
774 def __exit__(self, exception_type, value, traceback):
775 self.__lock.release()
777 def notify_as_finished(self):
778 if self.status == SyncTask.DONE:
779 if self._notification_shown:
780 return False
781 else:
782 self._notification_shown = True
783 return True
785 return False
787 def notify_as_failed(self):
788 if self.status == SyncTask.FAILED:
789 if self._notification_shown:
790 return False
791 else:
792 self._notification_shown = True
793 return True
795 return False
797 def add_progress_callback(self, callback):
798 self._progress_updated = callback
800 def status_updated(self, count, blockSize, totalSize):
801 # We see a different "total size" while downloading,
802 # so correct the total size variable in the thread
803 if totalSize != self.total_size and totalSize > 0:
804 self.total_size = float(totalSize)
806 if self.total_size > 0:
807 self.progress = max(0.0, min(1.0, (count * blockSize) / self.total_size))
808 self._progress_updated(self.progress)
810 if self.status in (SyncTask.CANCELLING, SyncTask.PAUSING):
811 self._signal_cancel_from_status()
813 # default implementation
814 def _signal_cancel_from_status(self):
815 raise SyncCancelledException()
817 def recycle(self):
818 self.episode.download_task = None
820 def run(self):
821 # Speed calculation (re-)starts here
822 self.__start_time = 0
823 self.__start_blocks = 0
825 # If the download has already been cancelled/paused, skip it
826 with self:
827 if self.status in (SyncTask.CANCELLING, SyncTask.CANCELLED):
828 self.progress = 0.0
829 self.speed = 0.0
830 self.status = SyncTask.CANCELLED
831 return False
833 if self.status == SyncTask.PAUSING:
834 self.status = SyncTask.PAUSED
835 return False
837 # We only start this download if its status is downloading
838 if self.status != SyncTask.DOWNLOADING:
839 return False
841 # We are syncing this file right now
842 self._notification_shown = False
844 sync_result = SyncTask.DOWNLOADING
845 try:
846 logger.info('Starting SyncTask')
847 self.device.add_track(self, reporthook=self.status_updated)
848 except SyncCancelledException:
849 sync_result = SyncTask.CANCELLED
850 except Exception as e:
851 sync_result = SyncTask.FAILED
852 logger.error('Sync failed: %s', str(e), exc_info=True)
853 self.error_message = _('Error: %s') % (str(e),)
855 with self:
856 if sync_result == SyncTask.DOWNLOADING:
857 # Everything went well - we're done
858 self.status = SyncTask.DONE
859 if self.total_size <= 0:
860 self.total_size = util.calculate_size(self.filename)
861 logger.info('Total size updated to %d', self.total_size)
862 self.progress = 1.0
863 gpodder.user_extensions.on_episode_synced(self.device, self.__episode)
864 return True
866 self.speed = 0.0
868 if sync_result == SyncTask.FAILED:
869 self.status = SyncTask.FAILED
871 # cancelled/paused -- update state to mark it as safe to manipulate this task again
872 elif self.status == SyncTask.PAUSING:
873 self.status = SyncTask.PAUSED
874 elif self.status == SyncTask.CANCELLING:
875 self.status = SyncTask.CANCELLED
877 # We finished, but not successfully (at least not really)
878 return False
881 class GioSyncTask(SyncTask):
882 def __init__(self, episode):
883 super().__init__(episode)
884 # For cancelling the copy
885 self.cancellable = Gio.Cancellable()
887 def _signal_cancel_from_status(self):
888 self.cancellable.cancel()