# # 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