deluge/deluge/core/torrent.py
Calum Lind de4fbd2e82 [Core] Workaround torrent file_progress lt 2.0 error
Workaround lt 2.0 python bindings error when calling a torrent handle
file_progress:

```
Boost.Python.ArgumentError: Python argument types in
    torrent_handle.file_progress(torrent_handle)
did not match C++ signature:
    file_progress(libtorrent::torrent_handle {lvalue}, libtorrent:🎏:bitfield_flag<unsigned char, libtorrent::file_progress_flags_tag, void> flags=0)
```

Should be fixed in 2.0.5 release: https://github.com/arvidn/libtorrent/commit/3feba04e6d
2021-10-03 18:53:31 +01:00

1518 lines
56 KiB
Python

# -*- coding: utf-8 -*-
#
# Copyright (C) 2007-2009 Andrew Resch <andrewresch@gmail.com>
#
# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
# the additional special exception to link portions of this program with the OpenSSL library.
# See LICENSE for more details.
#
"""Internal Torrent class
Attributes:
LT_TORRENT_STATE_MAP (dict): Maps the torrent state from libtorrent to Deluge state.
"""
from __future__ import division, unicode_literals
import logging
import os
import socket
from twisted.internet.defer import Deferred, DeferredList
import deluge.component as component
from deluge._libtorrent import lt
from deluge.common import decode_bytes
from deluge.configmanager import ConfigManager, get_config_dir
from deluge.core.authmanager import AUTH_LEVEL_ADMIN
from deluge.decorators import deprecated
from deluge.event import (
TorrentFolderRenamedEvent,
TorrentStateChangedEvent,
TorrentTrackerStatusEvent,
)
try:
from urllib.parse import urlparse
except ImportError:
# PY2 fallback
from urlparse import urlparse # pylint: disable=ungrouped-imports
try:
from future_builtins import zip
except ImportError:
# Ignore on Py3.
pass
log = logging.getLogger(__name__)
LT_TORRENT_STATE_MAP = {
'queued_for_checking': 'Checking',
'checking_files': 'Checking',
'downloading_metadata': 'Downloading',
'downloading': 'Downloading',
'finished': 'Seeding',
'seeding': 'Seeding',
'allocating': 'Allocating',
'checking_resume_data': 'Checking',
}
def sanitize_filepath(filepath, folder=False):
"""Returns a sanitized filepath to pass to libtorrent rename_file().
The filepath will have backslashes substituted along with whitespace
padding and duplicate slashes stripped.
Args:
folder (bool): A trailing slash is appended to the returned filepath.
"""
def clean_filename(filename):
"""Strips whitespace and discards dotted filenames"""
filename = filename.strip()
if filename.replace('.', '') == '':
return ''
return filename
if '\\' in filepath or '/' in filepath:
folderpath = filepath.replace('\\', '/').split('/')
folderpath = [clean_filename(x) for x in folderpath]
newfilepath = '/'.join([path for path in folderpath if path])
else:
newfilepath = clean_filename(filepath)
if folder is True:
newfilepath += '/'
return newfilepath
def convert_lt_files(files):
"""Indexes and decodes files from libtorrent get_files().
Args:
files (list): The libtorrent torrent files.
Returns:
list of dict: The files.
The format for the file dict::
{
"index": int,
"path": str,
"size": int,
"offset": int
}
"""
filelist = []
for index, _file in enumerate(files):
try:
file_path = _file.path.decode('utf8')
except AttributeError:
file_path = _file.path
filelist.append(
{
'index': index,
'path': file_path.replace('\\', '/'),
'size': _file.size,
'offset': _file.offset,
}
)
return filelist
class TorrentOptions(dict):
"""TorrentOptions create a dict of the torrent options.
Attributes:
add_paused (bool): Add the torrrent in a paused state.
auto_managed (bool): Set torrent to auto managed mode, i.e. will be started or queued automatically.
download_location (str): The path for the torrent data to be stored while downloading.
file_priorities (list of int): The priority for files in torrent, range is [0..7] however
only [0, 1, 4, 7] are normally used and correspond to [Skip, Low, Normal, High]
mapped_files (dict): A mapping of the renamed filenames in 'index:filename' pairs.
max_connections (int): Sets maximum number of connections this torrent will open.
This must be at least 2. The default is unlimited (-1).
max_download_speed (float): Will limit the download bandwidth used by this torrent to the
limit you set.The default is unlimited (-1) but will not exceed global limit.
max_upload_slots (int): Sets the maximum number of peers that are
unchoked at the same time on this torrent. This defaults to infinite (-1).
max_upload_speed (float): Will limit the upload bandwidth used by this torrent to the limit
you set. The default is unlimited (-1) but will not exceed global limit.
move_completed (bool): Move the torrent when downloading has finished.
move_completed_path (str): The path to move torrent to when downloading has finished.
name (str): The display name of the torrent.
owner (str): The user this torrent belongs to.
pre_allocate_storage (bool): When adding the torrent should all files be pre-allocated.
prioritize_first_last_pieces (bool): Prioritize the first and last pieces in the torrent.
remove_at_ratio (bool): Remove the torrent when it has reached the stop_ratio.
seed_mode (bool): Assume that all files are present for this torrent (Only used when adding a torent).
sequential_download (bool): Download the pieces of the torrent in order.
shared (bool): Enable the torrent to be seen by other Deluge users.
stop_at_ratio (bool): Stop the torrent when it has reached stop_ratio.
stop_ratio (float): The seeding ratio to stop (or remove) the torrent at.
super_seeding (bool): Enable super seeding/initial seeding.
"""
def __init__(self):
super(TorrentOptions, self).__init__()
config = ConfigManager('core.conf').config
options_conf_map = {
'add_paused': 'add_paused',
'auto_managed': 'auto_managed',
'download_location': 'download_location',
'max_connections': 'max_connections_per_torrent',
'max_download_speed': 'max_download_speed_per_torrent',
'max_upload_slots': 'max_upload_slots_per_torrent',
'max_upload_speed': 'max_upload_speed_per_torrent',
'move_completed': 'move_completed',
'move_completed_path': 'move_completed_path',
'pre_allocate_storage': 'pre_allocate_storage',
'prioritize_first_last_pieces': 'prioritize_first_last_pieces',
'remove_at_ratio': 'remove_seed_at_ratio',
'sequential_download': 'sequential_download',
'shared': 'shared',
'stop_at_ratio': 'stop_seed_at_ratio',
'stop_ratio': 'stop_seed_ratio',
'super_seeding': 'super_seeding',
}
for opt_k, conf_k in options_conf_map.items():
self[opt_k] = config[conf_k]
self['file_priorities'] = []
self['mapped_files'] = {}
self['name'] = ''
self['owner'] = ''
self['seed_mode'] = False
class TorrentError(object):
def __init__(self, error_message, was_paused=False, restart_to_resume=False):
self.error_message = error_message
self.was_paused = was_paused
self.restart_to_resume = restart_to_resume
class Torrent(object):
"""Torrent holds information about torrents added to the libtorrent session.
Args:
handle: The libtorrent torrent handle.
options (dict): The torrent options.
state (TorrentState): The torrent state.
filename (str): The filename of the torrent file.
magnet (str): The magnet URI.
Attributes:
torrent_id (str): The torrent_id for this torrent
handle: Holds the libtorrent torrent handle
magnet (str): The magnet URI used to add this torrent (if available).
status: Holds status info so that we don"t need to keep getting it from libtorrent.
torrent_info: store the torrent info.
has_metadata (bool): True if the metadata for the torrent is available, False otherwise.
status_funcs (dict): The function mappings to get torrent status
prev_status (dict): Previous status dicts returned for this torrent. We use this to return
dicts that only contain changes from the previous.
{session_id: status_dict, ...}
waiting_on_folder_rename (list of dict): A list of Deferreds for file indexes we're waiting for file_rename
alerts on. This is so we can send one folder_renamed signal instead of multiple file_renamed signals.
[{index: Deferred, ...}, ...]
options (dict): The torrent options.
filename (str): The filename of the torrent file in case it is required.
is_finished (bool): Keep track if torrent is finished to prevent some weird things on state load.
statusmsg (str): Status message holds error/extra info about the torrent.
state (str): The torrent's state
trackers (list of dict): The torrent's trackers
tracker_status (str): Status message of currently connected tracker
tracker_host (str): Hostname of the currently connected tracker
forcing_recheck (bool): Keep track if we're forcing a recheck of the torrent
forcing_recheck_paused (bool): Keep track if we're forcing a recheck of the torrent so that
we can re-pause it after its done if necessary
forced_error (TorrentError): Keep track if we have forced this torrent to be in Error state.
"""
def __init__(self, handle, options, state=None, filename=None, magnet=None):
self.torrent_id = str(handle.info_hash())
if log.isEnabledFor(logging.DEBUG):
log.debug('Creating torrent object %s', self.torrent_id)
# Get the core config
self.config = ConfigManager('core.conf')
self.rpcserver = component.get('RPCServer')
self.handle = handle
self.magnet = magnet
self.status = self.handle.status()
self.torrent_info = self.handle.get_torrent_info()
self.has_metadata = self.status.has_metadata
self.options = TorrentOptions()
self.options.update(options)
# Load values from state if we have it
if state:
self.set_trackers(state.trackers)
self.is_finished = state.is_finished
self.filename = state.filename
else:
self.set_trackers()
self.is_finished = False
self.filename = filename
if not self.filename:
self.filename = ''
self.forced_error = None
self.statusmsg = None
self.state = None
self.moving_storage_dest_path = None
self.tracker_status = ''
self.tracker_host = None
self.forcing_recheck = False
self.forcing_recheck_paused = False
self.status_funcs = None
self.prev_status = {}
self.waiting_on_folder_rename = []
self.update_status(self.handle.status())
self._create_status_funcs()
self.set_options(self.options)
self.update_state()
if log.isEnabledFor(logging.DEBUG):
log.debug('Torrent object created.')
def on_metadata_received(self):
"""Process the metadata received alert for this torrent"""
self.has_metadata = True
self.torrent_info = self.handle.get_torrent_info()
if self.options['prioritize_first_last_pieces']:
self.set_prioritize_first_last_pieces(True)
self.write_torrentfile()
# --- Options methods ---
def set_options(self, options):
"""Set the torrent options.
Args:
options (dict): Torrent options, see TorrentOptions class for valid keys.
"""
# Skip set_prioritize_first_last if set_file_priorities is in options as it also calls the method.
if 'file_priorities' in options and 'prioritize_first_last_pieces' in options:
self.options['prioritize_first_last_pieces'] = options.pop(
'prioritize_first_last_pieces'
)
for key, value in options.items():
if key in self.options:
options_set_func = getattr(self, 'set_' + key, None)
if options_set_func:
options_set_func(value)
else:
# Update config options that do not have funcs
self.options[key] = value
def get_options(self):
"""Get the torrent options.
Returns:
dict: the torrent options.
"""
return self.options
def set_max_connections(self, max_connections):
"""Sets maximum number of connections this torrent will open.
Args:
max_connections (int): Maximum number of connections
Note:
The minimum value for handle.max_connections is 2 (or -1 for unlimited connections).
This is enforced by libtorrent and values 0 or 1 raise an assert with lt debug builds.
"""
if max_connections == 0:
max_connections = -1
elif max_connections == 1:
max_connections = 2
self.options['max_connections'] = max_connections
self.handle.set_max_connections(max_connections)
def set_max_upload_slots(self, max_slots):
"""Sets maximum number of upload slots for this torrent.
Args:
max_slots (int): Maximum upload slots
"""
self.options['max_upload_slots'] = max_slots
self.handle.set_max_uploads(max_slots)
def set_max_upload_speed(self, m_up_speed):
"""Sets maximum upload speed for this torrent.
Args:
m_up_speed (float): Maximum upload speed in KiB/s.
"""
self.options['max_upload_speed'] = m_up_speed
if m_up_speed < 0:
value = -1
else:
value = int(m_up_speed * 1024)
self.handle.set_upload_limit(value)
def set_max_download_speed(self, m_down_speed):
"""Sets maximum download speed for this torrent.
Args:
m_up_speed (float): Maximum download speed in KiB/s.
"""
self.options['max_download_speed'] = m_down_speed
if m_down_speed < 0:
value = -1
else:
value = int(m_down_speed * 1024)
self.handle.set_download_limit(value)
@deprecated
def set_prioritize_first_last(self, prioritize):
"""Deprecated: Use set_prioritize_first_last_pieces."""
self.set_prioritize_first_last_pieces(prioritize)
def set_prioritize_first_last_pieces(self, prioritize):
"""Prioritize the first and last pieces in the torrent.
Args:
prioritize (bool): Prioritize the first and last pieces.
"""
if not self.has_metadata:
return
self.options['prioritize_first_last_pieces'] = prioritize
if not prioritize:
# If we are turning off this option, call set_file_priorities to
# reset all the piece priorities
self.set_file_priorities(self.options['file_priorities'])
return
# A list of priorities for each piece in the torrent
priorities = self.handle.piece_priorities()
def get_file_piece(idx, byte_offset):
return self.torrent_info.map_file(idx, byte_offset, 0).piece
for idx in range(self.torrent_info.num_files()):
file_size = self.torrent_info.files().file_size(idx)
two_percent_bytes = int(0.02 * file_size)
# Get the pieces for the byte offsets
first_start = get_file_piece(idx, 0)
first_end = get_file_piece(idx, two_percent_bytes) + 1
last_start = get_file_piece(idx, file_size - two_percent_bytes)
last_end = get_file_piece(idx, max(file_size - 1, 0)) + 1
# Set the pieces in first and last ranges to priority 7
# if they are not marked as do not download
priorities[first_start:first_end] = [
p and 7 for p in priorities[first_start:first_end]
]
priorities[last_start:last_end] = [
p and 7 for p in priorities[last_start:last_end]
]
# Setting the priorites for all the pieces of this torrent
self.handle.prioritize_pieces(priorities)
def set_sequential_download(self, sequential):
"""Sets whether to download the pieces of the torrent in order.
Args:
sequential (bool): Enable sequential downloading.
"""
self.options['sequential_download'] = sequential
self.handle.set_sequential_download(sequential)
def set_auto_managed(self, auto_managed):
"""Set auto managed mode, i.e. will be started or queued automatically.
Args:
auto_managed (bool): Enable auto managed.
"""
self.options['auto_managed'] = auto_managed
if not (self.status.paused and not self.status.auto_managed):
self.handle.auto_managed(auto_managed)
self.update_state()
def set_super_seeding(self, super_seeding):
"""Set super seeding/initial seeding.
Args:
super_seeding (bool): Enable super seeding.
"""
self.options['super_seeding'] = super_seeding
self.handle.super_seeding(super_seeding)
def set_stop_ratio(self, stop_ratio):
"""The seeding ratio to stop (or remove) the torrent at.
Args:
stop_ratio (float): The seeding ratio.
"""
self.options['stop_ratio'] = stop_ratio
def set_stop_at_ratio(self, stop_at_ratio):
"""Stop the torrent when it has reached stop_ratio.
Args:
stop_at_ratio (bool): Stop the torrent.
"""
self.options['stop_at_ratio'] = stop_at_ratio
def set_remove_at_ratio(self, remove_at_ratio):
"""Remove the torrent when it has reached the stop_ratio.
Args:
remove_at_ratio (bool): Remove the torrent.
"""
self.options['remove_at_ratio'] = remove_at_ratio
def set_move_completed(self, move_completed):
"""Set whether to move the torrent when downloading has finished.
Args:
move_completed (bool): Move the torrent.
"""
self.options['move_completed'] = move_completed
def set_move_completed_path(self, move_completed_path):
"""Set the path to move torrent to when downloading has finished.
Args:
move_completed_path (str): The move path.
"""
self.options['move_completed_path'] = move_completed_path
def set_file_priorities(self, file_priorities):
"""Sets the file priotities.
Args:
file_priorities (list of int): List of file priorities.
"""
if not self.has_metadata:
return
if log.isEnabledFor(logging.DEBUG):
log.debug(
'Setting %s file priorities to: %s', self.torrent_id, file_priorities
)
if file_priorities and len(file_priorities) == len(self.get_files()):
self.handle.prioritize_files(file_priorities)
else:
log.debug('Unable to set new file priorities.')
file_priorities = self.handle.file_priorities()
if 0 in self.options['file_priorities']:
# Previously marked a file 'skip' so check for any 0's now >0.
for index, priority in enumerate(self.options['file_priorities']):
if priority == 0 and file_priorities[index] > 0:
# Changed priority from skip to download so update state.
self.is_finished = False
self.update_state()
break
# Store the priorities.
self.options['file_priorities'] = file_priorities
# Set the first/last priorities if needed.
if self.options['prioritize_first_last_pieces']:
self.set_prioritize_first_last_pieces(True)
@deprecated
def set_save_path(self, download_location):
"""Deprecated: Use set_download_location."""
self.set_download_location(download_location)
def set_download_location(self, download_location):
"""The location for downloading torrent data."""
self.options['download_location'] = download_location
def set_owner(self, account):
"""Sets the owner of this torrent.
Args:
account (str): The new owner account name.
Notes:
Only a user with admin level auth can change this value.
"""
if self.rpcserver.get_session_auth_level() == AUTH_LEVEL_ADMIN:
self.options['owner'] = account
# End Options methods #
def set_trackers(self, trackers=None):
"""Sets the trackers for this torrent.
Args:
trackers (list of dicts): A list of trackers.
"""
if trackers is None:
self.trackers = [tracker for tracker in self.handle.trackers()]
self.tracker_host = None
return
if log.isEnabledFor(logging.DEBUG):
log.debug('Setting trackers for %s: %s', self.torrent_id, trackers)
tracker_list = []
for tracker in trackers:
new_entry = lt.announce_entry(str(tracker['url']))
new_entry.tier = tracker['tier']
tracker_list.append(new_entry)
self.handle.replace_trackers(tracker_list)
# Print out the trackers
if log.isEnabledFor(logging.DEBUG):
log.debug('Trackers set for %s:', self.torrent_id)
for tracker in self.handle.trackers():
log.debug(' [tier %s]: %s', tracker['tier'], tracker['url'])
# Set the tracker list in the torrent object
self.trackers = trackers
if len(trackers) > 0:
# Force a re-announce if there is at least 1 tracker
self.force_reannounce()
self.tracker_host = None
def set_tracker_status(self, status):
"""Sets the tracker status.
Args:
status (str): The tracker status.
Emits:
TorrentTrackerStatusEvent upon tracker status change.
"""
self.tracker_host = None
if self.tracker_status != status:
self.tracker_status = status
component.get('EventManager').emit(
TorrentTrackerStatusEvent(self.torrent_id, self.tracker_status)
)
def merge_trackers(self, torrent_info):
"""Merges new trackers in torrent_info into torrent"""
log.info(
'Adding any new trackers to torrent (%s) already in session...',
self.torrent_id,
)
if not torrent_info:
return
# Don't merge trackers if either torrent has private flag set.
if torrent_info.priv() or self.get_status(['private'])['private']:
log.info('Adding trackers aborted: Torrent has private flag set.')
else:
for tracker in torrent_info.trackers():
self.handle.add_tracker({'url': tracker.url, 'tier': tracker.tier})
# Update torrent.trackers from libtorrent handle.
self.set_trackers()
def update_state(self):
"""Updates the state, based on libtorrent's torrent state"""
status = self.handle.status()
session_paused = component.get('Core').session.is_paused()
old_state = self.state
self.set_status_message()
status_error = status.errc.message() if status.errc.value() else ''
if self.forced_error:
self.state = 'Error'
self.set_status_message(self.forced_error.error_message)
elif status_error:
self.state = 'Error'
# auto-manage status will be reverted upon resuming.
self.handle.auto_managed(False)
self.set_status_message(decode_bytes(status_error))
elif status.moving_storage:
self.state = 'Moving'
elif not session_paused and status.paused and status.auto_managed:
self.state = 'Queued'
elif session_paused or status.paused:
self.state = 'Paused'
else:
self.state = LT_TORRENT_STATE_MAP.get(str(status.state), str(status.state))
if self.state != old_state:
component.get('EventManager').emit(
TorrentStateChangedEvent(self.torrent_id, self.state)
)
if log.isEnabledFor(logging.DEBUG):
log.debug(
'State from lt was: %s | Session is paused: %s\nTorrent state set from "%s" to "%s" (%s)',
'error' if status_error else status.state,
session_paused,
old_state,
self.state,
self.torrent_id,
)
if self.forced_error:
log.debug(
'Torrent Error state message: %s', self.forced_error.error_message
)
def set_status_message(self, message=None):
"""Sets the torrent status message.
Calling method without a message will reset the message to 'OK'.
Args:
message (str, optional): The status message.
"""
if not message:
message = 'OK'
self.statusmsg = message
def force_error_state(self, message, restart_to_resume=True):
"""Forces the torrent into an error state.
For setting an error state not covered by libtorrent.
Args:
message (str): The error status message.
restart_to_resume (bool, optional): Prevent resuming clearing the error, only restarting
session can resume.
"""
status = self.handle.status()
self.handle.auto_managed(False)
self.forced_error = TorrentError(message, status.paused, restart_to_resume)
if not status.paused:
self.handle.pause()
self.update_state()
def clear_forced_error_state(self, update_state=True):
if not self.forced_error:
return
if self.forced_error.restart_to_resume:
log.error('Restart deluge to clear this torrent error')
if not self.forced_error.was_paused and self.options['auto_managed']:
self.handle.auto_managed(True)
self.forced_error = None
self.set_status_message('OK')
if update_state:
self.update_state()
def get_eta(self):
"""Get the ETA for this torrent.
Returns:
int: The ETA in seconds.
"""
status = self.status
eta = 0
if (
self.is_finished
and self.options['stop_at_ratio']
and status.upload_payload_rate
):
# We're a seed, so calculate the time to the 'stop_share_ratio'
eta = (
int(status.all_time_download * self.options['stop_ratio'])
- status.all_time_upload
) // status.upload_payload_rate
elif status.download_payload_rate:
left = status.total_wanted - status.total_wanted_done
if left > 0:
eta = left // status.download_payload_rate
# Limit to 1 year, avoid excessive values and prevent GTK int overflow.
return eta if eta < 31557600 else -1
def get_ratio(self):
"""Get the ratio of upload/download for this torrent.
Returns:
float: The ratio or -1.0 (for infinity).
"""
if self.status.total_done > 0:
return self.status.all_time_upload / self.status.total_done
else:
return -1.0
def get_files(self):
"""Get the files this torrent contains.
Returns:
list of dict: The files.
"""
if not self.has_metadata:
return []
files = self.torrent_info.files()
return convert_lt_files(files)
def get_orig_files(self):
"""Get the original filenames of files in this torrent.
Returns:
list of dict: The files with original filenames.
"""
if not self.has_metadata:
return []
files = self.torrent_info.orig_files()
return convert_lt_files(files)
def get_peers(self):
"""Get the peers for this torrent.
A list of peers and various information about them.
Returns:
list of dict: The peers.
The format for the peer dict::
{
"client": str,
"country": str,
"down_speed": int,
"ip": str,
"progress": float,
"seed": bool,
"up_speed": int
}
"""
ret = []
peers = self.handle.get_peer_info()
for peer in peers:
# We do not want to report peers that are half-connected
if peer.flags & peer.connecting or peer.flags & peer.handshake:
continue
try:
client = decode_bytes(peer.client)
except UnicodeDecodeError:
# libtorrent on Py3 can raise UnicodeDecodeError for peer_info.client
client = 'unknown'
try:
country = component.get('Core').geoip_instance.country_code_by_addr(
peer.ip[0]
)
except AttributeError:
country = ''
else:
try:
country = ''.join(
[char if char.isalpha() else ' ' for char in country]
)
except TypeError:
country = ''
ret.append(
{
'client': client,
'country': country,
'down_speed': peer.payload_down_speed,
'ip': '%s:%s' % (peer.ip[0], peer.ip[1]),
'progress': peer.progress,
'seed': peer.flags & peer.seed,
'up_speed': peer.payload_up_speed,
}
)
return ret
def get_queue_position(self):
"""Get the torrents queue position
Returns:
int: queue position
"""
return self.handle.queue_position()
def get_file_priorities(self):
"""Return the file priorities"""
if not self.handle.has_metadata():
return []
if not self.options['file_priorities']:
# Ensure file_priorities option is populated.
self.set_file_priorities([])
return self.options['file_priorities']
def get_file_progress(self):
"""Calculates the file progress as a percentage.
Returns:
list of floats: The file progress (0.0 -> 1.0), empty list if n/a.
"""
if not self.has_metadata:
return []
try:
files_progresses = zip(
self.handle.file_progress(), self.torrent_info.files()
)
except Exception:
# Handle libtorrent >=2.0.0,<=2.0.4 file_progress error
files_progresses = zip(iter(lambda: 0, 1), self.torrent_info.files())
return [
progress / _file.size if _file.size else 0.0
for progress, _file in files_progresses
]
def get_tracker_host(self):
"""Get the hostname of the currently connected tracker.
If no tracker is connected, it uses the 1st tracker.
Returns:
str: The tracker host
"""
if self.tracker_host:
return self.tracker_host
tracker = self.status.current_tracker
if not tracker and self.trackers:
tracker = self.trackers[0]['url']
if tracker:
url = urlparse(tracker.replace('udp://', 'http://'))
if hasattr(url, 'hostname'):
host = url.hostname or 'DHT'
# Check if hostname is an IP address and just return it if that's the case
try:
socket.inet_aton(host)
except socket.error:
pass
else:
# This is an IP address because an exception wasn't raised
return url.hostname
parts = host.split('.')
if len(parts) > 2:
if parts[-2] in ('co', 'com', 'net', 'org') or parts[-1] == 'uk':
host = '.'.join(parts[-3:])
else:
host = '.'.join(parts[-2:])
self.tracker_host = host
return host
return ''
def get_magnet_uri(self):
"""Returns a magnet URI for this torrent"""
return lt.make_magnet_uri(self.handle)
def get_name(self):
"""The name of the torrent (distinct from the filenames).
Note:
Can be manually set in options through `name` key. If the key is
reset to empty string "" it will return the original torrent name.
Returns:
str: the name of the torrent.
"""
if self.options['name']:
return self.options['name']
if self.has_metadata:
# Use the top-level folder as torrent name.
filename = decode_bytes(self.torrent_info.file_at(0).path)
name = filename.replace('\\', '/', 1).split('/', 1)[0]
else:
name = decode_bytes(self.handle.name())
if not name:
name = self.torrent_id
return name
def get_progress(self):
"""The progress of this torrent's current task.
Returns:
float: The progress percentage (0 to 100).
"""
def get_size(files, path):
"""Returns total size of 'files' currently located in 'path'"""
files = [os.path.join(path, f) for f in files]
return sum(os.stat(f).st_size for f in files if os.path.exists(f))
if self.state == 'Error':
progress = 100.0
elif self.state == 'Moving':
# Check if torrent has downloaded any data yet.
if self.status.total_done:
torrent_files = [f['path'] for f in self.get_files()]
dest_path_size = get_size(torrent_files, self.moving_storage_dest_path)
progress = dest_path_size / self.status.total_done * 100
else:
progress = 100.0
else:
progress = self.status.progress * 100
return progress
def get_time_since_transfer(self):
"""The time since either upload/download from peers"""
time_since = (self.status.time_since_download, self.status.time_since_upload)
try:
return min(x for x in time_since if x != -1)
except ValueError:
return -1
def get_status(self, keys, diff=False, update=False, all_keys=False):
"""Returns the status of the torrent based on the keys provided
Args:
keys (list of str): the keys to get the status on
diff (bool): Will return a diff of the changes since the last
call to get_status based on the session_id
update (bool): If True the status will be updated from libtorrent
if False, the cached values will be returned
all_keys (bool): If True return all keys while ignoring the keys param
if False, return only the requested keys
Returns:
dict: a dictionary of the status keys and their values
"""
if update:
self.update_status(self.handle.status())
if all_keys:
keys = list(self.status_funcs)
status_dict = {}
for key in keys:
status_dict[key] = self.status_funcs[key]()
if diff:
session_id = self.rpcserver.get_session_id()
if session_id in self.prev_status:
# We have a previous status dict, so lets make a diff
status_diff = {}
for key, value in status_dict.items():
if key in self.prev_status[session_id]:
if value != self.prev_status[session_id][key]:
status_diff[key] = value
else:
status_diff[key] = value
self.prev_status[session_id] = status_dict
return status_diff
self.prev_status[session_id] = status_dict
return status_dict
return status_dict
def update_status(self, status):
"""Updates the cached status.
Args:
status (libtorrent.torrent_status): a libtorrent torrent status
"""
self.status = status
def _create_status_funcs(self):
"""Creates the functions for getting torrent status"""
self.status_funcs = {
'active_time': lambda: self.status.active_time,
'seeding_time': lambda: self.status.seeding_time,
'finished_time': lambda: self.status.finished_time,
'all_time_download': lambda: self.status.all_time_download,
'storage_mode': lambda: self.status.storage_mode.name.split('_')[
2
], # sparse or allocate
'distributed_copies': lambda: max(0.0, self.status.distributed_copies),
'download_payload_rate': lambda: self.status.download_payload_rate,
'file_priorities': self.get_file_priorities,
'hash': lambda: self.torrent_id,
'auto_managed': lambda: self.options['auto_managed'],
'is_auto_managed': lambda: self.options['auto_managed'],
'is_finished': lambda: self.is_finished,
'max_connections': lambda: self.options['max_connections'],
'max_download_speed': lambda: self.options['max_download_speed'],
'max_upload_slots': lambda: self.options['max_upload_slots'],
'max_upload_speed': lambda: self.options['max_upload_speed'],
'message': lambda: self.statusmsg,
'move_on_completed_path': lambda: self.options[
'move_completed_path'
], # Deprecated: move_completed_path
'move_on_completed': lambda: self.options[
'move_completed'
], # Deprecated: Use move_completed
'move_completed_path': lambda: self.options['move_completed_path'],
'move_completed': lambda: self.options['move_completed'],
'next_announce': lambda: self.status.next_announce.seconds,
'num_peers': lambda: self.status.num_peers - self.status.num_seeds,
'num_seeds': lambda: self.status.num_seeds,
'owner': lambda: self.options['owner'],
'paused': lambda: self.status.paused,
'prioritize_first_last': lambda: self.options[
'prioritize_first_last_pieces'
],
# Deprecated: Use prioritize_first_last_pieces
'prioritize_first_last_pieces': lambda: self.options[
'prioritize_first_last_pieces'
],
'sequential_download': lambda: self.options['sequential_download'],
'progress': self.get_progress,
'shared': lambda: self.options['shared'],
'remove_at_ratio': lambda: self.options['remove_at_ratio'],
'save_path': lambda: self.options[
'download_location'
], # Deprecated: Use download_location
'download_location': lambda: self.options['download_location'],
'seeds_peers_ratio': lambda: -1.0
if self.status.num_incomplete == 0
else ( # Use -1.0 to signify infinity
self.status.num_complete / self.status.num_incomplete
),
'seed_rank': lambda: self.status.seed_rank,
'state': lambda: self.state,
'stop_at_ratio': lambda: self.options['stop_at_ratio'],
'stop_ratio': lambda: self.options['stop_ratio'],
'time_added': lambda: self.status.added_time,
'total_done': lambda: self.status.total_done,
'total_payload_download': lambda: self.status.total_payload_download,
'total_payload_upload': lambda: self.status.total_payload_upload,
'total_peers': lambda: self.status.num_incomplete,
'total_seeds': lambda: self.status.num_complete,
'total_uploaded': lambda: self.status.all_time_upload,
'total_wanted': lambda: self.status.total_wanted,
'total_remaining': lambda: self.status.total_wanted
- self.status.total_wanted_done,
'tracker': lambda: self.status.current_tracker,
'tracker_host': self.get_tracker_host,
'trackers': lambda: self.trackers,
'tracker_status': lambda: self.tracker_status,
'upload_payload_rate': lambda: self.status.upload_payload_rate,
'comment': lambda: decode_bytes(self.torrent_info.comment())
if self.has_metadata
else '',
'creator': lambda: decode_bytes(self.torrent_info.creator())
if self.has_metadata
else '',
'num_files': lambda: self.torrent_info.num_files()
if self.has_metadata
else 0,
'num_pieces': lambda: self.torrent_info.num_pieces()
if self.has_metadata
else 0,
'piece_length': lambda: self.torrent_info.piece_length()
if self.has_metadata
else 0,
'private': lambda: self.torrent_info.priv() if self.has_metadata else False,
'total_size': lambda: self.torrent_info.total_size()
if self.has_metadata
else 0,
'eta': self.get_eta,
'file_progress': self.get_file_progress,
'files': self.get_files,
'orig_files': self.get_orig_files,
'is_seed': lambda: self.status.is_seeding,
'peers': self.get_peers,
'queue': lambda: self.status.queue_position,
'ratio': self.get_ratio,
'completed_time': lambda: self.status.completed_time,
'last_seen_complete': lambda: self.status.last_seen_complete,
'name': self.get_name,
'pieces': self._get_pieces_info,
'seed_mode': lambda: self.status.seed_mode,
'super_seeding': lambda: self.status.super_seeding,
'time_since_download': lambda: self.status.time_since_download,
'time_since_upload': lambda: self.status.time_since_upload,
'time_since_transfer': self.get_time_since_transfer,
}
def pause(self):
"""Pause this torrent.
Returns:
bool: True is successful, otherwise False.
"""
# Turn off auto-management so the torrent will not be unpaused by lt queueing
self.handle.auto_managed(False)
if self.state == 'Error':
log.debug('Unable to pause torrent while in Error state')
elif self.status.paused:
# This torrent was probably paused due to being auto managed by lt
# Since we turned auto_managed off, we should update the state which should
# show it as 'Paused'. We need to emit a torrent_paused signal because
# the torrent_paused alert from libtorrent will not be generated.
self.update_state()
component.get('EventManager').emit(
TorrentStateChangedEvent(self.torrent_id, 'Paused')
)
else:
try:
self.handle.pause()
except RuntimeError as ex:
log.debug('Unable to pause torrent: %s', ex)
def resume(self):
"""Resumes this torrent."""
if self.status.paused and self.status.auto_managed:
log.debug('Resume not possible for auto-managed torrent!')
elif self.forced_error and self.forced_error.was_paused:
log.debug(
'Resume skipped for forced_error torrent as it was originally paused.'
)
elif (
self.status.is_finished
and self.options['stop_at_ratio']
and self.get_ratio() >= self.options['stop_ratio']
):
log.debug('Resume skipped for torrent as it has reached "stop_seed_ratio".')
else:
# Check if torrent was originally being auto-managed.
if self.options['auto_managed']:
self.handle.auto_managed(True)
try:
self.handle.resume()
except RuntimeError as ex:
log.debug('Unable to resume torrent: %s', ex)
# Clear torrent error state.
if self.forced_error and not self.forced_error.restart_to_resume:
self.clear_forced_error_state()
elif self.state == 'Error' and not self.forced_error:
self.handle.clear_error()
def connect_peer(self, peer_ip, peer_port):
"""Manually add a peer to the torrent
Args:
peer_ip (str) : Peer IP Address
peer_port (int): Peer Port
Returns:
bool: True is successful, otherwise False
"""
try:
self.handle.connect_peer((peer_ip, int(peer_port)), 0)
except (RuntimeError, ValueError) as ex:
log.debug('Unable to connect to peer: %s', ex)
return False
return True
def move_storage(self, dest):
"""Move a torrent's storage location
Args:
dest (str): The destination folder for the torrent data
Returns:
bool: True if successful, otherwise False
"""
dest = decode_bytes(dest)
if not os.path.exists(dest):
try:
os.makedirs(dest)
except OSError as ex:
log.error(
'Could not move storage for torrent %s since %s does '
'not exist and could not create the directory: %s',
self.torrent_id,
dest,
ex,
)
return False
try:
# lt needs utf8 byte-string. Otherwise if wstrings enabled, unicode string.
# Keyword argument flags=2 (dont_replace) dont overwrite target files but delete source.
try:
self.handle.move_storage(dest.encode('utf8'), flags=2)
except TypeError:
self.handle.move_storage(dest, flags=2)
except RuntimeError as ex:
log.error('Error calling libtorrent move_storage: %s', ex)
return False
self.moving_storage_dest_path = dest
self.update_state()
return True
def save_resume_data(self, flush_disk_cache=False):
"""Signals libtorrent to build resume data for this torrent.
Args:
flush_disk_cache (bool): Avoids potential issue with file timestamps
and is only needed when stopping the session.
Returns:
None: The response with resume data is returned in a libtorrent save_resume_data_alert.
"""
if log.isEnabledFor(logging.DEBUG):
log.debug('Requesting save_resume_data for torrent: %s', self.torrent_id)
flags = lt.save_resume_flags_t.flush_disk_cache if flush_disk_cache else 0
# Don't generate fastresume data if torrent is in a Deluge Error state.
if self.forced_error:
component.get('TorrentManager').waiting_on_resume_data[
self.torrent_id
].errback(UserWarning('Skipped creating resume_data while in Error state'))
else:
self.handle.save_resume_data(flags)
def write_torrentfile(self, filedump=None):
"""Writes the torrent file to the state dir and optional 'copy of' dir.
Args:
filedump (str, optional): bencoded filedump of a torrent file.
"""
def write_file(filepath, filedump):
"""Write out the torrent file"""
log.debug('Writing torrent file to: %s', filepath)
try:
with open(filepath, 'wb') as save_file:
save_file.write(filedump)
except IOError as ex:
log.error('Unable to save torrent file to: %s', ex)
filepath = os.path.join(get_config_dir(), 'state', self.torrent_id + '.torrent')
if filedump is None:
lt_ct = lt.create_torrent(self.torrent_info)
filedump = lt.bencode(lt_ct.generate())
write_file(filepath, filedump)
# If the user has requested a copy of the torrent be saved elsewhere we need to do that.
if self.config['copy_torrent_file']:
if not self.filename:
self.filename = self.get_name() + '.torrent'
filepath = os.path.join(self.config['torrentfiles_location'], self.filename)
write_file(filepath, filedump)
def delete_torrentfile(self, delete_copies=False):
"""Deletes the .torrent file in the state directory in config"""
torrent_files = [
os.path.join(get_config_dir(), 'state', self.torrent_id + '.torrent')
]
if delete_copies and self.filename:
torrent_files.append(
os.path.join(self.config['torrentfiles_location'], self.filename)
)
for torrent_file in torrent_files:
log.debug('Deleting torrent file: %s', torrent_file)
try:
os.remove(torrent_file)
except OSError as ex:
log.warning('Unable to delete the torrent file: %s', ex)
def force_reannounce(self):
"""Force a tracker reannounce"""
try:
self.handle.force_reannounce()
except RuntimeError as ex:
log.debug('Unable to force reannounce: %s', ex)
return False
return True
def scrape_tracker(self):
"""Scrape the tracker
A scrape request queries the tracker for statistics such as total
number of incomplete peers, complete peers, number of downloads etc.
"""
try:
self.handle.scrape_tracker()
except RuntimeError as ex:
log.debug('Unable to scrape tracker: %s', ex)
return False
return True
def force_recheck(self):
"""Forces a recheck of the torrent's pieces"""
if self.forced_error:
self.forcing_recheck_paused = self.forced_error.was_paused
self.clear_forced_error_state(update_state=False)
else:
self.forcing_recheck_paused = self.status.paused
try:
self.handle.force_recheck()
self.handle.resume()
self.forcing_recheck = True
except RuntimeError as ex:
log.debug('Unable to force recheck: %s', ex)
self.forcing_recheck = False
return self.forcing_recheck
def rename_files(self, filenames):
"""Renames files in the torrent.
Args:
filenames (list): A list of (index, filename) pairs.
"""
for index, filename in filenames:
# Make sure filename is a sanitized unicode string.
filename = sanitize_filepath(decode_bytes(filename))
# lt needs utf8 byte-string. Otherwise if wstrings enabled, unicode string.
try:
self.handle.rename_file(index, filename.encode('utf8'))
except (UnicodeDecodeError, TypeError):
self.handle.rename_file(index, filename)
def rename_folder(self, folder, new_folder):
"""Renames a folder within a torrent.
This basically does a file rename on all of the folders children.
Args:
folder (str): The original folder name
new_folder (str): The new folder name
Returns:
twisted.internet.defer.Deferred: A deferred which fires when the rename is complete
"""
log.debug('Attempting to rename folder: %s to %s', folder, new_folder)
# Empty string means remove the dir and move its content to the parent
if len(new_folder) > 0:
new_folder = sanitize_filepath(new_folder, folder=True)
def on_file_rename_complete(dummy_result, wait_dict, index):
"""File rename complete"""
wait_dict.pop(index, None)
wait_on_folder = {}
self.waiting_on_folder_rename.append(wait_on_folder)
for _file in self.get_files():
if _file['path'].startswith(folder):
# Keep track of filerenames we're waiting on
wait_on_folder[_file['index']] = Deferred().addBoth(
on_file_rename_complete, wait_on_folder, _file['index']
)
new_path = _file['path'].replace(folder, new_folder, 1)
try:
self.handle.rename_file(_file['index'], new_path.encode('utf8'))
except (UnicodeDecodeError, TypeError):
self.handle.rename_file(_file['index'], new_path)
def on_folder_rename_complete(dummy_result, torrent, folder, new_folder):
"""Folder rename complete"""
component.get('EventManager').emit(
TorrentFolderRenamedEvent(torrent.torrent_id, folder, new_folder)
)
# Empty folders are removed after libtorrent folder renames
self.remove_empty_folders(folder)
torrent.waiting_on_folder_rename = [
_dir for _dir in torrent.waiting_on_folder_rename if _dir
]
component.get('TorrentManager').save_resume_data((self.torrent_id,))
d = DeferredList(list(wait_on_folder.values()))
d.addBoth(on_folder_rename_complete, self, folder, new_folder)
return d
def remove_empty_folders(self, folder):
"""Recursively removes folders but only if they are empty.
This cleans up after libtorrent folder renames.
Args:
folder (str): The folder to recursively check
"""
# Removes leading slashes that can cause join to ignore download_location
download_location = self.options['download_location']
folder_full_path = os.path.normpath(
os.path.join(download_location, folder.lstrip('\\/'))
)
try:
if not os.listdir(folder_full_path):
os.removedirs(folder_full_path)
log.debug('Removed Empty Folder %s', folder_full_path)
else:
for root, dirs, dummy_files in os.walk(folder_full_path, topdown=False):
for name in dirs:
try:
os.removedirs(os.path.join(root, name))
log.debug(
'Removed Empty Folder %s', os.path.join(root, name)
)
except OSError as ex:
log.debug(ex)
except OSError as ex:
log.debug('Cannot Remove Folder: %s', ex)
def cleanup_prev_status(self):
"""Checks the validity of the keys in the prev_status dict.
If the key is no longer valid, the dict will be deleted.
"""
# Dict will be modified so iterate over generated list
for key in list(self.prev_status):
if not self.rpcserver.is_session_valid(key):
del self.prev_status[key]
def _get_pieces_info(self):
"""Get the pieces for this torrent."""
if not self.has_metadata or self.status.is_seeding:
pieces = None
else:
pieces = []
for piece, avail_piece in zip(
self.status.pieces, self.handle.piece_availability()
):
if piece:
pieces.append(3) # Completed.
elif avail_piece:
pieces.append(
1
) # Available, just not downloaded nor being downloaded.
else:
pieces.append(
0
) # Missing, no known peer with piece, or not asked for yet.
for peer_info in self.handle.get_peer_info():
if peer_info.downloading_piece_index >= 0:
pieces[
peer_info.downloading_piece_index
] = 2 # Being downloaded from peer.
return pieces