Proper single-time download notifications (bug 1161)
[gpodder.git] / src / gpodder / download.py
blobc8302d318f917c3e26c8c829f00d35fd6b8617bf
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2010 Thomas Perl and 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/>.
22 # download.py -- Download queue management
23 # Thomas Perl <thp@perli.net> 2007-09-15
25 # Based on libwget.py (2005-10-29)
28 from __future__ import with_statement
30 from gpodder.liblogger import log
31 from gpodder import util
32 from gpodder import youtube
33 import gpodder
35 import threading
36 import urllib
37 import urlparse
38 import shutil
39 import os.path
40 import os
41 import time
42 import collections
44 import mimetypes
45 import email
46 import email.Header
48 from xml.sax import saxutils
50 _ = gpodder.gettext
52 def get_header_param(headers, param, header_name):
53 """Extract a HTTP header parameter from a dict
55 Uses the "email" module to retrieve parameters
56 from HTTP headers. This can be used to get the
57 "filename" parameter of the "content-disposition"
58 header for downloads to pick a good filename.
60 Returns None if the filename cannot be retrieved.
61 """
62 try:
63 headers_string = ['%s:%s'%(k,v) for k,v in headers.items()]
64 msg = email.message_from_string('\n'.join(headers_string))
65 if header_name in msg:
66 value = msg.get_param(param, header=header_name)
67 if value is None:
68 return None
69 decoded_list = email.Header.decode_header(value)
70 value = []
71 for part, encoding in decoded_list:
72 if encoding:
73 value.append(part.decode(encoding))
74 else:
75 value.append(unicode(part))
76 return u''.join(value)
77 except Exception, e:
78 log('Error trying to get %s from %s: %s', \
79 param, header_name, str(e), traceback=True)
81 return None
83 class ContentRange(object):
84 # Based on:
85 # http://svn.pythonpaste.org/Paste/WebOb/trunk/webob/byterange.py
87 # Copyright (c) 2007 Ian Bicking and Contributors
89 # Permission is hereby granted, free of charge, to any person obtaining
90 # a copy of this software and associated documentation files (the
91 # "Software"), to deal in the Software without restriction, including
92 # without limitation the rights to use, copy, modify, merge, publish,
93 # distribute, sublicense, and/or sell copies of the Software, and to
94 # permit persons to whom the Software is furnished to do so, subject to
95 # the following conditions:
97 # The above copyright notice and this permission notice shall be
98 # included in all copies or substantial portions of the Software.
100 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
101 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
102 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
103 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
104 # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
105 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
106 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
108 Represents the Content-Range header
110 This header is ``start-stop/length``, where stop and length can be
111 ``*`` (represented as None in the attributes).
114 def __init__(self, start, stop, length):
115 assert start >= 0, "Bad start: %r" % start
116 assert stop is None or (stop >= 0 and stop >= start), (
117 "Bad stop: %r" % stop)
118 self.start = start
119 self.stop = stop
120 self.length = length
122 def __repr__(self):
123 return '<%s %s>' % (
124 self.__class__.__name__,
125 self)
127 def __str__(self):
128 if self.stop is None:
129 stop = '*'
130 else:
131 stop = self.stop + 1
132 if self.length is None:
133 length = '*'
134 else:
135 length = self.length
136 return 'bytes %s-%s/%s' % (self.start, stop, length)
138 def __iter__(self):
140 Mostly so you can unpack this, like:
142 start, stop, length = res.content_range
144 return iter([self.start, self.stop, self.length])
146 @classmethod
147 def parse(cls, value):
149 Parse the header. May return None if it cannot parse.
151 if value is None:
152 return None
153 value = value.strip()
154 if not value.startswith('bytes '):
155 # Unparseable
156 return None
157 value = value[len('bytes '):].strip()
158 if '/' not in value:
159 # Invalid, no length given
160 return None
161 range, length = value.split('/', 1)
162 if '-' not in range:
163 # Invalid, no range
164 return None
165 start, end = range.split('-', 1)
166 try:
167 start = int(start)
168 if end == '*':
169 end = None
170 else:
171 end = int(end)
172 if length == '*':
173 length = None
174 else:
175 length = int(length)
176 except ValueError:
177 # Parse problem
178 return None
179 if end is None:
180 return cls(start, None, length)
181 else:
182 return cls(start, end-1, length)
185 class DownloadCancelledException(Exception): pass
186 class AuthenticationError(Exception): pass
188 class gPodderDownloadHTTPError(Exception):
189 def __init__(self, url, error_code, error_message):
190 self.url = url
191 self.error_code = error_code
192 self.error_message = error_message
194 class DownloadURLOpener(urllib.FancyURLopener):
195 version = gpodder.user_agent
197 # Sometimes URLs are not escaped correctly - try to fix them
198 # (see RFC2396; Section 2.4.3. Excluded US-ASCII Characters)
199 # FYI: The omission of "%" in the list is to avoid double escaping!
200 ESCAPE_CHARS = dict((ord(c), u'%%%x'%ord(c)) for c in u' <>#"{}|\\^[]`')
202 def __init__( self, channel):
203 self.channel = channel
204 self._auth_retry_counter = 0
205 urllib.FancyURLopener.__init__(self, None)
207 def http_error_default(self, url, fp, errcode, errmsg, headers):
209 FancyURLopener by default does not raise an exception when
210 there is some unknown HTTP error code. We want to override
211 this and provide a function to log the error and raise an
212 exception, so we don't download the HTTP error page here.
214 # The following two lines are copied from urllib.URLopener's
215 # implementation of http_error_default
216 void = fp.read()
217 fp.close()
218 raise gPodderDownloadHTTPError(url, errcode, errmsg)
220 def redirect_internal(self, url, fp, errcode, errmsg, headers, data):
221 """ This is the exact same function that's included with urllib
222 except with "void = fp.read()" commented out. """
224 if 'location' in headers:
225 newurl = headers['location']
226 elif 'uri' in headers:
227 newurl = headers['uri']
228 else:
229 return
231 # This blocks forever(?) with certain servers (see bug #465)
232 #void = fp.read()
233 fp.close()
235 # In case the server sent a relative URL, join with original:
236 newurl = urlparse.urljoin(self.type + ":" + url, newurl)
237 return self.open(newurl)
239 # The following is based on Python's urllib.py "URLopener.retrieve"
240 # Also based on http://mail.python.org/pipermail/python-list/2001-October/110069.html
242 def http_error_206(self, url, fp, errcode, errmsg, headers, data=None):
243 # The next line is taken from urllib's URLopener.open_http
244 # method, at the end after the line "if errcode == 200:"
245 return urllib.addinfourl(fp, headers, 'http:' + url)
247 def retrieve_resume(self, url, filename, reporthook=None, data=None):
248 """Download files from an URL; return (headers, real_url)
250 Resumes a download if the local filename exists and
251 the server supports download resuming.
254 current_size = 0
255 tfp = None
256 if os.path.exists(filename):
257 try:
258 current_size = os.path.getsize(filename)
259 tfp = open(filename, 'ab')
260 #If the file exists, then only download the remainder
261 if current_size > 0:
262 self.addheader('Range', 'bytes=%s-' % (current_size))
263 except:
264 log('Cannot open file for resuming: %s', filename, sender=self, traceback=True)
265 tfp = None
266 current_size = 0
268 if tfp is None:
269 tfp = open(filename, 'wb')
271 # Fix a problem with bad URLs that are not encoded correctly (bug 549)
272 url = url.decode('ascii', 'ignore')
273 url = url.translate(self.ESCAPE_CHARS)
274 url = url.encode('ascii')
276 url = urllib.unwrap(urllib.toBytes(url))
277 fp = self.open(url, data)
278 headers = fp.info()
280 if current_size > 0:
281 # We told the server to resume - see if she agrees
282 # See RFC2616 (206 Partial Content + Section 14.16)
283 # XXX check status code here, too...
284 range = ContentRange.parse(headers.get('content-range', ''))
285 if range is None or range.start != current_size:
286 # Ok, that did not work. Reset the download
287 # TODO: seek and truncate if content-range differs from request
288 tfp.close()
289 tfp = open(filename, 'wb')
290 current_size = 0
291 log('Cannot resume. Missing or wrong Content-Range header (RFC2616)', sender=self)
293 result = headers, fp.geturl()
294 bs = 1024*8
295 size = -1
296 read = current_size
297 blocknum = int(current_size/bs)
298 if reporthook:
299 if "content-length" in headers:
300 size = int(headers.getrawheader("Content-Length")) + current_size
301 reporthook(blocknum, bs, size)
302 while read < size or size == -1:
303 if size == -1:
304 block = fp.read(bs)
305 else:
306 block = fp.read(min(size-read, bs))
307 if block == "":
308 break
309 read += len(block)
310 tfp.write(block)
311 blocknum += 1
312 if reporthook:
313 reporthook(blocknum, bs, size)
314 fp.close()
315 tfp.close()
316 del fp
317 del tfp
319 # raise exception if actual size does not match content-length header
320 if size >= 0 and read < size:
321 raise urllib.ContentTooShortError("retrieval incomplete: got only %i out "
322 "of %i bytes" % (read, size), result)
324 return result
326 # end code based on urllib.py
328 def prompt_user_passwd( self, host, realm):
329 # Keep track of authentication attempts, fail after the third one
330 self._auth_retry_counter += 1
331 if self._auth_retry_counter > 3:
332 raise AuthenticationError(_('Wrong username/password'))
334 if self.channel.username or self.channel.password:
335 log( 'Authenticating as "%s" to "%s" for realm "%s".', self.channel.username, host, realm, sender = self)
336 return ( self.channel.username, self.channel.password )
338 return (None, None)
341 class DownloadQueueWorker(threading.Thread):
342 def __init__(self, queue, exit_callback, continue_check_callback, minimum_tasks):
343 threading.Thread.__init__(self)
344 self.queue = queue
345 self.exit_callback = exit_callback
346 self.continue_check_callback = continue_check_callback
348 # The minimum amount of tasks that should be downloaded by this worker
349 # before using the continue_check_callback to determine if it might
350 # continue accepting tasks. This can be used to forcefully start a
351 # download, even if a download limit is in effect.
352 self.minimum_tasks = minimum_tasks
354 def run(self):
355 log('Running new thread: %s', self.getName(), sender=self)
356 while True:
357 # Check if this thread is allowed to continue accepting tasks
358 # (But only after reducing minimum_tasks to zero - see above)
359 if self.minimum_tasks > 0:
360 self.minimum_tasks -= 1
361 elif not self.continue_check_callback(self):
362 log('%s must not accept new tasks.', self.getName(), sender=self)
363 return
365 try:
366 task = self.queue.pop()
367 log('%s is processing: %s', self.getName(), task, sender=self)
368 task.run()
369 except IndexError, e:
370 log('No more tasks for %s to carry out.', self.getName(), sender=self)
371 break
372 self.exit_callback(self)
375 class DownloadQueueManager(object):
376 def __init__(self, config):
377 self._config = config
378 self.tasks = collections.deque()
380 self.worker_threads_access = threading.RLock()
381 self.worker_threads = []
383 def __exit_callback(self, worker_thread):
384 with self.worker_threads_access:
385 self.worker_threads.remove(worker_thread)
387 def __continue_check_callback(self, worker_thread):
388 with self.worker_threads_access:
389 if len(self.worker_threads) > self._config.max_downloads and \
390 self._config.max_downloads_enabled:
391 self.worker_threads.remove(worker_thread)
392 return False
393 else:
394 return True
396 def spawn_threads(self, force_start=False):
397 """Spawn new worker threads if necessary
399 If force_start is True, forcefully spawn a thread and
400 let it process at least one episodes, even if a download
401 limit is in effect at the moment.
403 with self.worker_threads_access:
404 if not len(self.tasks):
405 return
407 if force_start or len(self.worker_threads) == 0 or \
408 len(self.worker_threads) < self._config.max_downloads or \
409 not self._config.max_downloads_enabled:
410 # We have to create a new thread here, there's work to do
411 log('I am going to spawn a new worker thread.', sender=self)
413 # The new worker should process at least one task (the one
414 # that we want to forcefully start) if force_start is True.
415 if force_start:
416 minimum_tasks = 1
417 else:
418 minimum_tasks = 0
420 worker = DownloadQueueWorker(self.tasks, self.__exit_callback, \
421 self.__continue_check_callback, minimum_tasks)
422 self.worker_threads.append(worker)
423 worker.start()
425 def are_queued_or_active_tasks(self):
426 with self.worker_threads_access:
427 return len(self.worker_threads) > 0
429 def add_task(self, task, force_start=False):
430 """Add a new task to the download queue
432 If force_start is True, ignore the download limit
433 and forcefully start the download right away.
435 if task.status != DownloadTask.INIT:
436 # This task is old so update episode from db
437 task.episode.reload_from_db()
439 # Remove the task from its current position in the
440 # download queue (if any) to avoid race conditions
441 # where two worker threads download the same file
442 try:
443 self.tasks.remove(task)
444 except ValueError, e:
445 pass
446 task.status = DownloadTask.QUEUED
447 if force_start:
448 # Add the task to be taken on next pop
449 self.tasks.append(task)
450 else:
451 # Add the task to the end of the queue
452 self.tasks.appendleft(task)
453 self.spawn_threads(force_start)
456 class DownloadTask(object):
457 """An object representing the download task of an episode
459 You can create a new download task like this:
461 task = DownloadTask(episode, gpodder.config.Config(CONFIGFILE))
462 task.status = DownloadTask.QUEUED
463 task.run()
465 While the download is in progress, you can access its properties:
467 task.total_size # in bytes
468 task.progress # from 0.0 to 1.0
469 task.speed # in bytes per second
470 str(task) # name of the episode
471 task.status # current status
472 task.status_changed # True if the status has been changed (see below)
473 task.url # URL of the episode being downloaded
474 task.podcast_url # URL of the podcast this download belongs to
476 You can cancel a running download task by setting its status:
478 task.status = DownloadTask.CANCELLED
480 The task will then abort as soon as possible (due to the nature
481 of downloading data, this can take a while when the Internet is
482 busy).
484 The "status_changed" attribute gets set to True everytime the
485 "status" attribute changes its value. After you get the value of
486 the "status_changed" attribute, it is always reset to False:
488 if task.status_changed:
489 new_status = task.status
490 # .. update the UI accordingly ..
492 Obviously, this also means that you must have at most *one*
493 place in your UI code where you check for status changes and
494 broadcast the status updates from there.
496 While the download is taking place and after the .run() method
497 has finished, you can get the final status to check if the download
498 was successful:
500 if task.status == DownloadTask.DONE:
501 # .. everything ok ..
502 elif task.status == DownloadTask.FAILED:
503 # .. an error happened, and the
504 # error_message attribute is set ..
505 print task.error_message
506 elif task.status == DownloadTask.PAUSED:
507 # .. user paused the download ..
508 elif task.status == DownloadTask.CANCELLED:
509 # .. user cancelled the download ..
511 The difference between cancelling and pausing a DownloadTask is
512 that the temporary file gets deleted when cancelling, but does
513 not get deleted when pausing.
515 Be sure to call .removed_from_list() on this task when removing
516 it from the UI, so that it can carry out any pending clean-up
517 actions (e.g. removing the temporary file when the task has not
518 finished successfully; i.e. task.status != DownloadTask.DONE).
520 The UI can call the method "notify_as_finished()" to determine if
521 this episode still has still to be shown as "finished" download
522 in a notification window. This will return True only the first time
523 it is called when the status is DONE. After returning True once,
524 it will always return False afterwards.
526 The same thing works for failed downloads ("notify_as_failed()").
528 # Possible states this download task can be in
529 STATUS_MESSAGE = (_('Added'), _('Queued'), _('Downloading'),
530 _('Finished'), _('Failed'), _('Cancelled'), _('Paused'))
531 (INIT, QUEUED, DOWNLOADING, DONE, FAILED, CANCELLED, PAUSED) = range(7)
533 def __str__(self):
534 return self.__episode.title
536 def __get_status(self):
537 return self.__status
539 def __set_status(self, status):
540 if status != self.__status:
541 self.__status_changed = True
542 self.__status = status
544 status = property(fget=__get_status, fset=__set_status)
546 def __get_status_changed(self):
547 if self.__status_changed:
548 self.__status_changed = False
549 return True
550 else:
551 return False
553 status_changed = property(fget=__get_status_changed)
555 def __get_url(self):
556 return self.__episode.url
558 url = property(fget=__get_url)
560 def __get_podcast_url(self):
561 return self.__episode.channel.url
563 podcast_url = property(fget=__get_podcast_url)
565 def __get_episode(self):
566 return self.__episode
568 episode = property(fget=__get_episode)
570 def removed_from_list(self):
571 if self.status != self.DONE:
572 util.delete_file(self.tempname)
574 def __init__(self, episode, config):
575 self.__status = DownloadTask.INIT
576 self.__status_changed = True
577 self.__episode = episode
578 self._config = config
580 # Set names for the downloads list
581 self.markup_name = saxutils.escape(self.__episode.title)
582 self.markup_podcast_name = saxutils.escape(self.__episode.channel.title)
584 # Create the target filename and save it in the database
585 self.filename = self.__episode.local_filename(create=True)
586 self.tempname = self.filename + '.partial'
588 self.total_size = self.__episode.length
589 self.speed = 0.0
590 self.progress = 0.0
591 self.error_message = None
593 # Have we already shown this task in a notification?
594 self._notification_shown = False
596 # Variables for speed limit and speed calculation
597 self.__start_time = 0
598 self.__start_blocks = 0
599 self.__limit_rate_value = self._config.limit_rate_value
600 self.__limit_rate = self._config.limit_rate
602 # Callbacks
603 self._progress_updated = lambda x: None
605 # If the tempname already exists, set progress accordingly
606 if os.path.exists(self.tempname):
607 try:
608 already_downloaded = os.path.getsize(self.tempname)
609 if self.total_size > 0:
610 self.progress = max(0.0, min(1.0, float(already_downloaded)/self.total_size))
611 except OSError, os_error:
612 log('Error while getting size for existing file: %s', os_error, sender=self)
613 else:
614 # "touch self.tempname", so we also get partial
615 # files for resuming when the file is queued
616 open(self.tempname, 'w').close()
618 def notify_as_finished(self):
619 if self.status == DownloadTask.DONE:
620 if self._notification_shown:
621 return False
622 else:
623 self._notification_shown = True
624 return True
626 return False
628 def notify_as_failed(self):
629 if self.status == DownloadTask.FAILED:
630 if self._notification_shown:
631 return False
632 else:
633 self._notification_shown = True
634 return True
636 return False
638 def add_progress_callback(self, callback):
639 self._progress_updated = callback
641 def status_updated(self, count, blockSize, totalSize):
642 # We see a different "total size" while downloading,
643 # so correct the total size variable in the thread
644 if totalSize != self.total_size and totalSize > 0:
645 self.total_size = float(totalSize)
647 if self.total_size > 0:
648 self.progress = max(0.0, min(1.0, float(count*blockSize)/self.total_size))
649 self._progress_updated(self.progress)
651 self.calculate_speed(count, blockSize)
653 if self.status == DownloadTask.CANCELLED:
654 raise DownloadCancelledException()
656 if self.status == DownloadTask.PAUSED:
657 raise DownloadCancelledException()
659 def calculate_speed(self, count, blockSize):
660 if count % 5 == 0:
661 now = time.time()
662 if self.__start_time > 0:
663 # Has rate limiting been enabled or disabled?
664 if self.__limit_rate != self._config.limit_rate:
665 # If it has been enabled then reset base time and block count
666 if self._config.limit_rate:
667 self.__start_time = now
668 self.__start_blocks = count
669 self.__limit_rate = self._config.limit_rate
671 # Has the rate been changed and are we currently limiting?
672 if self.__limit_rate_value != self._config.limit_rate_value and self.__limit_rate:
673 self.__start_time = now
674 self.__start_blocks = count
675 self.__limit_rate_value = self._config.limit_rate_value
677 passed = now - self.__start_time
678 if passed > 0:
679 speed = ((count-self.__start_blocks)*blockSize)/passed
680 else:
681 speed = 0
682 else:
683 self.__start_time = now
684 self.__start_blocks = count
685 passed = now - self.__start_time
686 speed = count*blockSize
688 self.speed = float(speed)
690 if self._config.limit_rate and speed > self._config.limit_rate_value:
691 # calculate the time that should have passed to reach
692 # the desired download rate and wait if necessary
693 should_have_passed = float((count-self.__start_blocks)*blockSize)/(self._config.limit_rate_value*1024.0)
694 if should_have_passed > passed:
695 # sleep a maximum of 10 seconds to not cause time-outs
696 delay = min(10.0, float(should_have_passed-passed))
697 time.sleep(delay)
699 def run(self):
700 # Speed calculation (re-)starts here
701 self.__start_time = 0
702 self.__start_blocks = 0
704 # If the download has already been cancelled, skip it
705 if self.status == DownloadTask.CANCELLED:
706 util.delete_file(self.tempname)
707 self.progress = 0.0
708 self.speed = 0.0
709 return False
711 # We only start this download if its status is "queued"
712 if self.status != DownloadTask.QUEUED:
713 return False
715 # We are downloading this file right now
716 self.status = DownloadTask.DOWNLOADING
717 self._notification_shown = False
719 try:
720 # Resolve URL and start downloading the episode
721 url = youtube.get_real_download_url(self.__episode.url, \
722 self._config.youtube_preferred_fmt_id)
723 downloader = DownloadURLOpener(self.__episode.channel)
724 headers, real_url = downloader.retrieve_resume(url, \
725 self.tempname, reporthook=self.status_updated)
727 new_mimetype = headers.get('content-type', self.__episode.mimetype)
728 old_mimetype = self.__episode.mimetype
729 _basename, ext = os.path.splitext(self.filename)
730 if new_mimetype != old_mimetype or util.wrong_extension(ext):
731 log('Correcting mime type: %s => %s', old_mimetype, new_mimetype, sender=self)
732 old_extension = self.__episode.extension()
733 self.__episode.mimetype = new_mimetype
734 new_extension = self.__episode.extension()
736 # If the desired filename extension changed due to the new
737 # mimetype, we force an update of the local filename to fix the
738 # extension.
739 if old_extension != new_extension or util.wrong_extension(ext):
740 self.filename = self.__episode.local_filename(create=True, force_update=True)
742 # TODO: Check if "real_url" is different from "url" and if it is,
743 # see if we can get a better episode filename out of it
745 # Look at the Content-disposition header; use if if available
746 disposition_filename = get_header_param(headers, \
747 'filename', 'content-disposition')
749 if disposition_filename is not None:
750 # The server specifies a download filename - try to use it
751 disposition_filename = os.path.basename(disposition_filename)
752 self.filename = self.__episode.local_filename(create=True, \
753 force_update=True, template=disposition_filename)
754 new_mimetype, encoding = mimetypes.guess_type(self.filename)
755 if new_mimetype is not None:
756 log('Using content-disposition mimetype: %s',
757 new_mimetype, sender=self)
758 self.__episode.set_mimetype(new_mimetype, commit=True)
760 shutil.move(self.tempname, self.filename)
762 # Model- and database-related updates after a download has finished
763 self.__episode.on_downloaded(self.filename)
765 # If a user command has been defined, execute the command setting some environment variables
766 if len(self._config.cmd_download_complete) > 0:
767 os.environ["GPODDER_EPISODE_URL"]=self.__episode.url or ''
768 os.environ["GPODDER_EPISODE_TITLE"]=self.__episode.title or ''
769 os.environ["GPODDER_EPISODE_FILENAME"]=self.filename or ''
770 os.environ["GPODDER_EPISODE_PUBDATE"]=str(int(self.__episode.pubDate))
771 os.environ["GPODDER_EPISODE_LINK"]=self.__episode.link or ''
772 os.environ["GPODDER_EPISODE_DESC"]=self.__episode.description or ''
773 os.environ["GPODDER_CHANNEL_TITLE"]=self.__episode.channel.title or ''
774 util.run_external_command(self._config.cmd_download_complete)
775 except DownloadCancelledException:
776 log('Download has been cancelled/paused: %s', self, sender=self)
777 if self.status == DownloadTask.CANCELLED:
778 util.delete_file(self.tempname)
779 self.progress = 0.0
780 self.speed = 0.0
781 except urllib.ContentTooShortError, ctse:
782 self.status = DownloadTask.FAILED
783 self.error_message = _('Missing content from server')
784 except IOError, ioe:
785 log( 'Error "%s" while downloading "%s": %s', ioe.strerror, self.__episode.title, ioe.filename, sender=self, traceback=True)
786 self.status = DownloadTask.FAILED
787 d = {'error': ioe.strerror, 'filename': ioe.filename}
788 self.error_message = _('I/O Error: %(error)s: %(filename)s') % d
789 except gPodderDownloadHTTPError, gdhe:
790 log( 'HTTP error %s while downloading "%s": %s', gdhe.error_code, self.__episode.title, gdhe.error_message, sender=self)
791 self.status = DownloadTask.FAILED
792 d = {'code': gdhe.error_code, 'message': gdhe.error_message}
793 self.error_message = _('HTTP Error %(code)s: %(message)s') % d
794 except Exception, e:
795 self.status = DownloadTask.FAILED
796 log('Download error: %s', str(e), traceback=True, sender=self)
797 self.error_message = _('Error: %s') % (str(e),)
799 if self.status == DownloadTask.DOWNLOADING:
800 # Everything went well - we're done
801 self.status = DownloadTask.DONE
802 if self.total_size <= 0:
803 self.total_size = util.calculate_size(self.filename)
804 log('Total size updated to %d', self.total_size, sender=self)
805 self.progress = 1.0
806 if gpodder.user_hooks is not None:
807 gpodder.user_hooks.on_episode_downloaded(self.__episode)
808 return True
810 self.speed = 0.0
812 # We finished, but not successfully (at least not really)
813 return False