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
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__
)
46 from gpodder
import libgpod_ctypes
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
57 logger
.info('eyeD3 MP3 not available')
58 eyed3mp3_available
= False
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
)
77 def get_track_length(filename
):
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)
85 logger
.error('MPlayer could not determine length: %s', filename
, exc_info
=True)
88 if eyed3mp3_available
:
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.
94 logger
.warning('eyed3.mp3 successfully determined length: %s', filename
)
97 logger
.error('eyed3.mp3 could not determine length: %s', filename
, exc_info
=True)
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
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
)
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
)
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
):
168 self
.filesize
= util
.format_filesize(length
)
169 self
.modified
= modified
171 # Set some (possible) keyword arguments to default values
175 # Convert keyword arguments to object attributes
176 self
.__dict
__.update(kwargs
)
179 return 'SyncTrack(title={}, podcast={})'.format(self
.title
, self
.podcast
)
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']
192 self
.tracks_list
= []
193 signals
= ['progress', 'sub-progress', 'status', 'done', 'post-done']
194 services
.ObservableService
.__init
__(self
, signals
)
200 self
.cancelled
= True
201 self
.notify('status', _('Cancelled by user'))
204 self
.notify('status', _('Writing data to disk'))
205 if self
._config
.device_sync
.after_sync
.sync_disks
and not gpodder
.ui
.win32
:
208 logger
.warning('Not syncing disks. Unmount your device before unplugging.')
211 def create_task(self
, track
):
212 return SyncTask(track
)
214 def cancel_task(self
, task
):
217 def cleanup_task(self
, task
):
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
229 tracklist
.remove(track
)
230 elif exclude_played
or wrong_type
:
231 logger
.info('Excluding %s from sync', track
.title
)
232 tracklist
.remove(track
)
235 for track
in sorted(tracklist
, key
=lambda e
: e
.pubdate_prop
):
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
)
249 logger
.warning("No episodes to sync")
254 def get_all_tracks(self
):
257 def add_track(self
, track
, reporthook
=None):
260 def remove_track(self
, track
):
263 def get_free_space(self
):
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
:
272 if track_name
== title
:
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
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
)
294 # Can't get free disk space
296 return result
- RESERVED_FOR_ITDB
300 if not gpod_available
:
301 logger
.error('Please install libgpod 0.8.3 to sync with an iPod device.')
303 if not os
.path
.isdir(self
.mountpoint
):
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
:
312 self
.notify('status', _('iPod opened'))
314 # build the initial tracks_list
315 self
.tracks_list
= self
.get_all_tracks()
320 if self
.ipod
is not None:
321 self
.notify('status', _('Saving iPod database'))
328 def get_all_tracks(self
):
330 for track
in self
.ipod
.get_podcast_tracks():
331 filename
= track
.filename_on_ipod
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
,
343 playcount
=track
.playcount
,
344 podcast
=track
.podcast_title
)
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()
358 self
.tracks_list
.remove(next((sync_track
for sync_track
in self
.tracks_list
359 if sync_track
.ipod_track
== track
), None))
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
)
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
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.')
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
)
403 def update_from_episode(self
, track
, episode
, *, initial
=False):
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)
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()
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
)
440 self
.notify('status', _('Opening MP3 player'))
442 if not self
.mount_volume_for_file(self
.destination
):
446 info
= self
.destination
.query_info(
447 Gio
.FILE_ATTRIBUTE_ACCESS_CAN_WRITE
+ ","
448 + Gio
.FILE_ATTRIBUTE_STANDARD_TYPE
,
449 Gio
.FileQueryInfoFlags
.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
)
456 if info
.get_file_type() != Gio
.FileType
.DIRECTORY
:
457 logger
.error('destination %s is not a directory', self
.destination
.get_uri())
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()
469 logger
.error('destination %s is not writable', self
.destination
.get_uri())
472 def get_episode_folder_on_device(self
, episode
):
473 folder
= episode_foldername_on_device(self
._config
, episode
)
475 folder
= self
.destination
.get_child(folder
)
477 folder
= self
.destination
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
513 needed
= util
.calculate_size(from_file
)
514 free
= self
.get_free_space()
516 logger
.warning('Cannot determine free disk space on device')
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
:
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
)
540 # Assume same size and don't sync again
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
)
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
)
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
)
571 def get_all_tracks(self
):
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())
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
)
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)
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():
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
)
617 if self
._config
.one_folder_per_podcast
:
619 if self
.directory_is_empty(folder
):
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):
640 class SyncCancelledException(Exception):
644 class SyncFailedException(Exception):
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))
657 return self
.__episode
.title
659 def __get_status(self
):
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
):
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
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
):
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
)
706 return self
.status
in (self
.CANCELLED
, self
.PAUSED
, self
.FAILED
)
709 return self
.status
in (self
.DOWNLOADING
, self
.QUEUED
)
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
)
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
731 # Otherwise request cancellation
732 elif self
.status
== self
.DOWNLOADING
:
733 self
.status
= self
.CANCELLING
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
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
769 self
._progress
_updated
= lambda x
: None
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
:
782 self
._notification
_shown
= True
787 def notify_as_failed(self
):
788 if self
.status
== SyncTask
.FAILED
:
789 if self
._notification
_shown
:
792 self
._notification
_shown
= True
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()
818 self
.episode
.download_task
= None
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
827 if self
.status
in (SyncTask
.CANCELLING
, SyncTask
.CANCELLED
):
830 self
.status
= SyncTask
.CANCELLED
833 if self
.status
== SyncTask
.PAUSING
:
834 self
.status
= SyncTask
.PAUSED
837 # We only start this download if its status is downloading
838 if self
.status
!= SyncTask
.DOWNLOADING
:
841 # We are syncing this file right now
842 self
._notification
_shown
= False
844 sync_result
= SyncTask
.DOWNLOADING
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
),)
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
)
863 gpodder
.user_extensions
.on_episode_synced(self
.device
, self
.__episode
)
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)
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()