From 2e2851dc13d73352530dd4495c7e05603b2e520d Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 10 Apr 2024 23:38:38 +0200 Subject: Adding upstream version 2.1.2~dev0+20240219. Signed-off-by: Daniel Baumann --- deluge/core/torrent.py | 1563 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1563 insertions(+) create mode 100644 deluge/core/torrent.py (limited to 'deluge/core/torrent.py') diff --git a/deluge/core/torrent.py b/deluge/core/torrent.py new file mode 100644 index 0000000..57ec26f --- /dev/null +++ b/deluge/core/torrent.py @@ -0,0 +1,1563 @@ +# +# Copyright (C) 2007-2009 Andrew Resch +# +# 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. + +""" + +import logging +import os +import socket +import time +from typing import Optional +from urllib.parse import urlparse + +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, +) + +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 (file_storage): 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 in range(files.num_files()): + try: + file_path = files.file_path(index).decode('utf8') + except AttributeError: + file_path = files.file_path(index) + + filelist.append( + { + 'index': index, + 'path': file_path.replace('\\', '/'), + 'size': files.file_size(index), + 'offset': files.file_offset(index), + } + ) + + 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().__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: + 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: + """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: Optional['lt.torrent_status'] = None + self._status_last_update: float = 0.0 + + self.torrent_info = self.handle.torrent_file() + 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._create_status_funcs() + self.set_options(self.options) + self.update_state() + + if log.isEnabledFor(logging.DEBUG): + log.debug('Torrent object created.') + + def _set_handle_flags(self, flag: lt.torrent_flags, set_flag: bool): + """set or unset a flag to the lt handle + + Args: + flag (lt.torrent_flags): the flag to set/unset + set_flag (bool): True for setting the flag, False for unsetting it + """ + if set_flag: + self.handle.set_flags(flag) + else: + self.handle.unset_flags(flag) + + 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_down_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.get_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._set_handle_flags( + flag=lt.torrent_flags.sequential_download, + set_flag=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._set_handle_flags( + flag=lt.torrent_flags.auto_managed, + set_flag=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._set_handle_flags( + flag=lt.torrent_flags.super_seeding, + set_flag=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.get_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 = list(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.get_lt_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._set_handle_flags( + flag=lt.torrent_flags.auto_managed, + set_flag=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.get_lt_status() + self._set_handle_flags( + flag=lt.torrent_flags.auto_managed, + set_flag=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._set_handle_flags( + flag=lt.torrent_flags.auto_managed, + set_flag=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': f'{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.status().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 OSError: + 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.files().file_path(0)) + name = filename.replace('\\', '/', 1).split('/', 1)[0] + else: + name = decode_bytes(self.handle.status().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.get_lt_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 get_lt_status(self) -> 'lt.torrent_status': + """Get the torrent status fresh, not from cache. + + This should be used when a guaranteed fresh status is needed rather than + `torrent.handle.status()` because it will update the cache as well. + """ + self.status = self.handle.status() + return self.status + + @property + def status(self) -> 'lt.torrent_status': + """Cached copy of the libtorrent status for this torrent. + + If it has not been updated within the last five seconds, it will be + automatically refreshed. + """ + if self._status_last_update < (time.time() - 5): + self.status = self.handle.status() + return self._status + + @status.setter + def status(self, status: 'lt.torrent_status') -> None: + """Updates the cached status. + + Args: + status: a libtorrent torrent status + """ + self._status = status + self._status_last_update = time.time() + + 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._set_handle_flags( + flag=lt.torrent_flags.auto_managed, + set_flag=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._set_handle_flags( + flag=lt.torrent_flags.auto_managed, + set_flag=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 OSError 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 -- cgit v1.2.3