~ubuntu-branches/ubuntu/natty/miro/natty

« back to all changes in this revision

Viewing changes to lib/dl_daemon/download.py

  • Committer: Bazaar Package Importer
  • Author(s): Bryce Harrington
  • Date: 2011-01-22 02:46:33 UTC
  • mfrom: (1.4.10 upstream) (1.7.5 experimental)
  • Revision ID: james.westby@ubuntu.com-20110122024633-kjme8u93y2il5nmf
Tags: 3.5.1-1ubuntu1
* Merge from debian.  Remaining ubuntu changes:
  - Use python 2.7 instead of python 2.6
  - Relax dependency on python-dbus to >= 0.83.0

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Miro - an RSS based video player application
 
2
# Copyright (C) 2005-2010 Participatory Culture Foundation
 
3
#
 
4
# This program is free software; you can redistribute it and/or modify
 
5
# it under the terms of the GNU General Public License as published by
 
6
# the Free Software Foundation; either version 2 of the License, or
 
7
# (at your option) any later version.
 
8
#
 
9
# This program is distributed in the hope that it will be useful,
 
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
12
# GNU General Public License for more details.
 
13
#
 
14
# You should have received a copy of the GNU General Public License
 
15
# along with this program; if not, write to the Free Software
 
16
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301 USA
 
17
#
 
18
# In addition, as a special exception, the copyright holders give
 
19
# permission to link the code of portions of this program with the OpenSSL
 
20
# library.
 
21
#
 
22
# You must obey the GNU General Public License in all respects for all of
 
23
# the code used other than OpenSSL. If you modify file(s) with this
 
24
# exception, you may extend this exception to your version of the file(s),
 
25
# but you are not obligated to do so. If you do not wish to do so, delete
 
26
# this exception statement from your version. If you delete this exception
 
27
# statement from all source files in the program, then also delete it here.
 
28
 
 
29
import os
 
30
import re
 
31
import stat
 
32
from threading import RLock
 
33
from copy import copy
 
34
import sys
 
35
import datetime
 
36
import logging
 
37
 
 
38
from miro.gtcache import gettext as _
 
39
 
 
40
import libtorrent as lt
 
41
from miro.clock import clock
 
42
from miro.download_utils import (
 
43
    clean_filename, next_free_filename, check_filename_extension,
 
44
    filter_directory_name, filename_from_url, get_file_url_path)
 
45
from miro import eventloop
 
46
from miro import httpclient
 
47
from miro import fileutil
 
48
 
 
49
from miro import config
 
50
from miro import prefs
 
51
 
 
52
from miro.dl_daemon import command, daemon
 
53
from miro.util import check_f, check_u, stringify, MAX_TORRENT_SIZE
 
54
from miro.plat.utils import get_available_bytes_for_movies, utf8_to_filename
 
55
 
 
56
chatter = True
 
57
 
 
58
# a hash of download ids to downloaders
 
59
_downloads = {}
 
60
 
 
61
_lock = RLock()
 
62
 
 
63
def config_received():
 
64
    TORRENT_SESSION.startup()
 
65
 
 
66
def create_downloader(url, contentType, dlid):
 
67
    check_u(url)
 
68
    check_u(contentType)
 
69
    if contentType == u'application/x-bittorrent':
 
70
        return BTDownloader(url, dlid)
 
71
    else:
 
72
        return HTTPDownloader(url, dlid, expectedContentType=contentType)
 
73
 
 
74
def start_new_download(url, dlid, contentType, channelName):
 
75
    """Creates a new downloader object.
 
76
 
 
77
    Returns id on success, None on failure.
 
78
    """
 
79
    check_u(url)
 
80
    check_u(contentType)
 
81
    if channelName:
 
82
        check_f(channelName)
 
83
    dl = create_downloader(url, contentType, dlid)
 
84
    dl.channelName = channelName
 
85
    _downloads[dlid] = dl
 
86
 
 
87
def pause_download(dlid):
 
88
    try:
 
89
        download = _downloads[dlid]
 
90
    except KeyError:
 
91
        # There is no download with this id
 
92
        return True
 
93
    return download.pause()
 
94
 
 
95
def info_hash_to_long(info_hash):
 
96
    """The info_hash() method from libtorrent returns a "big_number" object.
 
97
    This doesn't hash very well: different instances with the same value
 
98
    will have different hashes.  So we need to convert them to long objects,
 
99
    though this weird process.
 
100
    """
 
101
    return long(str(info_hash), 16)
 
102
 
 
103
def start_download(dlid):
 
104
    try:
 
105
        download = _downloads[dlid]
 
106
    except KeyError:
 
107
        # There is no download with this id
 
108
        err = u"in start_download(): no downloader with id %s" % dlid
 
109
        c = command.DownloaderErrorCommand(daemon.LAST_DAEMON, err)
 
110
        c.send()
 
111
        return True
 
112
    return download.start()
 
113
 
 
114
def stop_download(dlid, delete):
 
115
    _lock.acquire()
 
116
    try:
 
117
        download = _downloads[dlid]
 
118
        del _downloads[dlid]
 
119
    except KeyError:
 
120
        # There is no download with this id
 
121
        return True
 
122
    finally:
 
123
        _lock.release()
 
124
 
 
125
    return download.stop(delete)
 
126
 
 
127
def stop_upload(dlid):
 
128
    _lock.acquire()
 
129
    try:
 
130
        download = _downloads[dlid]
 
131
        if download.state not in (u"uploading", u"uploading-paused"):
 
132
            return
 
133
        del _downloads[dlid]
 
134
    except KeyError:
 
135
        # There is no download with this id
 
136
        return
 
137
    finally:
 
138
        _lock.release()
 
139
    return download.stop_upload()
 
140
 
 
141
def pause_upload(dlid):
 
142
    _lock.acquire()
 
143
    try:
 
144
        download = _downloads[dlid]
 
145
        if download.state != u"uploading":
 
146
            return
 
147
        del _downloads[dlid]
 
148
    except KeyError:
 
149
        # There is no download with this id
 
150
        return
 
151
    finally:
 
152
        _lock.release()
 
153
    return download.pause_upload()
 
154
 
 
155
def migrate_download(dlid, directory):
 
156
    check_f(directory)
 
157
    try:
 
158
        download = _downloads[dlid]
 
159
    except KeyError:
 
160
        # There is no download with this id
 
161
        return
 
162
 
 
163
    if download.state in (u"finished", u"uploading", u"uploading-paused"):
 
164
        download.move_to_directory(directory)
 
165
 
 
166
def get_download_status(dlids=None):
 
167
    statuses = {}
 
168
    for key in _downloads.keys():
 
169
        if dlids is None or dlids == key or key in dlids:
 
170
            try:
 
171
                statuses[key] = _downloads[key].get_status()
 
172
            except KeyError:
 
173
                pass
 
174
    return statuses
 
175
 
 
176
def shutdown():
 
177
    logging.info("Shutting down downloaders...")
 
178
    for dlid in _downloads:
 
179
        _downloads[dlid].shutdown()
 
180
    logging.info("Shutting down torrent session...")
 
181
    TORRENT_SESSION.shutdown()
 
182
    logging.info("shutdown() finished")
 
183
 
 
184
def restore_downloader(downloader):
 
185
    if downloader['dlid'] in _downloads:
 
186
        logging.warn("Not restarting active downloader: %s",
 
187
                downloader['dlid'])
 
188
        return
 
189
 
 
190
    downloader = copy(downloader)
 
191
    dler_type = downloader.get('dlerType')
 
192
    if dler_type == u'HTTP':
 
193
        dl = HTTPDownloader(restore=downloader)
 
194
    elif dler_type == u'BitTorrent':
 
195
        dl = BTDownloader(restore=downloader)
 
196
    else:
 
197
        err = u"in restore_downloader(): unknown dlerType: %s" % dler_type
 
198
        c = command.DownloaderErrorCommand(daemon.LAST_DAEMON, err)
 
199
        c.send()
 
200
        return
 
201
 
 
202
    _downloads[downloader['dlid']] = dl
 
203
 
 
204
class TorrentSession(object):
 
205
    """Contains the bittorrent session and handles updating all
 
206
    running bittorrents.
 
207
    """
 
208
    def __init__(self):
 
209
        self.torrents = set()
 
210
        self.info_hash_to_downloader = {}
 
211
        self.session = None
 
212
        self.pnp_on = None
 
213
        self.pe_set = None
 
214
        self.enc_req = None
 
215
 
 
216
    def startup(self):
 
217
        fingerprint = lt.fingerprint("MR", 1, 1, 0, 0)
 
218
        self.session = lt.session(fingerprint)
 
219
        self.listen()
 
220
        self.set_upnp()
 
221
        self.set_upload_limit()
 
222
        self.set_download_limit()
 
223
        self.set_encryption()
 
224
        config.add_change_callback(self.config_changed)
 
225
 
 
226
    def listen(self):
 
227
        self.session.listen_on(config.get(prefs.BT_MIN_PORT),
 
228
                               config.get(prefs.BT_MAX_PORT))
 
229
 
 
230
    def set_upnp(self):
 
231
        use_upnp = config.get(prefs.USE_UPNP)
 
232
        if use_upnp == self.pnp_on:
 
233
            return
 
234
        self.pnp_on = use_upnp
 
235
        if use_upnp:
 
236
            self.session.start_upnp()
 
237
        else:
 
238
            self.session.stop_upnp()
 
239
 
 
240
    def set_upload_limit(self):
 
241
        limit = -1
 
242
        if config.get(prefs.LIMIT_UPSTREAM):
 
243
            limit = config.get(prefs.UPSTREAM_LIMIT_IN_KBS)
 
244
            limit = limit * (2 ** 10)
 
245
            if limit > sys.maxint:
 
246
                # avoid OverflowErrors by keeping the value an integer
 
247
                limit = sys.maxint
 
248
        self.session.set_upload_rate_limit(limit)
 
249
 
 
250
    def set_download_limit(self):
 
251
        limit = -1
 
252
        if config.get(prefs.LIMIT_DOWNSTREAM_BT):
 
253
            limit = config.get(prefs.DOWNSTREAM_BT_LIMIT_IN_KBS)
 
254
            limit = limit * (2 ** 10)
 
255
            if limit > sys.maxint:
 
256
                # avoid OverflowErrors by keeping the value an integer
 
257
                limit = sys.maxint
 
258
        self.session.set_download_rate_limit(limit)
 
259
 
 
260
    def set_connection_limit(self):
 
261
        limit = -1
 
262
        if config.get(prefs.LIMIT_CONNECTIONS_BT):
 
263
            limit = config.get(prefs.CONNECTION_LIMIT_BT_NUM)
 
264
            if limit > 65536:
 
265
                # there are only 2**16 TCP port numbers
 
266
                limit = 65536
 
267
        self.session.set_max_connections(limit)
 
268
 
 
269
    def set_encryption(self):
 
270
        if self.pe_set is None:
 
271
            self.pe_set = lt.pe_settings()
 
272
        enc_req = config.get(prefs.BT_ENC_REQ)
 
273
        if enc_req != self.enc_req:
 
274
            self.enc_req = enc_req
 
275
            if enc_req:
 
276
                self.pe_set.in_enc_policy = lt.enc_policy.forced
 
277
                self.pe_set.out_enc_policy = lt.enc_policy.forced
 
278
            else:
 
279
                self.pe_set.in_enc_policy = lt.enc_policy.enabled
 
280
                self.pe_set.out_enc_policy = lt.enc_policy.enabled
 
281
            self.session.set_pe_settings(self.pe_set)
 
282
 
 
283
    def shutdown(self):
 
284
        self.session.stop_upnp()
 
285
        config.remove_change_callback(self.config_changed)
 
286
 
 
287
    def config_changed(self, key, value):
 
288
        if key == prefs.BT_MIN_PORT.key:
 
289
            if value > self.session.listen_port():
 
290
                self.listen()
 
291
        elif key == prefs.BT_MAX_PORT.key:
 
292
            if value < self.session.listen_port():
 
293
                self.listen()
 
294
        elif key == prefs.USE_UPNP.key:
 
295
            self.set_upnp()
 
296
        elif key in (prefs.LIMIT_UPSTREAM.key,
 
297
                     prefs.UPSTREAM_LIMIT_IN_KBS.key):
 
298
            self.set_upload_limit()
 
299
        elif key in (prefs.LIMIT_DOWNSTREAM_BT.key,
 
300
                     prefs.DOWNSTREAM_BT_LIMIT_IN_KBS.key):
 
301
            self.set_download_limit()
 
302
        elif key == prefs.BT_ENC_REQ.key:
 
303
            self.set_encryption()
 
304
        elif key in (prefs.LIMIT_CONNECTIONS_BT.key,
 
305
                     prefs.CONNECTION_LIMIT_BT_NUM.key):
 
306
            self.set_connection_limit()
 
307
 
 
308
    def find_duplicate_torrent(self, torrent_info):
 
309
        info_hash = info_hash_to_long(torrent_info.info_hash())
 
310
        return self.info_hash_to_downloader.get(info_hash)
 
311
 
 
312
    def add_torrent(self, downloader):
 
313
        self.torrents.add(downloader)
 
314
        info_hash = info_hash_to_long(downloader.torrent.info_hash())
 
315
        self.info_hash_to_downloader[info_hash] = downloader
 
316
 
 
317
    def remove_torrent(self, downloader):
 
318
        if downloader in self.torrents:
 
319
            self.torrents.remove(downloader)
 
320
            info_hash = info_hash_to_long(downloader.torrent.info_hash())
 
321
            del self.info_hash_to_downloader[info_hash]
 
322
 
 
323
    def update_torrents(self):
 
324
        # Copy this set into a list in case any of the torrents gets
 
325
        # removed during the iteration.
 
326
        for torrent in [x for x in self.torrents]:
 
327
            torrent.update_status()
 
328
 
 
329
TORRENT_SESSION = TorrentSession()
 
330
 
 
331
class DownloadStatusUpdater(object):
 
332
    """Handles updating status for all in progress downloaders.
 
333
 
 
334
    On OS X and gtk if the user is on the downloads page and has a
 
335
    bunch of downloads going, this can be a fairly CPU intensive task.
 
336
    DownloadStatusUpdaters mitigate this in 2 ways.
 
337
 
 
338
    1. DownloadStatusUpdater objects batch all status updates into one
 
339
       big update which takes much less CPU.
 
340
 
 
341
    2. The update don't happen fairly infrequently (currently every 5
 
342
       seconds).
 
343
 
 
344
    Because updates happen infrequently, DownloadStatusUpdaters should
 
345
    only be used for progress updates, not events like downloads
 
346
    starting/finishing.  For those just call update_client() since they
 
347
    are more urgent, and don't happen often enough to cause CPU
 
348
    problems.
 
349
    """
 
350
 
 
351
    UPDATE_CLIENT_INTERVAL = 1
 
352
 
 
353
    def __init__(self):
 
354
        self.to_update = set()
 
355
 
 
356
    def start_updates(self):
 
357
        eventloop.add_timeout(self.UPDATE_CLIENT_INTERVAL, self.do_update,
 
358
                "Download status update")
 
359
 
 
360
    def do_update(self):
 
361
        try:
 
362
            TORRENT_SESSION.update_torrents()
 
363
            statuses = []
 
364
            for downloader in self.to_update:
 
365
                statuses.append(downloader.get_status())
 
366
            self.to_update = set()
 
367
            if statuses:
 
368
                command.BatchUpdateDownloadStatus(daemon.LAST_DAEMON,
 
369
                        statuses).send()
 
370
        finally:
 
371
            eventloop.add_timeout(self.UPDATE_CLIENT_INTERVAL, self.do_update,
 
372
                    "Download status update")
 
373
 
 
374
    def queue_update(self, downloader):
 
375
        self.to_update.add(downloader)
 
376
 
 
377
DOWNLOAD_UPDATER = DownloadStatusUpdater()
 
378
 
 
379
RETRY_TIMES = (
 
380
    60,
 
381
    5 * 60,
 
382
    10 * 60,
 
383
    30 * 60,
 
384
    60 * 60,
 
385
    2 * 60 * 60,
 
386
    6 * 60 * 60,
 
387
    24 * 60 * 60
 
388
    )
 
389
 
 
390
class BGDownloader(object):
 
391
    def __init__(self, url, dlid):
 
392
        self.dlid = dlid
 
393
        self.url = url
 
394
        self.startTime = clock()
 
395
        self.endTime = self.startTime
 
396
        self.shortFilename = filename_from_url(url)
 
397
        self.pick_initial_filename()
 
398
        self.state = u"downloading"
 
399
        self.currentSize = 0
 
400
        self.totalSize = -1
 
401
        self.shortReasonFailed = self.reasonFailed = u"No Error"
 
402
        self.retryTime = None
 
403
        self.retryCount = -1
 
404
 
 
405
    def get_url(self):
 
406
        return self.url
 
407
 
 
408
    def get_status(self):
 
409
        return {'dlid': self.dlid,
 
410
            'url': self.url,
 
411
            'state': self.state,
 
412
            'totalSize': self.totalSize,
 
413
            'currentSize': self.currentSize,
 
414
            'eta': self.get_eta(),
 
415
            'rate': self.get_rate(),
 
416
            'uploaded': 0,
 
417
            'filename': self.filename,
 
418
            'startTime': self.startTime,
 
419
            'endTime': self.endTime,
 
420
            'shortFilename': self.shortFilename,
 
421
            'reasonFailed': self.reasonFailed,
 
422
            'shortReasonFailed': self.shortReasonFailed,
 
423
            'dlerType': None,
 
424
            'retryTime': self.retryTime,
 
425
            'retryCount': self.retryCount,
 
426
            'channelName': self.channelName}
 
427
 
 
428
    def update_client(self):
 
429
        x = command.UpdateDownloadStatus(daemon.LAST_DAEMON, self.get_status())
 
430
        return x.send()
 
431
 
 
432
    def pick_initial_filename(self, suffix=".part", torrent=False):
 
433
        """Pick a path to download to based on self.shortFilename.
 
434
 
 
435
        This method sets self.filename, as well as creates any leading
 
436
        paths needed to start downloading there.
 
437
 
 
438
        If the torrent flag is true, then the filename we're working
 
439
        with is utf-8 and shouldn't be transformed in any way.
 
440
 
 
441
        If the torrent flag is false, then the filename we're working
 
442
        with is ascii and needs to be transformed into something sane.
 
443
        (default)
 
444
        """
 
445
        download_dir = os.path.join(config.get(prefs.MOVIES_DIRECTORY),
 
446
                                    'Incomplete Downloads')
 
447
        # Create the download directory if it doesn't already exist.
 
448
        if not os.path.exists(download_dir):
 
449
            fileutil.makedirs(download_dir)
 
450
        filename = self.shortFilename + suffix
 
451
        if not torrent:
 
452
            # this is an ascii filename and needs to be fixed
 
453
            filename = clean_filename(filename)
 
454
        self.filename = next_free_filename(
 
455
            os.path.join(download_dir, filename))
 
456
 
 
457
    def move_to_movies_directory(self):
 
458
        """Move our downloaded file from the Incomplete Downloads
 
459
        directory to the movies directory.
 
460
        """
 
461
        if chatter:
 
462
            logging.info("move_to_movies_directory: filename is %s",
 
463
                         self.filename)
 
464
        self.move_to_directory(config.get(prefs.MOVIES_DIRECTORY))
 
465
 
 
466
    def move_to_directory(self, directory):
 
467
        check_f(directory)
 
468
        if self.channelName:
 
469
            channel_name = filter_directory_name(self.channelName)
 
470
            # bug 10769: shutil and windows has problems with long
 
471
            # filenames, so we clip the directory name.
 
472
            if len(channel_name) > 80:
 
473
                channel_name = channel_name[:80]
 
474
            directory = os.path.join(directory, channel_name)
 
475
            if not os.path.exists(directory):
 
476
                try:
 
477
                    fileutil.makedirs(directory)
 
478
                except (SystemExit, KeyboardInterrupt):
 
479
                    raise
 
480
                except:
 
481
                    pass
 
482
        newfilename = os.path.join(directory, self.shortFilename)
 
483
        if newfilename == self.filename:
 
484
            return
 
485
        newfilename = next_free_filename(newfilename)
 
486
        def callback():
 
487
            self.filename = newfilename
 
488
            self.update_client()
 
489
        fileutil.migrate_file(self.filename, newfilename, callback)
 
490
 
 
491
    def get_eta(self):
 
492
        """Returns a float with the estimated number of seconds left.
 
493
        """
 
494
        if self.totalSize == -1:
 
495
            return -1
 
496
        rate = self.get_rate()
 
497
        if rate > 0:
 
498
            return (self.totalSize - self.currentSize) / rate
 
499
        else:
 
500
            return 0
 
501
 
 
502
    def get_rate(self):
 
503
        """Returns a float with the download rate in bytes per second
 
504
        """
 
505
        if self.endTime != self.startTime:
 
506
            rate = self.currentSize / (self.endTime - self.startTime)
 
507
        else:
 
508
            rate = self.rate
 
509
        return rate
 
510
 
 
511
    def retry_download(self):
 
512
        self.retryDC = None
 
513
        self.start(resume=False)
 
514
 
 
515
    def handle_temporary_error(self, shortReason, reason):
 
516
        self.state = u"offline"
 
517
        self.endTime = self.startTime = 0
 
518
        self.rate = 0
 
519
        self.reasonFailed = reason
 
520
        self.shortReasonFailed = shortReason
 
521
        self.retryCount = self.retryCount + 1
 
522
        if self.retryCount >= len(RETRY_TIMES):
 
523
            self.retryCount = len(RETRY_TIMES) - 1
 
524
        self.retryDC = eventloop.add_timeout(
 
525
            RETRY_TIMES[self.retryCount], self.retry_download,
 
526
            "Logarithmic retry")
 
527
        now = datetime.datetime.now()
 
528
        self.retryTime = now + datetime.timedelta(seconds=RETRY_TIMES[self.retryCount])
 
529
        logging.info("Temporary error: '%s' '%s'.  retrying at %s %s",
 
530
                     shortReason, reason, self.retryTime, self.retryCount)
 
531
        self.update_client()
 
532
 
 
533
    def handle_error(self, shortReason, reason):
 
534
        self.state = u"failed"
 
535
        self.reasonFailed = reason
 
536
        self.shortReasonFailed = shortReason
 
537
        self.update_client()
 
538
 
 
539
    def handle_network_error(self, error):
 
540
        if isinstance(error, httpclient.NetworkError):
 
541
            if (isinstance(error, httpclient.MalformedURL)
 
542
                 or isinstance(error, httpclient.UnknownHostError)
 
543
                 or isinstance(error, httpclient.AuthorizationFailed)
 
544
                 or isinstance(error, httpclient.ProxyAuthorizationFailed)
 
545
                 or isinstance(error, httpclient.UnexpectedStatusCode)):
 
546
                self.handle_error(error.getFriendlyDescription(),
 
547
                                  error.getLongDescription())
 
548
                self.retryCount = -1 # reset retryCount
 
549
            else:
 
550
                self.handle_temporary_error(error.getFriendlyDescription(),
 
551
                                            error.getLongDescription())
 
552
        else:
 
553
            logging.info("WARNING: grab_url errback not called with "
 
554
                         "NetworkError")
 
555
            self.handle_error(str(error), str(error))
 
556
 
 
557
    def handle_generic_error(self, longDescription):
 
558
        self.handle_error(_("Error"), longDescription)
 
559
 
 
560
    def accept_download_size(self, size):
 
561
        """Checks the download file size to see if we can accept it
 
562
        based on the user disk space preservation preference
 
563
        """
 
564
        accept = True
 
565
        if config.get(prefs.PRESERVE_DISK_SPACE):
 
566
            if size < 0:
 
567
                size = 0
 
568
            preserved = (config.get(prefs.PRESERVE_X_GB_FREE) *
 
569
                         1024 * 1024 * 1024)
 
570
            available = get_available_bytes_for_movies() - preserved
 
571
            accept = (size <= available)
 
572
        return accept
 
573
 
 
574
class HTTPDownloader(BGDownloader):
 
575
    CHECK_STATS_TIMEOUT = 1.0
 
576
 
 
577
    def __init__(self, url=None, dlid=None, restore=None,
 
578
                 expectedContentType=None):
 
579
        self.retryDC = None
 
580
        self.channelName = None
 
581
        self.expectedContentType = expectedContentType
 
582
        if restore is not None:
 
583
            if not isinstance(restore.get('totalSize', 0), int):
 
584
                # Sometimes restoring old downloaders caused errors
 
585
                # because their totalSize wasn't an int.  (see #3965)
 
586
                restore['totalSize'] = int(restore['totalSize'])
 
587
            self.__dict__.update(restore)
 
588
            self.restartOnError = True
 
589
        else:
 
590
            BGDownloader.__init__(self, url, dlid)
 
591
            self.restartOnError = False
 
592
        self.client = None
 
593
        self.rate = 0
 
594
        if self.state == 'downloading':
 
595
            self.start_download()
 
596
        elif self.state == 'offline':
 
597
            self.start()
 
598
        else:
 
599
            self.update_client()
 
600
 
 
601
    def start_new_download(self):
 
602
        """Start a download, discarding any existing data"""
 
603
        self.currentSize = 0
 
604
        self.totalSize = -1
 
605
        self.start_download(resume=False)
 
606
 
 
607
    def start_download(self, resume=True):
 
608
        if self.retryDC:
 
609
            self.retryDC.cancel()
 
610
            self.retryDC = None
 
611
        if resume:
 
612
            resume = self._resume_sanity_check()
 
613
 
 
614
        logging.info("start_download: %s", self.url)
 
615
 
 
616
        self.client = httpclient.grab_url(
 
617
            self.url, self.on_download_finished, self.on_download_error,
 
618
            header_callback=self.on_headers, write_file=self.filename,
 
619
            resume=resume)
 
620
        self.update_client()
 
621
        eventloop.add_timeout(self.CHECK_STATS_TIMEOUT, self.update_stats,
 
622
                'update http downloader stats')
 
623
 
 
624
    def _resume_sanity_check(self):
 
625
        """Do sanity checks to test if we should try HTTP Resume.
 
626
 
 
627
        :returns: If we should still try HTTP resume
 
628
        """
 
629
        if not os.path.exists(self.filename):
 
630
            return False
 
631
        # sanity check that the file we're resuming from is the right
 
632
        # size.  In particular, before the libcurl change, we would
 
633
        # preallocate the entire file, so we need to undo this.
 
634
        file_size = os.stat(self.filename)[stat.ST_SIZE]
 
635
        if file_size > self.currentSize:
 
636
            # use logging.info rather than warn, since this is the
 
637
            # usual case from upgrading from 3.0.x to 3.1
 
638
            logging.info("File larger than currentSize: truncating.  "
 
639
                    "url: %s, path: %s.", self.url, self.filename)
 
640
            f = open(self.filename, "ab")
 
641
            f.truncate(self.currentSize)
 
642
            f.close()
 
643
        elif file_size < self.currentSize:
 
644
            # Data got deleted somehow.  Let's start over.
 
645
            logging.warn("File doesn't contain enough data to resume.  "
 
646
                    "url: %s, path: %s.", self.url, self.filename)
 
647
            return False
 
648
        return True
 
649
 
 
650
    def destroy_client(self):
 
651
        """update the stats before we throw away the client.
 
652
        """
 
653
        self.update_stats()
 
654
        self.client = None
 
655
 
 
656
    def cancel_request(self, remove_file=False):
 
657
        if self.client is not None:
 
658
            self.client.cancel(remove_file=remove_file)
 
659
            self.destroy_client()
 
660
        # if it's in a retrying state, we want to nix that, too
 
661
        if self.retryDC:
 
662
            self.retryDC.cancel()
 
663
            self.retryDC = None
 
664
 
 
665
    def handle_error(self, shortReason, reason):
 
666
        BGDownloader.handle_error(self, shortReason, reason)
 
667
        self.cancel_request()
 
668
        if os.path.exists(self.filename):
 
669
            try:
 
670
                fileutil.remove(self.filename)
 
671
            except OSError:
 
672
                pass
 
673
        self.currentSize = 0
 
674
        self.totalSize = -1
 
675
 
 
676
    def handle_temporary_error(self, shortReason, reason):
 
677
        self.cancel_request()
 
678
        BGDownloader.handle_temporary_error(self, shortReason, reason)
 
679
 
 
680
    def handle_write_error(self, error):
 
681
        text = (_("Could not write to %(filename)s") %
 
682
                {"filename": stringify(self.filename)})
 
683
        self.handle_generic_error(text)
 
684
 
 
685
    def on_headers(self, info):
 
686
        if 'total-size' in info:
 
687
            self.totalSize = info['total-size']
 
688
        if not self.accept_download_size(self.totalSize):
 
689
            self.handle_error(_("Not enough disk space"),
 
690
                _("%(amount)s MB required to store this video") %
 
691
                  {"amount": self.totalSize / (2 ** 20)})
 
692
            return
 
693
        # We should successfully download the file.  Reset retryCount
 
694
        # and accept defeat if we see an error.
 
695
        self.restartOnError = False
 
696
        # update shortFilename based on the headers.  This will affect
 
697
        # how we move the file once the download is finished
 
698
        self.shortFilename = clean_filename(info['filename'])
 
699
        if self.expectedContentType is not None:
 
700
            ext_content_type = self.expectedContentType
 
701
        else:
 
702
            ext_content_type = info.get('content-type')
 
703
        self.shortFilename = check_filename_extension(self.shortFilename,
 
704
                ext_content_type)
 
705
 
 
706
    def on_download_error(self, error):
 
707
        if isinstance(error, httpclient.ResumeFailed):
 
708
            # try starting from scratch
 
709
            self.currentSize = 0
 
710
            self.totalSize = -1
 
711
            self.start_new_download()
 
712
        elif isinstance(error, httpclient.AuthorizationCanceled):
 
713
            self.destroy_client()
 
714
            self.stop(False)
 
715
        elif self.restartOnError:
 
716
            self.restartOnError = False
 
717
            self.start_download()
 
718
        else:
 
719
            self.destroy_client()
 
720
            self.handle_network_error(error)
 
721
 
 
722
    def on_download_finished(self, response):
 
723
        self.destroy_client()
 
724
        self.state = "finished"
 
725
        self.endTime = clock()
 
726
        # bug 14131 -- if there's nothing here, treat it like a temporary
 
727
        # error
 
728
        if self.currentSize == 0:
 
729
            self.handle_network_error(httpclient.PossiblyTemporaryError(_("no content")))
 
730
 
 
731
        else:
 
732
            if self.totalSize == -1:
 
733
                self.totalSize = self.currentSize
 
734
            try:
 
735
                self.move_to_movies_directory()
 
736
            except IOError, e:
 
737
                self.handle_write_error(e)
 
738
        self.update_client()
 
739
 
 
740
    def get_status(self):
 
741
        data = BGDownloader.get_status(self)
 
742
        data['dlerType'] = 'HTTP'
 
743
        return data
 
744
 
 
745
    def update_stats(self):
 
746
        """Update the download rate and eta based on receiving length
 
747
        bytes.
 
748
        """
 
749
        if self.client is None or self.state != 'downloading':
 
750
            return
 
751
        stats = self.client.get_stats()
 
752
        if stats.status_code in (200, 206):
 
753
            self.currentSize = stats.downloaded + stats.initial_size
 
754
            self.rate = stats.download_rate
 
755
        else:
 
756
            self.currentSize = self.rate = 0
 
757
        eventloop.add_timeout(self.CHECK_STATS_TIMEOUT, self.update_stats,
 
758
                'update http downloader stats')
 
759
        DOWNLOAD_UPDATER.queue_update(self)
 
760
 
 
761
    def pause(self):
 
762
        """Pauses the download.
 
763
        """
 
764
        if self.state != "stopped":
 
765
            self.cancel_request()
 
766
            self.state = "paused"
 
767
            self.update_client()
 
768
 
 
769
    def stop(self, delete):
 
770
        """Stops the download and removes the partially downloaded
 
771
        file.
 
772
        """
 
773
        if self.state == 'finished':
 
774
            if delete:
 
775
                try:
 
776
                    if fileutil.isdir(self.filename):
 
777
                        fileutil.rmtree(self.filename)
 
778
                    else:
 
779
                        fileutil.remove(self.filename)
 
780
                except OSError:
 
781
                    pass
 
782
        else:
 
783
            # Cancel the request, don't keep around partially
 
784
            # downloaded data
 
785
            self.cancel_request(remove_file=True)
 
786
        self.currentSize = 0
 
787
        self.state = "stopped"
 
788
        self.update_client()
 
789
 
 
790
    def stop_upload(self):
 
791
        # HTTP downloads never upload.
 
792
        pass
 
793
 
 
794
    def start(self, resume=True):
 
795
        """Continues a paused or stopped download thread.
 
796
        """
 
797
        if self.state in ('paused', 'stopped', 'offline'):
 
798
            self.state = "downloading"
 
799
            self.start_download(resume=resume)
 
800
 
 
801
    def shutdown(self):
 
802
        self.cancel_request()
 
803
        self.update_client()
 
804
 
 
805
class BTDownloader(BGDownloader):
 
806
    # update fast resume every 5 minutes
 
807
    FAST_RESUME_UPDATE_INTERVAL = 60 * 5
 
808
 
 
809
    def __init__(self, url=None, item=None, restore=None):
 
810
        self.metainfo = None
 
811
        self.torrent = None
 
812
        self.rate = self.eta = 0
 
813
        self.upRate = self.uploaded = 0
 
814
        self.activity = None
 
815
        self.fastResumeData = None
 
816
        self.retryDC = None
 
817
        self.channelName = None
 
818
        self.uploadedStart = 0
 
819
        self.restarting = False
 
820
        self.seeders = -1
 
821
        self.leechers = -1
 
822
        self.last_fast_resume_update = clock()
 
823
        self.metainfo_updated = self.fast_resume_data_updated = False
 
824
        if restore is not None:
 
825
            self.firstTime = False
 
826
            self.restore_state(restore)
 
827
        else:
 
828
            self.firstTime = True
 
829
            BGDownloader.__init__(self,url,item)
 
830
            self.run_downloader()
 
831
 
 
832
    def _start_torrent(self):
 
833
        try:
 
834
            torrent_info = lt.torrent_info(lt.bdecode(self.metainfo))
 
835
            duplicate = TORRENT_SESSION.find_duplicate_torrent(torrent_info)
 
836
            if duplicate is not None:
 
837
                c = command.DuplicateTorrent(daemon.LAST_DAEMON,
 
838
                        duplicate.dlid, self.dlid)
 
839
                c.send()
 
840
                return
 
841
            self.totalSize = torrent_info.total_size()
 
842
 
 
843
            if self.firstTime and not self.accept_download_size(self.totalSize):
 
844
                self.handle_error(
 
845
                    _("Not enough disk space"),
 
846
                    _("%(amount)s MB required to store this video",
 
847
                      {"amount": self.totalSize / (2 ** 20)})
 
848
                    )
 
849
                return
 
850
 
 
851
            save_path = os.path.dirname(fileutil.expand_filename(self.filename))
 
852
            if self.fastResumeData:
 
853
                self.torrent = TORRENT_SESSION.session.add_torrent(
 
854
                    torrent_info, save_path, lt.bdecode(self.fastResumeData),
 
855
                    lt.storage_mode_t.storage_mode_allocate)
 
856
                self.torrent.resume()
 
857
            else:
 
858
                self.torrent = TORRENT_SESSION.session.add_torrent(
 
859
                    torrent_info, save_path, None,
 
860
                    lt.storage_mode_t.storage_mode_allocate)
 
861
            try:
 
862
                if (lt.version_major, lt.version_minor) > (0, 13):
 
863
                    logging.debug(
 
864
                        "setting libtorrent auto_managed to False")
 
865
                    self.torrent.auto_managed(False)
 
866
            except AttributeError:
 
867
                logging.warning("libtorrent module doesn't have "
 
868
                                "version_major or version_minor")
 
869
        except (SystemExit, KeyboardInterrupt):
 
870
            raise
 
871
        except:
 
872
            self.handle_error(_('BitTorrent failure'),
 
873
                              _('BitTorrent failed to startup'))
 
874
            logging.exception("Exception thrown in _start_torrent")
 
875
        else:
 
876
            TORRENT_SESSION.add_torrent(self)
 
877
 
 
878
    def _shutdown_torrent(self):
 
879
        try:
 
880
            TORRENT_SESSION.remove_torrent(self)
 
881
            if self.torrent is not None:
 
882
                self.torrent.pause()
 
883
                self.update_fast_resume_data()
 
884
                TORRENT_SESSION.session.remove_torrent(self.torrent, 0)
 
885
                self.torrent = None
 
886
        except (SystemExit, KeyboardInterrupt):
 
887
            raise
 
888
        except:
 
889
            logging.exception("Error shutting down torrent")
 
890
 
 
891
    def _pause_torrent(self):
 
892
        try:
 
893
            TORRENT_SESSION.remove_torrent(self)
 
894
            if self.torrent is not None:
 
895
                self.torrent.pause()
 
896
        except (SystemExit, KeyboardInterrupt):
 
897
            raise
 
898
        except:
 
899
            logging.exception("Error pausing torrent")
 
900
 
 
901
    def _resume_torrent(self):
 
902
        if self.torrent is None:
 
903
            self._start_torrent()
 
904
            return
 
905
 
 
906
        try:
 
907
            self.torrent.resume()
 
908
            TORRENT_SESSION.add_torrent(self)
 
909
        except (SystemExit, KeyboardInterrupt):
 
910
            raise
 
911
        except:
 
912
            logging.exception("Error resuming torrent")
 
913
 
 
914
    def update_status(self):
 
915
        """
 
916
        activity -- string specifying what's currently happening or None for
 
917
                normal operations.
 
918
        upRate -- upload rate in B/s
 
919
        downRate -- download rate in B/s
 
920
        upTotal -- total MB uploaded
 
921
        downTotal -- total MB downloaded
 
922
        fractionDone -- what portion of the download is completed.
 
923
        timeEst -- estimated completion time, in seconds.
 
924
        totalSize -- total size of the torrent in bytes
 
925
        """
 
926
        status = self.torrent.status()
 
927
        self.totalSize = status.total_wanted
 
928
        self.rate = status.download_payload_rate
 
929
        self.upRate = status.upload_payload_rate
 
930
        self.uploaded = status.total_payload_upload + self.uploadedStart
 
931
        self.seeders = status.num_complete
 
932
        self.leechers = status.num_incomplete
 
933
        try:
 
934
            self.eta = ((status.total_wanted - status.total_wanted_done) /
 
935
                        float(status.download_payload_rate))
 
936
        except ZeroDivisionError:
 
937
            self.eta = 0
 
938
        if status.state == lt.torrent_status.states.queued_for_checking:
 
939
            self.activity = "waiting to check existing files"
 
940
        elif status.state == lt.torrent_status.states.checking_files:
 
941
            self.activity = "checking existing files"
 
942
        elif status.state == lt.torrent_status.states.allocating:
 
943
            self.activity = "allocating disk space"
 
944
        else:
 
945
            self.activity = None
 
946
        self.currentSize = status.total_wanted_done
 
947
        if ((self.state == "downloading"
 
948
             and status.state == lt.torrent_status.states.seeding)):
 
949
            self.move_to_movies_directory()
 
950
            self.state = "uploading"
 
951
            self.endTime = clock()
 
952
            self.update_client()
 
953
        else:
 
954
            DOWNLOAD_UPDATER.queue_update(self)
 
955
 
 
956
        if config.get(prefs.LIMIT_UPLOAD_RATIO):
 
957
            if status.state == lt.torrent_status.states.seeding:
 
958
                if ((float(self.uploaded) / self.totalSize >
 
959
                     config.get(prefs.UPLOAD_RATIO))):
 
960
                    self.stop_upload()
 
961
 
 
962
        if self.should_update_fast_resume_data():
 
963
            self.update_fast_resume_data()
 
964
 
 
965
    def should_update_fast_resume_data(self):
 
966
        return (clock() - self.last_fast_resume_update >
 
967
                self.FAST_RESUME_UPDATE_INTERVAL)
 
968
 
 
969
    def update_fast_resume_data(self):
 
970
        self.last_fast_resume_update = clock()
 
971
        self.fastResumeData = lt.bencode(self.torrent.write_resume_data())
 
972
        self.fast_resume_data_updated = True
 
973
 
 
974
    def handle_error(self, shortReason, reason):
 
975
        self._shutdown_torrent()
 
976
        BGDownloader.handle_error(self, shortReason, reason)
 
977
 
 
978
    def handle_temporary_error(self, shortReason, reason):
 
979
        self._shutdown_torrent()
 
980
        BGDownloader.handle_temporary_error(self, shortReason, reason)
 
981
 
 
982
    def move_to_directory(self, directory):
 
983
        if self.state in ('uploading', 'downloading'):
 
984
            self._shutdown_torrent()
 
985
            BGDownloader.move_to_directory(self, directory)
 
986
            self._resume_torrent()
 
987
        else:
 
988
            BGDownloader.move_to_directory(self, directory)
 
989
 
 
990
    def restore_state(self, data):
 
991
        self.__dict__.update(data)
 
992
        self.rate = self.eta = 0
 
993
        self.upRate = 0
 
994
        self.uploadedStart = self.uploaded
 
995
        if self.state in ('downloading', 'uploading'):
 
996
            self.run_downloader(done=True)
 
997
        elif self.state == 'offline':
 
998
            self.start()
 
999
 
 
1000
    def get_status(self):
 
1001
        data = BGDownloader.get_status(self)
 
1002
        data['upRate'] = self.upRate
 
1003
        data['uploaded'] = self.uploaded
 
1004
        if self.metainfo_updated:
 
1005
            data['metainfo'] = self.metainfo
 
1006
            self.metainfo_updated = False
 
1007
        if self.fast_resume_data_updated:
 
1008
            data['fastResumeData'] = self.fastResumeData
 
1009
            self.fast_resume_data_updated = False
 
1010
        data['activity'] = self.activity
 
1011
        data['dlerType'] = 'BitTorrent'
 
1012
        data['seeders'] = self.seeders
 
1013
        data['leechers'] = self.leechers
 
1014
        return data
 
1015
 
 
1016
    def get_rate(self):
 
1017
        return self.rate
 
1018
 
 
1019
    def get_eta(self):
 
1020
        return self.eta
 
1021
 
 
1022
    def pause(self):
 
1023
        self.state = "paused"
 
1024
        self.restarting = True
 
1025
        self._pause_torrent()
 
1026
        self.update_client()
 
1027
 
 
1028
    def stop(self, delete):
 
1029
        self.state = "stopped"
 
1030
        self._shutdown_torrent()
 
1031
        self.update_client()
 
1032
        if delete:
 
1033
            try:
 
1034
                if fileutil.isdir(self.filename):
 
1035
                    fileutil.rmtree(self.filename)
 
1036
                else:
 
1037
                    fileutil.remove(self.filename)
 
1038
            except OSError:
 
1039
                pass
 
1040
 
 
1041
    def stop_upload(self):
 
1042
        self.state = "finished"
 
1043
        self._shutdown_torrent()
 
1044
        self.update_client()
 
1045
 
 
1046
    def pause_upload(self):
 
1047
        self.state = "uploading-paused"
 
1048
        self._shutdown_torrent()
 
1049
        self.update_client()
 
1050
 
 
1051
    def start(self, resume=True):
 
1052
        # for BT downloads, resume doesn't mean anything, so we
 
1053
        # ignore it.
 
1054
        if self.state not in ('paused', 'stopped', 'offline'):
 
1055
            return
 
1056
 
 
1057
        self.state = "downloading"
 
1058
        if self.retryDC:
 
1059
            self.retryDC.cancel()
 
1060
            self.retryDC = None
 
1061
        self.update_client()
 
1062
        self.get_metainfo()
 
1063
 
 
1064
    def shutdown(self):
 
1065
        self._shutdown_torrent()
 
1066
        self.update_client()
 
1067
 
 
1068
    def got_metainfo(self):
 
1069
        # FIXME: If the client is stopped before a BT download gets
 
1070
        #        its metadata, we never run this. It's not a huge deal
 
1071
        #        because it only affects the incomplete filename
 
1072
        if not self.restarting:
 
1073
            try:
 
1074
                metainfo = lt.bdecode(self.metainfo)
 
1075
                # if we don't get valid torrent metadata back, then the
 
1076
                # metainfo is None.  treat that like a runtime error.
 
1077
                if not metainfo:
 
1078
                    raise RuntimeError()
 
1079
                name = metainfo['info']['name']
 
1080
            except RuntimeError:
 
1081
                self.handle_corrupt_torrent()
 
1082
                return
 
1083
            self.shortFilename = utf8_to_filename(name)
 
1084
            self.pick_initial_filename(suffix="", torrent=True)
 
1085
        self.update_client()
 
1086
        self._resume_torrent()
 
1087
 
 
1088
    def handle_corrupt_torrent(self):
 
1089
        self.handle_error(
 
1090
            _("Corrupt Torrent"),
 
1091
            _("The torrent file at %(url)s was not valid",
 
1092
              {"url": stringify(self.url)})
 
1093
            )
 
1094
 
 
1095
    def handle_metainfo(self, metainfo):
 
1096
        self.metainfo = metainfo
 
1097
        self.metainfo_updated = True
 
1098
        self.got_metainfo()
 
1099
 
 
1100
    def check_description(self, data):
 
1101
        if len(data) > MAX_TORRENT_SIZE or data[0] != 'd':
 
1102
            # Bailout if we get too much data or it doesn't begin with
 
1103
            # "d" (see #12301 for details)
 
1104
            eventloop.add_idle(self.handle_corrupt_torrent,
 
1105
                               'description check failed')
 
1106
            return False
 
1107
        else:
 
1108
            return True
 
1109
 
 
1110
    def on_metainfo_download(self, info):
 
1111
        self.handle_metainfo(info['body'])
 
1112
 
 
1113
    def on_metainfo_download_error(self, exception):
 
1114
        self.handle_network_error(exception)
 
1115
 
 
1116
    def get_metainfo(self):
 
1117
        if self.metainfo is None:
 
1118
            if self.url.startswith('file://'):
 
1119
                path = get_file_url_path(self.url)
 
1120
                try:
 
1121
                    metainfoFile = open(path, 'rb')
 
1122
                except IOError:
 
1123
                    self.handle_error(_("Torrent file deleted"),
 
1124
                                     _("The torrent file for this item was deleted "
 
1125
                                       "outside of %(appname)s.",
 
1126
                                       {"appname": config.get(prefs.SHORT_APP_NAME)}
 
1127
                                       ))
 
1128
 
 
1129
                    return
 
1130
                try:
 
1131
                    metainfo = metainfoFile.read()
 
1132
                finally:
 
1133
                    metainfoFile.close()
 
1134
 
 
1135
                self.handle_metainfo(metainfo)
 
1136
            else:
 
1137
                self.description_client = httpclient.grab_url(self.url,
 
1138
                        self.on_metainfo_download,
 
1139
                        self.on_metainfo_download_error,
 
1140
                        content_check_callback=self.check_description)
 
1141
        else:
 
1142
            self.got_metainfo()
 
1143
 
 
1144
    def run_downloader(self, done=False):
 
1145
        self.restarting = done
 
1146
        self.update_client()
 
1147
        self.get_metainfo()