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())
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())
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)
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():
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
)
614 if self
._config
.one_folder_per_podcast
:
616 if self
.directory_is_empty(folder
):
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):
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))
652 return self
.__episode
.title
654 def __get_status(self
):
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
):
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
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
):
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
)
701 return self
.status
in (self
.CANCELLED
, self
.PAUSED
, self
.FAILED
)
704 return self
.status
in (self
.DOWNLOADING
, self
.QUEUED
)
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
)
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
726 # Otherwise request cancellation
727 elif self
.status
== self
.DOWNLOADING
:
728 self
.status
= self
.CANCELLING
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
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
764 self
._progress
_updated
= lambda x
: None
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
:
777 self
._notification
_shown
= True
782 def notify_as_failed(self
):
783 if self
.status
== SyncTask
.FAILED
:
784 if self
._notification
_shown
:
787 self
._notification
_shown
= True
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()
813 self
.episode
.download_task
= None
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
822 if self
.status
in (SyncTask
.CANCELLING
, SyncTask
.CANCELLED
):
825 self
.status
= SyncTask
.CANCELLED
828 if self
.status
== SyncTask
.PAUSING
:
829 self
.status
= SyncTask
.PAUSED
832 # We only start this download if its status is downloading
833 if self
.status
!= SyncTask
.DOWNLOADING
:
836 # We are syncing this file right now
837 self
._notification
_shown
= False
839 sync_result
= SyncTask
.DOWNLOADING
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
),)
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
)
858 gpodder
.user_extensions
.on_episode_synced(self
.device
, self
.__episode
)
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)
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()