2007-09-18 20:25:25 +02:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
#
|
|
|
|
# gPodder - A media aggregator and podcast client
|
2018-01-28 19:39:53 +01:00
|
|
|
# Copyright (c) 2005-2018 The gPodder Team
|
2007-09-18 20:25:25 +02:00
|
|
|
#
|
|
|
|
# gPodder is free software; you can redistribute it and/or modify
|
|
|
|
# it under the terms of the GNU General Public License as published by
|
|
|
|
# the Free Software Foundation; either version 3 of the License, or
|
|
|
|
# (at your option) any later version.
|
|
|
|
#
|
|
|
|
# gPodder is distributed in the hope that it will be useful,
|
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
# GNU General Public License for more details.
|
|
|
|
#
|
|
|
|
# You should have received a copy of the GNU General Public License
|
|
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
#
|
|
|
|
|
|
|
|
|
|
|
|
#
|
2009-08-24 16:17:32 +02:00
|
|
|
# download.py -- Download queue management
|
2007-09-18 20:25:25 +02:00
|
|
|
# Thomas Perl <thp@perli.net> 2007-09-15
|
|
|
|
#
|
|
|
|
# Based on libwget.py (2005-10-29)
|
|
|
|
#
|
|
|
|
|
2022-02-02 06:09:00 +01:00
|
|
|
import glob
|
2011-07-15 16:32:06 +02:00
|
|
|
import logging
|
2018-07-24 11:08:10 +02:00
|
|
|
import mimetypes
|
|
|
|
import os
|
|
|
|
import os.path
|
|
|
|
import shutil
|
2007-09-18 20:25:25 +02:00
|
|
|
import threading
|
|
|
|
import time
|
2018-05-05 23:50:37 +02:00
|
|
|
import urllib.error
|
2022-10-27 22:54:52 +02:00
|
|
|
from abc import ABC, abstractmethod
|
2020-07-12 17:03:05 +02:00
|
|
|
|
|
|
|
import requests
|
|
|
|
from requests.adapters import HTTPAdapter
|
|
|
|
from requests.exceptions import ConnectionError, HTTPError, RequestException
|
|
|
|
from requests.packages.urllib3.exceptions import MaxRetryError
|
|
|
|
from requests.packages.urllib3.util.retry import Retry
|
2009-09-06 16:38:40 +02:00
|
|
|
|
2018-07-24 11:08:10 +02:00
|
|
|
import gpodder
|
2019-08-17 16:25:00 +02:00
|
|
|
from gpodder import registry, util
|
2018-07-24 11:08:10 +02:00
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2009-05-07 16:26:07 +02:00
|
|
|
_ = gpodder.gettext
|
2009-05-05 10:22:12 +02:00
|
|
|
|
2020-07-12 17:03:05 +02:00
|
|
|
REDIRECT_RETRIES = 3
|
|
|
|
|
2018-02-11 00:22:00 +01:00
|
|
|
|
2022-10-27 22:54:52 +02:00
|
|
|
class CustomDownload(ABC):
|
2019-08-17 16:25:00 +02:00
|
|
|
""" abstract class for custom downloads. DownloadTask call retrieve_resume() on it """
|
|
|
|
|
2022-10-27 22:54:52 +02:00
|
|
|
@property
|
|
|
|
@abstractmethod
|
|
|
|
def partial_filename(self):
|
|
|
|
"""
|
|
|
|
Full path to the temporary file actually being downloaded (downloaders
|
|
|
|
may not support setting a tempname).
|
|
|
|
"""
|
|
|
|
...
|
|
|
|
|
|
|
|
@partial_filename.setter
|
|
|
|
@abstractmethod
|
|
|
|
def partial_filename(self, val):
|
|
|
|
...
|
|
|
|
|
|
|
|
@abstractmethod
|
2020-01-05 21:54:00 +01:00
|
|
|
def retrieve_resume(self, tempname, reporthook):
|
2019-08-17 16:25:00 +02:00
|
|
|
"""
|
|
|
|
:param str tempname: temporary filename for the download
|
|
|
|
:param func(number, number, number) reporthook: callback for download progress (count, blockSize, totalSize)
|
|
|
|
:return dict(str, str), str: (headers, real_url)
|
|
|
|
"""
|
|
|
|
return {}, None
|
|
|
|
|
|
|
|
|
2022-10-27 22:54:52 +02:00
|
|
|
class CustomDownloader(ABC):
|
2019-08-17 16:25:00 +02:00
|
|
|
"""
|
|
|
|
abstract class for custom downloaders.
|
|
|
|
|
|
|
|
DownloadTask calls custom_downloader to get a CustomDownload
|
|
|
|
"""
|
|
|
|
|
2022-10-27 22:54:52 +02:00
|
|
|
@abstractmethod
|
2019-08-17 16:25:00 +02:00
|
|
|
def custom_downloader(self, config, episode):
|
|
|
|
"""
|
|
|
|
if this custom downloader has a custom download method (e.g. youtube-dl),
|
|
|
|
return a CustomDownload. Else return None
|
|
|
|
:param config: gpodder config (e.g. to get preferred video format)
|
|
|
|
:param model.PodcastEpisode episode: episode to download
|
|
|
|
:return CustomDownload: object used to download the episode
|
|
|
|
"""
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
2009-05-05 10:22:12 +02:00
|
|
|
class ContentRange(object):
|
|
|
|
# Based on:
|
|
|
|
# http://svn.pythonpaste.org/Paste/WebOb/trunk/webob/byterange.py
|
|
|
|
#
|
|
|
|
# Copyright (c) 2007 Ian Bicking and Contributors
|
|
|
|
#
|
|
|
|
# Permission is hereby granted, free of charge, to any person obtaining
|
|
|
|
# a copy of this software and associated documentation files (the
|
|
|
|
# "Software"), to deal in the Software without restriction, including
|
|
|
|
# without limitation the rights to use, copy, modify, merge, publish,
|
|
|
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
|
|
|
# permit persons to whom the Software is furnished to do so, subject to
|
|
|
|
# the following conditions:
|
|
|
|
#
|
|
|
|
# The above copyright notice and this permission notice shall be
|
|
|
|
# included in all copies or substantial portions of the Software.
|
|
|
|
#
|
|
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
|
|
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
|
|
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
|
|
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
|
|
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
|
|
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
|
|
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
|
"""
|
|
|
|
Represents the Content-Range header
|
|
|
|
|
|
|
|
This header is ``start-stop/length``, where stop and length can be
|
|
|
|
``*`` (represented as None in the attributes).
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self, start, stop, length):
|
|
|
|
assert start >= 0, "Bad start: %r" % start
|
|
|
|
assert stop is None or (stop >= 0 and stop >= start), (
|
|
|
|
"Bad stop: %r" % stop)
|
|
|
|
self.start = start
|
|
|
|
self.stop = stop
|
|
|
|
self.length = length
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
return '<%s %s>' % (
|
|
|
|
self.__class__.__name__,
|
|
|
|
self)
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
if self.stop is None:
|
|
|
|
stop = '*'
|
|
|
|
else:
|
|
|
|
stop = self.stop + 1
|
|
|
|
if self.length is None:
|
|
|
|
length = '*'
|
|
|
|
else:
|
|
|
|
length = self.length
|
|
|
|
return 'bytes %s-%s/%s' % (self.start, stop, length)
|
|
|
|
|
|
|
|
def __iter__(self):
|
|
|
|
"""
|
|
|
|
Mostly so you can unpack this, like:
|
|
|
|
|
|
|
|
start, stop, length = res.content_range
|
|
|
|
"""
|
|
|
|
return iter([self.start, self.stop, self.length])
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def parse(cls, value):
|
|
|
|
"""
|
|
|
|
Parse the header. May return None if it cannot parse.
|
|
|
|
"""
|
|
|
|
if value is None:
|
|
|
|
return None
|
|
|
|
value = value.strip()
|
|
|
|
if not value.startswith('bytes '):
|
|
|
|
# Unparseable
|
|
|
|
return None
|
|
|
|
value = value[len('bytes '):].strip()
|
|
|
|
if '/' not in value:
|
|
|
|
# Invalid, no length given
|
|
|
|
return None
|
|
|
|
range, length = value.split('/', 1)
|
|
|
|
if '-' not in range:
|
|
|
|
# Invalid, no range
|
|
|
|
return None
|
|
|
|
start, end = range.split('-', 1)
|
|
|
|
try:
|
|
|
|
start = int(start)
|
|
|
|
if end == '*':
|
|
|
|
end = None
|
|
|
|
else:
|
|
|
|
end = int(end)
|
|
|
|
if length == '*':
|
|
|
|
length = None
|
|
|
|
else:
|
|
|
|
length = int(length)
|
|
|
|
except ValueError:
|
|
|
|
# Parse problem
|
|
|
|
return None
|
|
|
|
if end is None:
|
|
|
|
return cls(start, None, length)
|
|
|
|
else:
|
2018-03-27 21:40:36 +02:00
|
|
|
return cls(start, end - 1, length)
|
2009-05-05 10:22:12 +02:00
|
|
|
|
|
|
|
|
2007-09-18 20:25:25 +02:00
|
|
|
class DownloadCancelledException(Exception): pass
|
2018-02-11 00:22:00 +01:00
|
|
|
|
|
|
|
|
2021-03-28 13:54:16 +02:00
|
|
|
class DownloadNoURLException(Exception): pass
|
|
|
|
|
|
|
|
|
2008-08-10 14:38:20 +02:00
|
|
|
class gPodderDownloadHTTPError(Exception):
|
|
|
|
def __init__(self, url, error_code, error_message):
|
|
|
|
self.url = url
|
|
|
|
self.error_code = error_code
|
|
|
|
self.error_message = error_message
|
2007-09-18 20:25:25 +02:00
|
|
|
|
2018-02-11 00:22:00 +01:00
|
|
|
|
2020-07-12 17:03:05 +02:00
|
|
|
class DownloadURLOpener:
|
2007-09-18 20:25:25 +02:00
|
|
|
|
2009-09-09 18:53:45 +02:00
|
|
|
# Sometimes URLs are not escaped correctly - try to fix them
|
|
|
|
# (see RFC2396; Section 2.4.3. Excluded US-ASCII Characters)
|
|
|
|
# FYI: The omission of "%" in the list is to avoid double escaping!
|
2018-03-18 01:00:02 +01:00
|
|
|
ESCAPE_CHARS = dict((ord(c), '%%%x' % ord(c)) for c in ' <>#"{}|\\^[]`')
|
2009-09-09 18:53:45 +02:00
|
|
|
|
2020-07-12 17:03:05 +02:00
|
|
|
def __init__(self, channel, max_retries=3):
|
2018-12-15 16:18:47 +01:00
|
|
|
super().__init__()
|
2020-07-12 17:03:05 +02:00
|
|
|
self.channel = channel
|
|
|
|
self.max_retries = max_retries
|
|
|
|
|
|
|
|
def init_session(self):
|
|
|
|
""" init a session with our own retry codes + retry count """
|
|
|
|
# I add a few retries for redirects but it means that I will allow max_retries + REDIRECT_RETRIES
|
|
|
|
# if encountering max_retries connect and REDIRECT_RETRIES read for instance
|
|
|
|
retry_strategy = Retry(
|
|
|
|
total=self.max_retries + REDIRECT_RETRIES,
|
|
|
|
connect=self.max_retries,
|
|
|
|
read=self.max_retries,
|
|
|
|
redirect=max(REDIRECT_RETRIES, self.max_retries),
|
|
|
|
status=self.max_retries,
|
|
|
|
status_forcelist=Retry.RETRY_AFTER_STATUS_CODES.union((408, 418, 504, 598, 599,)))
|
|
|
|
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
|
|
http = requests.Session()
|
|
|
|
http.mount("https://", adapter)
|
|
|
|
http.mount("http://", adapter)
|
|
|
|
return http
|
2018-01-30 14:04:28 +01:00
|
|
|
|
2009-02-09 23:26:47 +01:00
|
|
|
# The following is based on Python's urllib.py "URLopener.retrieve"
|
|
|
|
# Also based on http://mail.python.org/pipermail/python-list/2001-October/110069.html
|
|
|
|
|
2022-06-17 00:37:30 +02:00
|
|
|
def retrieve_resume(self, url, filename, reporthook=None, data=None, disable_auth=False):
|
2009-09-06 16:38:40 +02:00
|
|
|
"""Download files from an URL; return (headers, real_url)
|
2009-02-09 23:26:47 +01:00
|
|
|
|
2009-09-06 16:38:40 +02:00
|
|
|
Resumes a download if the local filename exists and
|
|
|
|
the server supports download resuming.
|
|
|
|
"""
|
2009-02-09 23:26:47 +01:00
|
|
|
|
|
|
|
current_size = 0
|
|
|
|
tfp = None
|
2020-07-12 17:03:05 +02:00
|
|
|
headers = {
|
|
|
|
'User-agent': gpodder.user_agent
|
|
|
|
}
|
|
|
|
|
2022-06-17 00:37:30 +02:00
|
|
|
if (self.channel.auth_username or self.channel.auth_password) and not disable_auth:
|
2020-07-12 17:03:05 +02:00
|
|
|
logger.debug('Authenticating as "%s"', self.channel.auth_username)
|
|
|
|
auth = (self.channel.auth_username, self.channel.auth_password)
|
|
|
|
else:
|
|
|
|
auth = None
|
|
|
|
|
2009-02-09 23:26:47 +01:00
|
|
|
if os.path.exists(filename):
|
|
|
|
try:
|
|
|
|
current_size = os.path.getsize(filename)
|
|
|
|
tfp = open(filename, 'ab')
|
2018-05-17 08:39:56 +02:00
|
|
|
# If the file exists, then only download the remainder
|
2009-06-10 01:57:35 +02:00
|
|
|
if current_size > 0:
|
2020-07-12 17:03:05 +02:00
|
|
|
headers['Range'] = 'bytes=%s-' % (current_size)
|
2009-02-09 23:26:47 +01:00
|
|
|
except:
|
2022-04-17 11:07:51 +02:00
|
|
|
logger.warning('Cannot resume download: %s', filename, exc_info=True)
|
2009-02-09 23:26:47 +01:00
|
|
|
tfp = None
|
|
|
|
current_size = 0
|
|
|
|
|
|
|
|
if tfp is None:
|
|
|
|
tfp = open(filename, 'wb')
|
|
|
|
|
2009-09-09 18:53:45 +02:00
|
|
|
# Fix a problem with bad URLs that are not encoded correctly (bug 549)
|
|
|
|
url = url.translate(self.ESCAPE_CHARS)
|
|
|
|
|
2020-07-12 17:03:05 +02:00
|
|
|
session = self.init_session()
|
|
|
|
with session.get(url,
|
|
|
|
headers=headers,
|
|
|
|
stream=True,
|
|
|
|
auth=auth,
|
|
|
|
timeout=gpodder.SOCKET_TIMEOUT) as resp:
|
|
|
|
try:
|
|
|
|
resp.raise_for_status()
|
|
|
|
except HTTPError as e:
|
2022-06-17 00:37:30 +02:00
|
|
|
if auth is not None:
|
|
|
|
# Try again without authentication (bug 1296)
|
|
|
|
return self.retrieve_resume(url, filename, reporthook, data, True)
|
|
|
|
else:
|
|
|
|
raise gPodderDownloadHTTPError(url, resp.status_code, str(e))
|
2020-07-12 17:03:05 +02:00
|
|
|
|
|
|
|
headers = resp.headers
|
|
|
|
|
|
|
|
if current_size > 0:
|
|
|
|
# We told the server to resume - see if she agrees
|
|
|
|
# See RFC2616 (206 Partial Content + Section 14.16)
|
|
|
|
# XXX check status code here, too...
|
|
|
|
range = ContentRange.parse(headers.get('content-range', ''))
|
|
|
|
if range is None or range.start != current_size:
|
|
|
|
# Ok, that did not work. Reset the download
|
|
|
|
# TODO: seek and truncate if content-range differs from request
|
|
|
|
tfp.close()
|
|
|
|
tfp = open(filename, 'wb')
|
|
|
|
current_size = 0
|
2022-04-17 11:07:51 +02:00
|
|
|
logger.warning('Cannot resume: Invalid Content-Range (RFC2616).')
|
2020-07-12 17:03:05 +02:00
|
|
|
|
|
|
|
result = headers, resp.url
|
|
|
|
bs = 1024 * 8
|
|
|
|
size = -1
|
|
|
|
read = current_size
|
|
|
|
blocknum = current_size // bs
|
2009-02-09 23:26:47 +01:00
|
|
|
if reporthook:
|
2020-07-12 17:03:05 +02:00
|
|
|
if "content-length" in headers:
|
|
|
|
size = int(headers['content-length']) + current_size
|
2009-02-09 23:26:47 +01:00
|
|
|
reporthook(blocknum, bs, size)
|
2020-07-12 17:03:05 +02:00
|
|
|
for block in resp.iter_content(bs):
|
|
|
|
read += len(block)
|
|
|
|
tfp.write(block)
|
|
|
|
blocknum += 1
|
|
|
|
if reporthook:
|
|
|
|
reporthook(blocknum, bs, size)
|
|
|
|
tfp.close()
|
|
|
|
del tfp
|
2009-02-09 23:26:47 +01:00
|
|
|
|
|
|
|
# raise exception if actual size does not match content-length header
|
|
|
|
if size >= 0 and read < size:
|
2016-11-21 23:13:46 +01:00
|
|
|
raise urllib.error.ContentTooShortError("retrieval incomplete: got only %i out "
|
2009-02-09 23:26:47 +01:00
|
|
|
"of %i bytes" % (read, size), result)
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
# end code based on urllib.py
|
|
|
|
|
2007-09-18 20:25:25 +02:00
|
|
|
|
2020-01-05 21:54:00 +01:00
|
|
|
class DefaultDownload(CustomDownload):
|
|
|
|
def __init__(self, config, episode, url):
|
|
|
|
self._config = config
|
|
|
|
self.__episode = episode
|
|
|
|
self._url = url
|
2022-10-27 22:54:52 +02:00
|
|
|
self.__partial_filename = None
|
|
|
|
|
|
|
|
@property
|
|
|
|
def partial_filename(self):
|
|
|
|
return self.__partial_filename
|
|
|
|
|
|
|
|
@partial_filename.setter
|
|
|
|
def partial_filename(self, val):
|
|
|
|
self.__partial_filename = val
|
2020-01-05 21:54:00 +01:00
|
|
|
|
|
|
|
def retrieve_resume(self, tempname, reporthook):
|
|
|
|
url = self._url
|
|
|
|
logger.info("Downloading %s", url)
|
|
|
|
max_retries = max(0, self._config.auto.retries)
|
2020-07-12 17:03:05 +02:00
|
|
|
downloader = DownloadURLOpener(self.__episode.channel, max_retries=max_retries)
|
2022-10-27 22:54:52 +02:00
|
|
|
self.partial_filename = tempname
|
2020-01-05 21:54:00 +01:00
|
|
|
|
2020-07-12 17:03:05 +02:00
|
|
|
# Retry the download on incomplete download (other retries are done by the Retry strategy)
|
2020-01-05 21:54:00 +01:00
|
|
|
for retry in range(max_retries + 1):
|
|
|
|
if retry > 0:
|
|
|
|
logger.info('Retrying download of %s (%d)', url, retry)
|
|
|
|
time.sleep(1)
|
|
|
|
|
|
|
|
try:
|
|
|
|
headers, real_url = downloader.retrieve_resume(url,
|
|
|
|
tempname, reporthook=reporthook)
|
|
|
|
# If we arrive here, the download was successful
|
|
|
|
break
|
|
|
|
except urllib.error.ContentTooShortError as ctse:
|
|
|
|
if retry < max_retries:
|
|
|
|
logger.info('Content too short: %s - will retry.',
|
|
|
|
url)
|
|
|
|
continue
|
|
|
|
raise
|
|
|
|
return (headers, real_url)
|
|
|
|
|
|
|
|
|
|
|
|
class DefaultDownloader(CustomDownloader):
|
|
|
|
@staticmethod
|
|
|
|
def custom_downloader(config, episode):
|
|
|
|
url = episode.url
|
|
|
|
# Resolve URL and start downloading the episode
|
2020-09-12 10:34:39 +02:00
|
|
|
res = registry.download_url.resolve(config, None, episode, False)
|
2020-01-05 21:54:00 +01:00
|
|
|
if res:
|
|
|
|
url = res
|
|
|
|
if url == episode.url:
|
|
|
|
# don't modify custom urls (#635 - vimeo breaks if * is unescaped)
|
|
|
|
url = url.strip()
|
|
|
|
url = util.iri_to_url(url)
|
|
|
|
return DefaultDownload(config, episode, url)
|
|
|
|
|
|
|
|
|
2012-07-10 13:52:34 +02:00
|
|
|
class DownloadQueueWorker(object):
|
2016-11-15 04:54:21 +01:00
|
|
|
def __init__(self, queue, exit_callback, continue_check_callback):
|
2009-04-01 01:12:17 +02:00
|
|
|
self.queue = queue
|
|
|
|
self.exit_callback = exit_callback
|
2009-12-17 00:37:45 +01:00
|
|
|
self.continue_check_callback = continue_check_callback
|
2007-09-18 20:25:25 +02:00
|
|
|
|
2012-07-10 13:52:34 +02:00
|
|
|
def __repr__(self):
|
|
|
|
return threading.current_thread().getName()
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
def run(self):
|
2012-07-10 13:52:34 +02:00
|
|
|
logger.info('Starting new thread: %s', self)
|
2009-12-17 00:37:45 +01:00
|
|
|
while True:
|
2016-11-15 04:54:21 +01:00
|
|
|
if not self.continue_check_callback(self):
|
2009-12-17 00:37:45 +01:00
|
|
|
return
|
|
|
|
|
2022-12-18 02:28:13 +01:00
|
|
|
task = self.queue.get_next() if self.queue.enabled else None
|
2021-06-05 23:24:04 +02:00
|
|
|
if not task:
|
2012-07-10 13:52:34 +02:00
|
|
|
logger.info('No more tasks for %s to carry out.', self)
|
2009-04-01 01:12:17 +02:00
|
|
|
break
|
2021-06-05 23:24:04 +02:00
|
|
|
logger.info('%s is processing: %s', self, task)
|
|
|
|
task.run()
|
|
|
|
task.recycle()
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
self.exit_callback(self)
|
|
|
|
|
|
|
|
|
2016-11-15 04:54:21 +01:00
|
|
|
class ForceDownloadWorker(object):
|
|
|
|
def __init__(self, task):
|
|
|
|
self.task = task
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
return threading.current_thread().getName()
|
|
|
|
|
|
|
|
def run(self):
|
|
|
|
logger.info('Starting new thread: %s', self)
|
|
|
|
logger.info('%s is processing: %s', self, self.task)
|
|
|
|
self.task.run()
|
2022-05-14 04:27:21 +02:00
|
|
|
self.task.recycle()
|
2016-11-15 04:54:21 +01:00
|
|
|
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
class DownloadQueueManager(object):
|
2016-11-15 04:54:21 +01:00
|
|
|
def __init__(self, config, queue):
|
2009-08-11 00:09:02 +02:00
|
|
|
self._config = config
|
2016-11-15 04:54:21 +01:00
|
|
|
self.tasks = queue
|
2009-04-01 01:12:17 +02:00
|
|
|
|
|
|
|
self.worker_threads_access = threading.RLock()
|
|
|
|
self.worker_threads = []
|
|
|
|
|
2022-12-18 02:28:13 +01:00
|
|
|
def disable(self):
|
|
|
|
self.tasks.enabled = False
|
|
|
|
|
|
|
|
def enable(self):
|
|
|
|
self.tasks.enabled = True
|
|
|
|
self.__spawn_threads()
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
def __exit_callback(self, worker_thread):
|
|
|
|
with self.worker_threads_access:
|
|
|
|
self.worker_threads.remove(worker_thread)
|
2008-12-13 13:29:45 +01:00
|
|
|
|
2009-12-17 00:37:45 +01:00
|
|
|
def __continue_check_callback(self, worker_thread):
|
2009-04-01 01:12:17 +02:00
|
|
|
with self.worker_threads_access:
|
2022-12-08 22:49:33 +01:00
|
|
|
if len(self.worker_threads) > self._config.limit.downloads.concurrent and \
|
2022-12-08 22:47:03 +01:00
|
|
|
self._config.limit.downloads.enabled:
|
2009-12-17 00:37:45 +01:00
|
|
|
self.worker_threads.remove(worker_thread)
|
|
|
|
return False
|
|
|
|
else:
|
|
|
|
return True
|
|
|
|
|
2016-11-15 04:54:21 +01:00
|
|
|
def __spawn_threads(self):
|
2010-03-07 20:15:36 +01:00
|
|
|
"""Spawn new worker threads if necessary
|
|
|
|
"""
|
2022-12-18 02:28:13 +01:00
|
|
|
if not self.tasks.enabled:
|
|
|
|
return
|
|
|
|
|
2009-12-17 00:37:45 +01:00
|
|
|
with self.worker_threads_access:
|
2018-04-29 15:15:18 +02:00
|
|
|
work_count = self.tasks.available_work_count()
|
2022-12-08 22:47:03 +01:00
|
|
|
if self._config.limit.downloads.enabled:
|
2018-04-29 15:15:18 +02:00
|
|
|
# always allow at least 1 download
|
2022-12-08 22:49:33 +01:00
|
|
|
spawn_limit = max(int(self._config.limit.downloads.concurrent), 1)
|
2018-04-29 15:15:18 +02:00
|
|
|
else:
|
2018-04-29 15:59:22 +02:00
|
|
|
spawn_limit = self._config.limit.downloads.concurrent_max
|
2021-06-04 08:56:34 +02:00
|
|
|
running = len(self.worker_threads)
|
|
|
|
logger.info('%r tasks to do, can start at most %r threads, %r threads currently running', work_count, spawn_limit, running)
|
|
|
|
for i in range(0, min(work_count, spawn_limit - running)):
|
2009-04-01 01:12:17 +02:00
|
|
|
# We have to create a new thread here, there's work to do
|
2011-07-15 16:32:06 +02:00
|
|
|
logger.info('Starting new worker thread.')
|
2010-03-07 20:15:36 +01:00
|
|
|
|
2012-07-10 13:52:34 +02:00
|
|
|
worker = DownloadQueueWorker(self.tasks, self.__exit_callback,
|
2016-11-15 04:54:21 +01:00
|
|
|
self.__continue_check_callback)
|
2009-04-01 01:12:17 +02:00
|
|
|
self.worker_threads.append(worker)
|
2012-07-10 13:52:34 +02:00
|
|
|
util.run_in_background(worker.run)
|
2007-09-18 20:25:25 +02:00
|
|
|
|
2016-11-15 04:54:21 +01:00
|
|
|
def update_max_downloads(self):
|
|
|
|
self.__spawn_threads()
|
2009-08-17 21:46:17 +02:00
|
|
|
|
2016-11-15 04:54:21 +01:00
|
|
|
def force_start_task(self, task):
|
2021-09-14 09:53:15 +02:00
|
|
|
with task:
|
|
|
|
if task.status in (task.QUEUED, task.PAUSED, task.CANCELLED, task.FAILED):
|
|
|
|
task.status = task.DOWNLOADING
|
|
|
|
worker = ForceDownloadWorker(task)
|
|
|
|
util.run_in_background(worker.run)
|
2010-03-07 20:15:36 +01:00
|
|
|
|
2016-11-15 04:54:21 +01:00
|
|
|
def queue_task(self, task):
|
|
|
|
"""Marks a task as queued
|
2010-03-07 20:15:36 +01:00
|
|
|
"""
|
2021-08-30 16:45:20 +02:00
|
|
|
self.tasks.queue_task(task)
|
2016-11-15 04:54:21 +01:00
|
|
|
self.__spawn_threads()
|
2007-09-18 20:25:25 +02:00
|
|
|
|
2022-06-22 21:51:23 +02:00
|
|
|
def has_workers(self):
|
|
|
|
return len(self.worker_threads) > 0
|
|
|
|
|
2008-04-17 17:59:38 +02:00
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
class DownloadTask(object):
|
|
|
|
"""An object representing the download task of an episode
|
|
|
|
|
|
|
|
You can create a new download task like this:
|
|
|
|
|
2009-08-11 00:09:02 +02:00
|
|
|
task = DownloadTask(episode, gpodder.config.Config(CONFIGFILE))
|
2021-08-30 16:45:20 +02:00
|
|
|
task.status = DownloadTask.QUEUED
|
2009-04-01 01:12:17 +02:00
|
|
|
task.run()
|
|
|
|
|
|
|
|
While the download is in progress, you can access its properties:
|
|
|
|
|
|
|
|
task.total_size # in bytes
|
|
|
|
task.progress # from 0.0 to 1.0
|
|
|
|
task.speed # in bytes per second
|
|
|
|
str(task) # name of the episode
|
|
|
|
task.status # current status
|
2009-05-12 11:03:59 +02:00
|
|
|
task.status_changed # True if the status has been changed (see below)
|
|
|
|
task.url # URL of the episode being downloaded
|
|
|
|
task.podcast_url # URL of the podcast this download belongs to
|
2012-01-21 09:55:11 +01:00
|
|
|
task.episode # Episode object of this task
|
2009-04-01 01:12:17 +02:00
|
|
|
|
|
|
|
You can cancel a running download task by setting its status:
|
|
|
|
|
2021-08-30 16:45:20 +02:00
|
|
|
with task:
|
|
|
|
task.status = DownloadTask.CANCELLING
|
2009-04-01 01:12:17 +02:00
|
|
|
|
|
|
|
The task will then abort as soon as possible (due to the nature
|
|
|
|
of downloading data, this can take a while when the Internet is
|
|
|
|
busy).
|
|
|
|
|
2009-04-02 00:02:07 +02:00
|
|
|
The "status_changed" attribute gets set to True everytime the
|
|
|
|
"status" attribute changes its value. After you get the value of
|
|
|
|
the "status_changed" attribute, it is always reset to False:
|
|
|
|
|
|
|
|
if task.status_changed:
|
|
|
|
new_status = task.status
|
|
|
|
# .. update the UI accordingly ..
|
|
|
|
|
|
|
|
Obviously, this also means that you must have at most *one*
|
|
|
|
place in your UI code where you check for status changes and
|
|
|
|
broadcast the status updates from there.
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
While the download is taking place and after the .run() method
|
|
|
|
has finished, you can get the final status to check if the download
|
|
|
|
was successful:
|
|
|
|
|
|
|
|
if task.status == DownloadTask.DONE:
|
|
|
|
# .. everything ok ..
|
|
|
|
elif task.status == DownloadTask.FAILED:
|
|
|
|
# .. an error happened, and the
|
|
|
|
# error_message attribute is set ..
|
|
|
|
print task.error_message
|
|
|
|
elif task.status == DownloadTask.PAUSED:
|
|
|
|
# .. user paused the download ..
|
|
|
|
elif task.status == DownloadTask.CANCELLED:
|
|
|
|
# .. user cancelled the download ..
|
|
|
|
|
|
|
|
The difference between cancelling and pausing a DownloadTask is
|
|
|
|
that the temporary file gets deleted when cancelling, but does
|
|
|
|
not get deleted when pausing.
|
|
|
|
|
|
|
|
Be sure to call .removed_from_list() on this task when removing
|
|
|
|
it from the UI, so that it can carry out any pending clean-up
|
|
|
|
actions (e.g. removing the temporary file when the task has not
|
|
|
|
finished successfully; i.e. task.status != DownloadTask.DONE).
|
2010-12-18 14:50:43 +01:00
|
|
|
|
|
|
|
The UI can call the method "notify_as_finished()" to determine if
|
|
|
|
this episode still has still to be shown as "finished" download
|
|
|
|
in a notification window. This will return True only the first time
|
|
|
|
it is called when the status is DONE. After returning True once,
|
|
|
|
it will always return False afterwards.
|
|
|
|
|
|
|
|
The same thing works for failed downloads ("notify_as_failed()").
|
2009-04-01 01:12:17 +02:00
|
|
|
"""
|
|
|
|
# Possible states this download task can be in
|
2021-08-31 15:37:36 +02:00
|
|
|
STATUS_MESSAGE = (_('Queued'), _('Queued'), _('Downloading'),
|
2021-08-30 16:45:20 +02:00
|
|
|
_('Finished'), _('Failed'), _('Cancelling'), _('Cancelled'), _('Pausing'), _('Paused'))
|
2021-08-31 15:37:36 +02:00
|
|
|
(NEW, QUEUED, DOWNLOADING, DONE, FAILED, CANCELLING, CANCELLED, PAUSING, PAUSED) = list(range(9))
|
2012-07-09 21:08:40 +02:00
|
|
|
|
|
|
|
# Wheter this task represents a file download or a device sync operation
|
2016-11-21 23:13:46 +01:00
|
|
|
ACTIVITY_DOWNLOAD, ACTIVITY_SYNCHRONIZE = list(range(2))
|
2012-07-09 21:08:40 +02:00
|
|
|
|
2012-10-23 13:10:27 +02:00
|
|
|
# Minimum time between progress updates (in seconds)
|
|
|
|
MIN_TIME_BETWEEN_UPDATES = 1.
|
2009-04-01 01:12:17 +02:00
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return self.__episode.title
|
|
|
|
|
2021-08-30 16:45:20 +02:00
|
|
|
def __enter__(self):
|
|
|
|
return self.__lock.acquire()
|
|
|
|
|
|
|
|
def __exit__(self, type, value, traceback):
|
|
|
|
self.__lock.release()
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
def __get_status(self):
|
|
|
|
return self.__status
|
|
|
|
|
|
|
|
def __set_status(self, status):
|
2009-04-02 00:02:07 +02:00
|
|
|
if status != self.__status:
|
|
|
|
self.__status_changed = True
|
|
|
|
self.__status = status
|
2009-04-01 01:12:17 +02:00
|
|
|
|
|
|
|
status = property(fget=__get_status, fset=__set_status)
|
|
|
|
|
2009-04-02 00:02:07 +02:00
|
|
|
def __get_status_changed(self):
|
|
|
|
if self.__status_changed:
|
|
|
|
self.__status_changed = False
|
|
|
|
return True
|
|
|
|
else:
|
|
|
|
return False
|
|
|
|
|
|
|
|
status_changed = property(fget=__get_status_changed)
|
|
|
|
|
2012-07-07 23:41:56 +02:00
|
|
|
def __get_activity(self):
|
|
|
|
return self.__activity
|
|
|
|
|
|
|
|
def __set_activity(self, activity):
|
|
|
|
self.__activity = activity
|
|
|
|
|
|
|
|
activity = property(fget=__get_activity, fset=__set_activity)
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
def __get_url(self):
|
|
|
|
return self.__episode.url
|
|
|
|
|
|
|
|
url = property(fget=__get_url)
|
|
|
|
|
2009-05-12 11:03:59 +02:00
|
|
|
def __get_podcast_url(self):
|
|
|
|
return self.__episode.channel.url
|
|
|
|
|
|
|
|
podcast_url = property(fget=__get_podcast_url)
|
|
|
|
|
2009-04-19 08:09:36 +02:00
|
|
|
def __get_episode(self):
|
|
|
|
return self.__episode
|
|
|
|
|
|
|
|
episode = property(fget=__get_episode)
|
|
|
|
|
2020-01-05 21:54:00 +01:00
|
|
|
def __get_downloader(self):
|
|
|
|
return self.__downloader
|
|
|
|
|
|
|
|
def __set_downloader(self, downloader):
|
|
|
|
# modifying the downloader will only have effect before the download is started
|
|
|
|
self.__downloader = downloader
|
|
|
|
|
|
|
|
downloader = property(fget=__get_downloader, fset=__set_downloader)
|
|
|
|
|
2022-03-24 01:53:38 +01:00
|
|
|
def can_queue(self):
|
|
|
|
return self.status in (self.CANCELLED, self.PAUSED, self.FAILED)
|
|
|
|
|
|
|
|
def unpause(self):
|
|
|
|
with self:
|
|
|
|
# Resume a downloading task that was transitioning to paused
|
|
|
|
if self.status == self.PAUSING:
|
|
|
|
self.status = self.DOWNLOADING
|
|
|
|
|
|
|
|
def can_pause(self):
|
|
|
|
return self.status in (self.DOWNLOADING, self.QUEUED)
|
|
|
|
|
2021-09-14 09:53:15 +02:00
|
|
|
def pause(self):
|
|
|
|
with self:
|
|
|
|
# Pause a queued download
|
|
|
|
if self.status == self.QUEUED:
|
|
|
|
self.status = self.PAUSED
|
|
|
|
# Request pause of a running download
|
|
|
|
elif self.status == self.DOWNLOADING:
|
|
|
|
self.status = self.PAUSING
|
2022-03-08 14:21:10 +01:00
|
|
|
# download rate limited tasks sleep and take longer to transition from the PAUSING state to the PAUSED state
|
|
|
|
|
2022-03-24 01:53:38 +01:00
|
|
|
def can_cancel(self):
|
|
|
|
return self.status in (self.DOWNLOADING, self.QUEUED, self.PAUSED, self.FAILED)
|
2021-09-14 09:53:15 +02:00
|
|
|
|
2011-07-16 18:04:07 +02:00
|
|
|
def cancel(self):
|
2021-08-30 16:45:20 +02:00
|
|
|
with self:
|
2021-09-14 09:53:15 +02:00
|
|
|
# Cancelling directly is allowed if the task isn't currently downloading
|
|
|
|
if self.status in (self.QUEUED, self.PAUSED, self.FAILED):
|
2021-10-12 03:53:00 +02:00
|
|
|
self.status = self.CANCELLING
|
|
|
|
# Call run, so the partial file gets deleted, and task recycled
|
2021-09-14 09:53:15 +02:00
|
|
|
self.run()
|
|
|
|
# Otherwise request cancellation
|
|
|
|
elif self.status == self.DOWNLOADING:
|
2021-08-30 16:45:20 +02:00
|
|
|
self.status = self.CANCELLING
|
2011-07-16 18:04:07 +02:00
|
|
|
|
2022-03-24 01:53:38 +01:00
|
|
|
def can_remove(self):
|
|
|
|
return self.status in (self.CANCELLED, self.FAILED, self.DONE)
|
|
|
|
|
2022-02-02 06:09:00 +01:00
|
|
|
def delete_partial_files(self):
|
|
|
|
temporary_files = [self.tempname]
|
2022-07-04 22:51:45 +02:00
|
|
|
# youtube-dl creates .partial.* files for adaptive formats
|
2022-02-02 06:09:00 +01:00
|
|
|
temporary_files += glob.glob('%s.*' % self.tempname)
|
|
|
|
|
|
|
|
for tempfile in temporary_files:
|
|
|
|
util.delete_file(tempfile)
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
def removed_from_list(self):
|
|
|
|
if self.status != self.DONE:
|
2022-02-02 06:09:00 +01:00
|
|
|
self.delete_partial_files()
|
2009-04-01 01:12:17 +02:00
|
|
|
|
2020-01-05 21:54:00 +01:00
|
|
|
def __init__(self, episode, config, downloader=None):
|
2011-07-16 18:04:07 +02:00
|
|
|
assert episode.download_task is None
|
2021-08-30 16:45:20 +02:00
|
|
|
self.__lock = threading.RLock()
|
2021-08-31 15:37:36 +02:00
|
|
|
self.__status = DownloadTask.NEW
|
2012-07-09 21:08:40 +02:00
|
|
|
self.__activity = DownloadTask.ACTIVITY_DOWNLOAD
|
2009-04-02 00:02:07 +02:00
|
|
|
self.__status_changed = True
|
2009-04-01 01:12:17 +02:00
|
|
|
self.__episode = episode
|
2009-08-11 00:09:02 +02:00
|
|
|
self._config = config
|
2020-01-05 21:54:00 +01:00
|
|
|
# specify a custom downloader to be used for this download
|
|
|
|
self.__downloader = downloader
|
2009-04-01 01:12:17 +02:00
|
|
|
|
|
|
|
# Create the target filename and save it in the database
|
|
|
|
self.filename = self.__episode.local_filename(create=True)
|
|
|
|
self.tempname = self.filename + '.partial'
|
|
|
|
|
2010-12-20 14:35:46 +01:00
|
|
|
self.total_size = self.__episode.file_size
|
2009-04-01 01:12:17 +02:00
|
|
|
self.speed = 0.0
|
2007-09-18 20:25:25 +02:00
|
|
|
self.progress = 0.0
|
2009-04-01 01:12:17 +02:00
|
|
|
self.error_message = None
|
2022-10-27 22:57:05 +02:00
|
|
|
self.custom_downloader = None
|
2009-04-01 01:12:17 +02:00
|
|
|
|
2010-12-18 14:50:43 +01:00
|
|
|
# Have we already shown this task in a notification?
|
|
|
|
self._notification_shown = False
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
# Variables for speed limit and speed calculation
|
|
|
|
self.__start_time = 0
|
|
|
|
self.__start_blocks = 0
|
2022-12-08 22:41:32 +01:00
|
|
|
self.__limit_rate_value = self._config.limit.bandwidth.kbps
|
2022-12-08 22:45:24 +01:00
|
|
|
self.__limit_rate = self._config.limit.bandwidth.enabled
|
2009-02-18 13:32:39 +01:00
|
|
|
|
2012-10-23 13:10:27 +02:00
|
|
|
# Progress update functions
|
|
|
|
self._progress_updated = None
|
|
|
|
self._last_progress_updated = 0.
|
2010-10-10 22:44:22 +02:00
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
# If the tempname already exists, set progress accordingly
|
|
|
|
if os.path.exists(self.tempname):
|
2009-02-18 13:32:39 +01:00
|
|
|
try:
|
2009-04-01 01:12:17 +02:00
|
|
|
already_downloaded = os.path.getsize(self.tempname)
|
|
|
|
if self.total_size > 0:
|
2018-03-27 21:40:36 +02:00
|
|
|
self.progress = max(0.0, min(1.0, already_downloaded / self.total_size))
|
2016-11-21 23:13:46 +01:00
|
|
|
except OSError as os_error:
|
2011-07-15 16:32:06 +02:00
|
|
|
logger.error('Cannot get size for %s', os_error)
|
2007-09-18 20:25:25 +02:00
|
|
|
else:
|
2009-04-01 01:12:17 +02:00
|
|
|
# "touch self.tempname", so we also get partial
|
|
|
|
# files for resuming when the file is queued
|
|
|
|
open(self.tempname, 'w').close()
|
|
|
|
|
2011-07-16 18:04:07 +02:00
|
|
|
# Store a reference to this task in the episode
|
|
|
|
episode.download_task = self
|
|
|
|
|
2022-02-08 11:57:22 +01:00
|
|
|
def reuse(self):
|
|
|
|
if not os.path.exists(self.tempname):
|
|
|
|
# partial file was deleted when cancelled, recreate it
|
|
|
|
open(self.tempname, 'w').close()
|
|
|
|
|
2010-12-18 14:50:43 +01:00
|
|
|
def notify_as_finished(self):
|
|
|
|
if self.status == DownloadTask.DONE:
|
|
|
|
if self._notification_shown:
|
|
|
|
return False
|
|
|
|
else:
|
|
|
|
self._notification_shown = True
|
|
|
|
return True
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
def notify_as_failed(self):
|
|
|
|
if self.status == DownloadTask.FAILED:
|
|
|
|
if self._notification_shown:
|
|
|
|
return False
|
|
|
|
else:
|
|
|
|
self._notification_shown = True
|
|
|
|
return True
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
2010-10-10 22:44:22 +02:00
|
|
|
def add_progress_callback(self, callback):
|
|
|
|
self._progress_updated = callback
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
def status_updated(self, count, blockSize, totalSize):
|
|
|
|
# We see a different "total size" while downloading,
|
|
|
|
# so correct the total size variable in the thread
|
|
|
|
if totalSize != self.total_size and totalSize > 0:
|
|
|
|
self.total_size = float(totalSize)
|
2011-07-16 17:25:44 +02:00
|
|
|
if self.__episode.file_size != self.total_size:
|
|
|
|
logger.debug('Updating file size of %s to %s',
|
|
|
|
self.filename, self.total_size)
|
|
|
|
self.__episode.file_size = self.total_size
|
|
|
|
self.__episode.save()
|
2009-04-01 01:12:17 +02:00
|
|
|
|
|
|
|
if self.total_size > 0:
|
2018-03-27 21:40:36 +02:00
|
|
|
self.progress = max(0.0, min(1.0, count * blockSize / self.total_size))
|
2012-10-23 13:10:27 +02:00
|
|
|
if self._progress_updated is not None:
|
|
|
|
diff = time.time() - self._last_progress_updated
|
|
|
|
if diff > self.MIN_TIME_BETWEEN_UPDATES or self.progress == 1.:
|
|
|
|
self._progress_updated(self.progress)
|
|
|
|
self._last_progress_updated = time.time()
|
2009-04-01 01:12:17 +02:00
|
|
|
|
|
|
|
self.calculate_speed(count, blockSize)
|
|
|
|
|
2021-08-30 16:45:20 +02:00
|
|
|
if self.status == DownloadTask.CANCELLING:
|
2007-09-18 20:25:25 +02:00
|
|
|
raise DownloadCancelledException()
|
|
|
|
|
2021-08-30 16:45:20 +02:00
|
|
|
if self.status == DownloadTask.PAUSING:
|
2009-04-01 01:12:17 +02:00
|
|
|
raise DownloadCancelledException()
|
|
|
|
|
|
|
|
def calculate_speed(self, count, blockSize):
|
2007-09-18 20:25:25 +02:00
|
|
|
if count % 5 == 0:
|
|
|
|
now = time.time()
|
2009-04-01 01:12:17 +02:00
|
|
|
if self.__start_time > 0:
|
2018-02-01 07:59:22 +01:00
|
|
|
# Has rate limiting been enabled or disabled?
|
2022-12-08 22:45:24 +01:00
|
|
|
if self.__limit_rate != self._config.limit.bandwidth.enabled:
|
2018-02-01 07:59:22 +01:00
|
|
|
# If it has been enabled then reset base time and block count
|
2022-12-08 22:45:24 +01:00
|
|
|
if self._config.limit.bandwidth.enabled:
|
2009-04-01 01:12:17 +02:00
|
|
|
self.__start_time = now
|
|
|
|
self.__start_blocks = count
|
2022-12-08 22:45:24 +01:00
|
|
|
self.__limit_rate = self._config.limit.bandwidth.enabled
|
2018-01-30 14:04:28 +01:00
|
|
|
|
2018-02-01 07:59:22 +01:00
|
|
|
# Has the rate been changed and are we currently limiting?
|
2022-12-08 22:41:32 +01:00
|
|
|
if self.__limit_rate_value != self._config.limit.bandwidth.kbps and self.__limit_rate:
|
2009-04-01 01:12:17 +02:00
|
|
|
self.__start_time = now
|
|
|
|
self.__start_blocks = count
|
2022-12-08 22:41:32 +01:00
|
|
|
self.__limit_rate_value = self._config.limit.bandwidth.kbps
|
2008-04-17 17:45:29 +02:00
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
passed = now - self.__start_time
|
2007-09-25 22:06:48 +02:00
|
|
|
if passed > 0:
|
2018-03-27 21:40:36 +02:00
|
|
|
speed = ((count - self.__start_blocks) * blockSize) / passed
|
2007-09-25 22:06:48 +02:00
|
|
|
else:
|
|
|
|
speed = 0
|
2007-09-18 20:25:25 +02:00
|
|
|
else:
|
2009-04-01 01:12:17 +02:00
|
|
|
self.__start_time = now
|
|
|
|
self.__start_blocks = count
|
|
|
|
passed = now - self.__start_time
|
2018-03-27 21:40:36 +02:00
|
|
|
speed = count * blockSize
|
2008-04-17 17:45:29 +02:00
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
self.speed = float(speed)
|
2007-09-18 20:25:25 +02:00
|
|
|
|
2022-12-08 22:45:24 +01:00
|
|
|
if self._config.limit.bandwidth.enabled and speed > self._config.limit.bandwidth.kbps:
|
2007-09-18 20:25:25 +02:00
|
|
|
# calculate the time that should have passed to reach
|
|
|
|
# the desired download rate and wait if necessary
|
2022-12-08 22:41:32 +01:00
|
|
|
should_have_passed = (count - self.__start_blocks) * blockSize / (self._config.limit.bandwidth.kbps * 1024.0)
|
2007-09-18 20:25:25 +02:00
|
|
|
if should_have_passed > passed:
|
|
|
|
# sleep a maximum of 10 seconds to not cause time-outs
|
2018-03-27 21:40:36 +02:00
|
|
|
delay = min(10.0, float(should_have_passed - passed))
|
2009-04-01 01:12:17 +02:00
|
|
|
time.sleep(delay)
|
2007-09-18 20:25:25 +02:00
|
|
|
|
2011-07-16 18:04:07 +02:00
|
|
|
def recycle(self):
|
2021-09-17 02:01:10 +02:00
|
|
|
if self.status not in (self.FAILED, self.PAUSED):
|
|
|
|
self.episode.download_task = None
|
2011-07-16 18:04:07 +02:00
|
|
|
|
2021-09-14 09:53:15 +02:00
|
|
|
def set_episode_download_task(self):
|
|
|
|
if not self.episode.download_task:
|
|
|
|
self.episode.download_task = self
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
def run(self):
|
|
|
|
# Speed calculation (re-)starts here
|
|
|
|
self.__start_time = 0
|
|
|
|
self.__start_blocks = 0
|
2007-09-18 20:25:25 +02:00
|
|
|
|
2021-08-31 15:37:36 +02:00
|
|
|
# If the download has already been cancelled/paused, skip it
|
2021-08-30 16:45:20 +02:00
|
|
|
with self:
|
|
|
|
if self.status == DownloadTask.CANCELLING:
|
2021-08-31 15:37:36 +02:00
|
|
|
self.status = DownloadTask.CANCELLED
|
2022-03-16 02:51:45 +01:00
|
|
|
self.__episode._download_error = None
|
2022-02-02 06:09:00 +01:00
|
|
|
self.delete_partial_files()
|
2021-08-30 16:45:20 +02:00
|
|
|
self.progress = 0.0
|
|
|
|
self.speed = 0.0
|
|
|
|
self.recycle()
|
|
|
|
return False
|
2009-02-09 23:26:47 +01:00
|
|
|
|
2021-08-31 15:37:36 +02:00
|
|
|
if self.status == DownloadTask.PAUSING:
|
|
|
|
self.status = DownloadTask.PAUSED
|
|
|
|
return False
|
|
|
|
|
2021-09-14 09:53:15 +02:00
|
|
|
# We only start this download if its status is downloading
|
|
|
|
if self.status != DownloadTask.DOWNLOADING:
|
2021-08-30 16:45:20 +02:00
|
|
|
return False
|
2009-02-09 23:26:47 +01:00
|
|
|
|
2021-08-30 16:45:20 +02:00
|
|
|
# We are downloading this file right now
|
|
|
|
self._notification_shown = False
|
2007-09-18 20:25:25 +02:00
|
|
|
|
2021-08-30 16:45:20 +02:00
|
|
|
# Restore a reference to this task in the episode
|
|
|
|
# when running a recycled task following a pause or failed
|
|
|
|
# see #649
|
2021-09-14 09:53:15 +02:00
|
|
|
self.set_episode_download_task()
|
2019-08-11 11:57:44 +02:00
|
|
|
|
2020-01-05 21:54:00 +01:00
|
|
|
url = self.__episode.url
|
2021-08-30 16:45:20 +02:00
|
|
|
result = DownloadTask.DOWNLOADING
|
2007-09-18 20:25:25 +02:00
|
|
|
try:
|
2021-03-28 13:54:16 +02:00
|
|
|
if url == '':
|
|
|
|
raise DownloadNoURLException()
|
|
|
|
|
2020-01-05 21:54:00 +01:00
|
|
|
if self.downloader:
|
|
|
|
downloader = self.downloader.custom_downloader(self._config, self.episode)
|
|
|
|
else:
|
|
|
|
downloader = registry.custom_downloader.resolve(self._config, None, self.episode)
|
2019-08-17 16:25:00 +02:00
|
|
|
|
2020-01-05 21:54:00 +01:00
|
|
|
if downloader:
|
|
|
|
logger.info('Downloading %s with %s', url, downloader)
|
2019-08-17 16:25:00 +02:00
|
|
|
else:
|
2020-01-05 21:54:00 +01:00
|
|
|
downloader = DefaultDownloader.custom_downloader(self._config, self.episode)
|
|
|
|
|
2022-10-27 22:57:05 +02:00
|
|
|
self.custom_downloader = downloader
|
2020-01-05 21:54:00 +01:00
|
|
|
headers, real_url = downloader.retrieve_resume(self.tempname, self.status_updated)
|
2009-04-01 01:12:17 +02:00
|
|
|
|
2010-12-20 14:35:46 +01:00
|
|
|
new_mimetype = headers.get('content-type', self.__episode.mime_type)
|
|
|
|
old_mimetype = self.__episode.mime_type
|
2010-04-09 02:28:11 +02:00
|
|
|
_basename, ext = os.path.splitext(self.filename)
|
|
|
|
if new_mimetype != old_mimetype or util.wrong_extension(ext):
|
2011-07-15 16:32:06 +02:00
|
|
|
logger.info('Updating mime type: %s => %s', old_mimetype, new_mimetype)
|
2009-04-01 01:12:17 +02:00
|
|
|
old_extension = self.__episode.extension()
|
2010-12-20 14:35:46 +01:00
|
|
|
self.__episode.mime_type = new_mimetype
|
2020-09-12 15:14:41 +02:00
|
|
|
# don't call local_filename because we'll get the old download name
|
|
|
|
new_extension = self.__episode.extension(may_call_local_filename=False)
|
2009-04-01 01:12:17 +02:00
|
|
|
|
2010-04-09 02:28:11 +02:00
|
|
|
# If the desired filename extension changed due to the new
|
|
|
|
# mimetype, we force an update of the local filename to fix the
|
|
|
|
# extension.
|
|
|
|
if old_extension != new_extension or util.wrong_extension(ext):
|
2009-04-01 01:12:17 +02:00
|
|
|
self.filename = self.__episode.local_filename(create=True, force_update=True)
|
|
|
|
|
2011-08-31 21:27:41 +02:00
|
|
|
# In some cases, the redirect of a URL causes the real filename to
|
|
|
|
# be revealed in the final URL (e.g. http://gpodder.org/bug/1423)
|
2011-09-18 22:04:33 +02:00
|
|
|
if real_url != url and not util.is_known_redirecter(real_url):
|
|
|
|
realname, realext = util.filename_from_url(real_url)
|
|
|
|
|
|
|
|
# Only update from redirect if the redirected-to filename has
|
|
|
|
# a proper extension (this is needed for e.g. YouTube)
|
|
|
|
if not util.wrong_extension(realext):
|
|
|
|
real_filename = ''.join((realname, realext))
|
|
|
|
self.filename = self.__episode.local_filename(create=True,
|
|
|
|
force_update=True, template=real_filename)
|
|
|
|
logger.info('Download was redirected (%s). New filename: %s',
|
|
|
|
real_url, os.path.basename(self.filename))
|
2009-09-06 16:38:40 +02:00
|
|
|
|
|
|
|
# Look at the Content-disposition header; use if if available
|
2020-07-12 17:01:24 +02:00
|
|
|
disposition_filename = util.get_header_param(headers, 'filename', 'content-disposition')
|
2016-01-15 15:08:22 +01:00
|
|
|
|
2011-10-04 10:36:43 +02:00
|
|
|
# Some servers do send the content-disposition header, but provide
|
|
|
|
# an empty filename, resulting in an empty string here (bug 1440)
|
|
|
|
if disposition_filename is not None and disposition_filename != '':
|
2009-09-06 16:38:40 +02:00
|
|
|
# The server specifies a download filename - try to use it
|
2019-01-27 12:30:46 +01:00
|
|
|
# filename_from_url to remove query string; see #591
|
|
|
|
fn, ext = util.filename_from_url(disposition_filename)
|
|
|
|
logger.debug("converting disposition filename '%s' to local filename '%s%s'", disposition_filename, fn, ext)
|
|
|
|
disposition_filename = fn + ext
|
2018-05-08 10:43:56 +02:00
|
|
|
self.filename = self.__episode.local_filename(create=True,
|
2009-09-06 16:38:40 +02:00
|
|
|
force_update=True, template=disposition_filename)
|
|
|
|
new_mimetype, encoding = mimetypes.guess_type(self.filename)
|
|
|
|
if new_mimetype is not None:
|
2011-07-15 16:32:06 +02:00
|
|
|
logger.info('Using content-disposition mimetype: %s',
|
|
|
|
new_mimetype)
|
2012-12-28 18:31:39 +01:00
|
|
|
self.__episode.mime_type = new_mimetype
|
2009-09-06 16:38:40 +02:00
|
|
|
|
2011-08-07 23:39:46 +02:00
|
|
|
# Re-evaluate filename and tempname to take care of podcast renames
|
|
|
|
# while downloads are running (which will change both file names)
|
|
|
|
self.filename = self.__episode.local_filename(create=False)
|
2011-08-31 21:27:41 +02:00
|
|
|
self.tempname = os.path.join(os.path.dirname(self.filename),
|
|
|
|
os.path.basename(self.tempname))
|
2009-04-01 01:12:17 +02:00
|
|
|
shutil.move(self.tempname, self.filename)
|
|
|
|
|
2010-04-26 21:41:50 +02:00
|
|
|
# Model- and database-related updates after a download has finished
|
|
|
|
self.__episode.on_downloaded(self.filename)
|
2007-09-18 20:25:25 +02:00
|
|
|
except DownloadCancelledException:
|
2011-07-15 16:32:06 +02:00
|
|
|
logger.info('Download has been cancelled/paused: %s', self)
|
2021-08-30 16:45:20 +02:00
|
|
|
if self.status == DownloadTask.CANCELLING:
|
2022-03-16 02:51:45 +01:00
|
|
|
self.__episode._download_error = None
|
2022-02-02 06:09:00 +01:00
|
|
|
self.delete_partial_files()
|
2009-04-01 01:12:17 +02:00
|
|
|
self.progress = 0.0
|
|
|
|
self.speed = 0.0
|
2021-08-30 16:45:20 +02:00
|
|
|
result = DownloadTask.CANCELLED
|
2021-03-28 13:54:16 +02:00
|
|
|
except DownloadNoURLException:
|
2021-08-30 16:45:20 +02:00
|
|
|
result = DownloadTask.FAILED
|
2021-03-28 13:54:16 +02:00
|
|
|
self.error_message = _('Episode has no URL to download')
|
2016-11-21 23:13:46 +01:00
|
|
|
except urllib.error.ContentTooShortError as ctse:
|
2021-08-30 16:45:20 +02:00
|
|
|
result = DownloadTask.FAILED
|
2009-06-10 01:57:35 +02:00
|
|
|
self.error_message = _('Missing content from server')
|
2020-07-12 17:03:05 +02:00
|
|
|
except ConnectionError as ce:
|
|
|
|
# special case request exception
|
2021-08-30 16:45:20 +02:00
|
|
|
result = DownloadTask.FAILED
|
2023-01-26 00:44:06 +01:00
|
|
|
logger.error('Download failed: %s', str(ce))
|
2020-07-12 17:03:05 +02:00
|
|
|
d = {'host': ce.args[0].pool.host, 'port': ce.args[0].pool.port}
|
|
|
|
self.error_message = _("Couldn't connect to server %(host)s:%(port)s" % d)
|
|
|
|
except RequestException as re:
|
|
|
|
# extract MaxRetryError to shorten the exception message
|
|
|
|
if isinstance(re.args[0], MaxRetryError):
|
|
|
|
re = re.args[0]
|
|
|
|
logger.error('%s while downloading "%s"', str(re),
|
2023-01-26 00:44:06 +01:00
|
|
|
self.__episode.title)
|
2021-08-30 16:45:20 +02:00
|
|
|
result = DownloadTask.FAILED
|
2020-07-12 17:03:05 +02:00
|
|
|
d = {'error': str(re)}
|
|
|
|
self.error_message = _('Request Error: %(error)s') % d
|
2016-11-21 23:13:46 +01:00
|
|
|
except IOError as ioe:
|
2011-07-15 16:32:06 +02:00
|
|
|
logger.error('%s while downloading "%s": %s', ioe.strerror,
|
2023-01-26 00:44:06 +01:00
|
|
|
self.__episode.title, ioe.filename)
|
2021-08-30 16:45:20 +02:00
|
|
|
result = DownloadTask.FAILED
|
2010-01-18 21:20:22 +01:00
|
|
|
d = {'error': ioe.strerror, 'filename': ioe.filename}
|
|
|
|
self.error_message = _('I/O Error: %(error)s: %(filename)s') % d
|
2016-11-21 23:13:46 +01:00
|
|
|
except gPodderDownloadHTTPError as gdhe:
|
2011-07-15 16:32:06 +02:00
|
|
|
logger.error('HTTP %s while downloading "%s": %s',
|
2023-01-26 00:44:06 +01:00
|
|
|
gdhe.error_code, self.__episode.title, gdhe.error_message)
|
2021-08-30 16:45:20 +02:00
|
|
|
result = DownloadTask.FAILED
|
2010-01-18 21:20:22 +01:00
|
|
|
d = {'code': gdhe.error_code, 'message': gdhe.error_message}
|
|
|
|
self.error_message = _('HTTP Error %(code)s: %(message)s') % d
|
2016-11-21 23:13:46 +01:00
|
|
|
except Exception as e:
|
2021-08-30 16:45:20 +02:00
|
|
|
result = DownloadTask.FAILED
|
2011-07-15 16:32:06 +02:00
|
|
|
logger.error('Download failed: %s', str(e), exc_info=True)
|
2010-12-18 14:50:43 +01:00
|
|
|
self.error_message = _('Error: %s') % (str(e),)
|
2009-04-01 01:12:17 +02:00
|
|
|
|
2021-08-30 16:45:20 +02:00
|
|
|
with self:
|
|
|
|
if result == DownloadTask.DOWNLOADING:
|
|
|
|
# Everything went well - we're done (even if the task was cancelled/paused,
|
|
|
|
# since it's finished we might as well mark it done)
|
|
|
|
self.status = DownloadTask.DONE
|
|
|
|
if self.total_size <= 0:
|
|
|
|
self.total_size = util.calculate_size(self.filename)
|
|
|
|
logger.info('Total size updated to %d', self.total_size)
|
|
|
|
self.progress = 1.0
|
|
|
|
gpodder.user_extensions.on_episode_downloaded(self.__episode)
|
|
|
|
return True
|
2021-05-20 14:32:31 +02:00
|
|
|
|
2021-08-30 16:45:20 +02:00
|
|
|
self.speed = 0.0
|
2018-01-30 14:04:28 +01:00
|
|
|
|
2021-09-14 09:53:15 +02:00
|
|
|
if result == DownloadTask.FAILED:
|
|
|
|
self.status = DownloadTask.FAILED
|
|
|
|
self.__episode._download_error = self.error_message
|
|
|
|
|
|
|
|
# cancelled/paused -- update state to mark it as safe to manipulate this task again
|
|
|
|
elif self.status == DownloadTask.PAUSING:
|
2021-08-30 16:45:20 +02:00
|
|
|
self.status = DownloadTask.PAUSED
|
|
|
|
elif self.status == DownloadTask.CANCELLING:
|
|
|
|
self.status = DownloadTask.CANCELLED
|
2009-04-01 01:12:17 +02:00
|
|
|
|
|
|
|
# We finished, but not successfully (at least not really)
|
|
|
|
return False
|