Source code for flexget.plugins.clients.deluge

import base64
import os
import re
import sys
import time
from pathlib import Path

import pendulum
from loguru import logger

from flexget import plugin
from flexget.entry import Entry
from flexget.event import event
from flexget.utils.pathscrub import pathscrub
from flexget.utils.template import RenderError

logger = logger.bind(name='deluge')


[docs] class DelugePlugin: """Base class for deluge plugins, contains settings and methods for connecting to a deluge daemon."""
[docs] def on_task_start(self, task, config): """Fail early if we can't import/configure the deluge client.""" self.setup_client(config)
[docs] def setup_client(self, config): try: from deluge_client import DelugeRPCClient except ImportError as e: logger.debug('Error importing deluge-client: {}', e) raise plugin.DependencyError( 'deluge', 'deluge-client', 'deluge-client >=1.5 is required. `pip install deluge-client` to install.', logger, ) config = self.prepare_config(config) if config['host'] in ['localhost', '127.0.0.1'] and not config.get('username'): # If an username is not specified, we have to do a lookup for the localclient username/password auth = self.get_localhost_auth(config.get('config_path')) if auth and auth[0]: config['username'], config['password'] = auth if not config.get('username') or not config.get('password'): raise plugin.PluginError( 'Unable to get authentication info for Deluge. You may need to ' 'specify an username and password from your Deluge auth file.' ) return DelugeRPCClient( config['host'], config['port'], config['username'], config['password'], decode_utf8=True, )
[docs] def prepare_config(self, config): config.setdefault('host', 'localhost') config.setdefault('port', 58846) return config
[docs] @staticmethod def get_localhost_auth(config_path=None): if config_path is None: if sys.platform.startswith('win'): auth_file = os.path.join(os.getenv('APPDATA'), 'deluge', 'auth') else: auth_file = os.path.expanduser('~/.config/deluge/auth') else: auth_file = os.path.join(config_path, 'auth') if not os.path.isfile(auth_file): return None with open(auth_file) as auth: for line in auth: line = line.strip() if line.startswith('#') or not line: # This is a comment or blank line continue lsplit = line.split(':') if lsplit[0] == 'localclient': username, password = lsplit[:2] return username, password return None
[docs] class InputDeluge(DelugePlugin): """Create entries for torrents in the deluge session.""" # Fields we provide special cases for settings_map = { 'name': 'title', 'hash': 'torrent_info_hash', 'num_peers': 'torrent_peers', 'num_seeds': 'torrent_seeds', 'total_size': 'content_size', 'files': ('content_files', lambda file_dicts: [f['path'] for f in file_dicts]), # Convert to datetimes for easier use in 'if' plugin and templates 'time_added': ('deluge_date_added', pendulum.from_timestamp), 'completed_time': ( 'deluge_date_completed', lambda timestamp: pendulum.from_timestamp(timestamp) if timestamp else None, ), } schema = { 'anyOf': [ {'type': 'boolean'}, { 'type': 'object', 'properties': { 'host': {'type': 'string'}, 'port': {'type': 'integer'}, 'username': {'type': 'string'}, 'password': {'type': 'string'}, 'config_path': {'type': 'string', 'format': 'path'}, 'filter': { 'type': 'object', 'properties': { 'label': {'type': 'string'}, 'state': { 'type': 'string', 'enum': ['active', 'downloading', 'seeding', 'queued', 'paused'], }, }, 'additionalProperties': False, }, }, 'additionalProperties': False, }, ] }
[docs] def on_task_start(self, task, config): config = self.prepare_config(config) super().on_task_start(task, config)
[docs] def prepare_config(self, config): if isinstance(config, bool): config = {} if 'filter' in config: filter = config['filter'] if 'label' in filter: filter['label'] = filter['label'].lower() if 'state' in filter: filter['state'] = filter['state'].capitalize() super().prepare_config(config) return config
[docs] def on_task_input(self, task, config): """Generate and return a list of entries from the deluge daemon.""" config = self.prepare_config(config) # Reset the entries list client = self.setup_client(config) try: client.connect() except ConnectionError as exc: raise plugin.PluginError( f'Error connecting to deluge daemon: {exc}', logger=logger ) from exc entries = self.generate_entries(client, config) client.disconnect() return entries
[docs] def generate_entries(self, client, config): entries = [] filter = config.get('filter', {}) torrents = client.call('core.get_torrents_status', filter or {}, []) for hash, torrent_dict in torrents.items(): # Make sure it has a url so no plugins crash entry = Entry(deluge_id=hash, url='') if config.get('config_path'): config_path = Path(config['config_path']).expanduser() torrent_path = config_path / 'state' / f'{hash}.torrent' if torrent_path.is_file(): entry['location'] = torrent_path entry['url'] = torrent_path.as_uri() else: logger.warning('Did not find torrent file at {}', torrent_path) # Pieces is just a really long list, cluttering up the entry and --dump output blacklist_fields = ['pieces'] for key, value in torrent_dict.items(): # All fields (except a few) provided by deluge get placed under the deluge_ namespace if key in blacklist_fields: continue entry['deluge_' + key] = value # Some fields also get special handling if key in self.settings_map: flexget_key = self.settings_map[key] if isinstance(flexget_key, tuple): flexget_key, format_func = flexget_key value = format_func(value) entry[flexget_key] = value entries.append(entry) return entries
[docs] class OutputDeluge(DelugePlugin): """Add the torrents directly to deluge, supporting custom save paths.""" schema = { 'anyOf': [ {'type': 'boolean'}, { 'type': 'object', 'properties': { 'host': {'type': 'string'}, 'port': {'type': 'integer'}, 'username': {'type': 'string'}, 'password': {'type': 'string'}, 'config_path': {'type': 'string', 'format': 'path'}, 'action': { 'type': 'string', 'enum': ['add', 'remove', 'purge', 'pause', 'resume'], }, 'path': {'type': 'string'}, 'move_completed_path': {'type': 'string'}, 'label': {'type': 'string'}, 'queue_to_top': {'type': 'boolean'}, 'auto_managed': {'type': 'boolean'}, 'max_up_speed': {'type': 'number'}, 'max_down_speed': {'type': 'number'}, 'max_connections': {'type': 'integer'}, 'max_up_slots': {'type': 'integer'}, 'ratio': {'type': 'number'}, 'remove_at_ratio': {'type': 'boolean'}, 'add_paused': {'type': 'boolean'}, 'compact': {'type': 'boolean'}, 'content_filename': {'type': 'string'}, 'main_file_only': {'type': 'boolean'}, 'main_file_ratio': {'type': 'number'}, 'magnetization_timeout': {'type': 'integer'}, 'keep_subs': {'type': 'boolean'}, 'hide_sparse_files': {'type': 'boolean'}, 'enabled': {'type': 'boolean'}, 'container_directory': {'type': 'string'}, 'force_recheck': {'type': 'boolean'}, }, 'additionalProperties': False, }, ] }
[docs] def prepare_config(self, config): if isinstance(config, bool): config = {'enabled': config} super().prepare_config(config) config.setdefault('enabled', True) config.setdefault('action', 'add') config.setdefault('path', '') config.setdefault('move_completed_path', '') config.setdefault('label', '') config.setdefault('main_file_ratio', 0.90) config.setdefault('magnetization_timeout', 0) config.setdefault( 'keep_subs', True ) # does nothing without 'content_filename' or 'main_file_only' enabled config.setdefault( 'hide_sparse_files', False ) # does nothing without 'main_file_only' enabled config.setdefault('force_recheck', False) return config
def __init__(self): self.deluge_version = None self.options = { 'max_up_speed': 'max_upload_speed', 'max_down_speed': 'max_download_speed', 'max_connections': 'max_connections', 'max_up_slots': 'max_upload_slots', 'auto_managed': 'auto_managed', 'ratio': 'stop_ratio', 'remove_at_ratio': 'remove_at_ratio', 'add_paused': 'add_paused', 'compact': 'compact_allocation', }
[docs] @plugin.priority(120) def on_task_download(self, task, config): """Call download plugin to generate the temp files we will load into deluge then verify they are valid torrents.""" config = self.prepare_config(config) if not config['enabled']: return # If the download plugin is not enabled, we need to call it to get our temp .torrent files if 'download' not in task.config: download = plugin.get('download', self) for entry in task.accepted: if entry.get('deluge_id'): # The torrent is already loaded in deluge, we don't need to get anything continue if config['action'] != 'add' and entry.get('torrent_info_hash'): # If we aren't adding the torrent new, all we need is info hash continue download.get_temp_file(task, entry, handle_magnets=True)
[docs] @plugin.priority(135) def on_task_output(self, task, config): """Add torrents to deluge at exit.""" config = self.prepare_config(config) client = self.setup_client(config) # don't add when learning if task.options.learn: return if not config['enabled'] or not (task.accepted or task.options.test): return try: client.connect() except ConnectionError as exc: raise plugin.PluginError( f'Error connecting to deluge daemon: {exc}', logger=logger ) from exc if task.options.test: logger.debug('Test connection to deluge daemon successful.') client.disconnect() return # loop through entries to get a list of labels to add labels = set() for entry in task.accepted: label = entry.get('label') or config.get('label') if label and label.lower() != 'no label': try: label = self._format_label(entry.render(label)) logger.debug('Rendered label: {}', label) except RenderError as e: logger.error('Error rendering label `{}`: {}', label, e) continue labels.add(label) if labels: # Make sure the label plugin is available and enabled, then add appropriate labels enabled_plugins = client.call('core.get_enabled_plugins') label_enabled = 'Label' in enabled_plugins if not label_enabled: available_plugins = client.call('core.get_available_plugins') if 'Label' in available_plugins: logger.debug('Enabling label plugin in deluge') label_enabled = client.call('core.enable_plugin', 'Label') else: logger.error('Label plugin is not installed in deluge') if label_enabled: d_labels = client.call('label.get_labels') for label in labels: if label not in d_labels: logger.debug('Adding the label `{}` to deluge', label) client.call('label.add', label) # add the torrents torrent_ids = client.call('core.get_session_state') for entry in task.accepted: # Generate deluge options dict for torrent add add_opts = {} try: path = entry.render(entry.get('path') or config['path']) if path: add_opts['download_location'] = pathscrub(os.path.expanduser(path)) except RenderError as e: logger.error('Could not set path for {}: {}', entry['title'], e) for fopt, dopt in self.options.items(): value = entry.get(fopt, config.get(fopt)) if value is not None: add_opts[dopt] = value if fopt == 'ratio': add_opts['stop_at_ratio'] = True # Make another set of options, that get set after the torrent has been added modify_opts = { 'queue_to_top': entry.get('queue_to_top', config.get('queue_to_top')), 'main_file_only': entry.get('main_file_only', config.get('main_file_only', False)), 'main_file_ratio': entry.get('main_file_ratio', config.get('main_file_ratio')), 'hide_sparse_files': entry.get( 'hide_sparse_files', config.get('hide_sparse_files', True) ), 'keep_subs': entry.get('keep_subs', config.get('keep_subs', True)), 'container_directory': config.get('container_directory', ''), 'force_recheck': entry.get('force_recheck', config.get('force_recheck')), } try: label = entry.render(entry.get('label') or config['label']) modify_opts['label'] = self._format_label(label) except RenderError as e: logger.error('Error setting label for `{}`: {}', entry['title'], e) try: move_completed_path = entry.render( entry.get('move_completed_path') or config['move_completed_path'] ) modify_opts['move_completed_path'] = pathscrub( os.path.expanduser(move_completed_path) ) except RenderError as e: logger.error('Error setting move_completed_path for {}: {}', entry['title'], e) try: content_filename = entry.get('content_filename') or config.get( 'content_filename', '' ) modify_opts['content_filename'] = pathscrub(entry.render(content_filename)) except RenderError as e: logger.error('Error setting content_filename for {}: {}', entry['title'], e) torrent_id = entry.get('deluge_id') or entry.get('torrent_info_hash') torrent_id = torrent_id and torrent_id.lower() if torrent_id in torrent_ids: logger.info('{} is already loaded in deluge, setting options', entry['title']) # Entry has a deluge id, verify the torrent is still in the deluge session and apply options # Since this is already loaded in deluge, we may also need to change the path modify_opts['path'] = add_opts.pop('download_location', None) client.call('core.set_torrent_options', [torrent_id], add_opts) self._set_torrent_options(client, torrent_id, entry, modify_opts) elif config['action'] != 'add': logger.warning( 'Cannot {} {}, because it is not loaded in deluge.', config['action'], entry['title'], ) continue else: magnet, filedump = None, None if entry.get('url', '').startswith('magnet:'): magnet = entry['url'] else: if not os.path.exists(entry['file']): entry.fail( "Downloaded temp file '{}' doesn't exist!".format(entry['file']) ) del entry['file'] return with open(entry['file'], 'rb') as f: filedump = base64.encodebytes(f.read()) logger.verbose('Adding {} to deluge.', entry['title']) added_torrent = None if magnet: try: added_torrent = client.call('core.add_torrent_magnet', magnet, add_opts) except Exception as exc: logger.error('{} was not added to deluge! {}', entry['title'], exc) logger.opt(exception=True).debug('Error adding magnet:') entry.fail('Could not be added to deluge') else: if config.get('magnetization_timeout'): timeout = config['magnetization_timeout'] logger.verbose( 'Waiting {} seconds for "{}" to magnetize', timeout, entry['title'] ) for _ in range(timeout): time.sleep(1) try: status = client.call( 'core.get_torrent_status', added_torrent, ['files'] ) except Exception as err: logger.error('wait_for_metadata Error: {}', err) break if status.get('files'): logger.info('"{}" magnetization successful', entry['title']) break else: logger.warning( '"{}" did not magnetize before the timeout elapsed, ' 'file list unavailable for processing.', entry['title'], ) else: try: added_torrent = client.call( 'core.add_torrent_file', entry['title'], filedump, add_opts ) except Exception as e: logger.error('{} was not added to deluge! {}', entry['title'], e) entry.fail('Could not be added to deluge') if not added_torrent: logger.error('There was an error adding {} to deluge.', entry['title']) else: logger.info('{} successfully added to deluge.', entry['title']) self._set_torrent_options(client, added_torrent, entry, modify_opts) if config['action'] in ('remove', 'purge'): client.call('core.remove_torrent', torrent_id, config['action'] == 'purge') logger.info('{} removed from deluge.', entry['title']) elif config['action'] == 'pause': client.call('core.pause_torrent', [torrent_id]) logger.info('{} has been paused in deluge.', entry['title']) elif config['action'] == 'resume': client.call('core.resume_torrent', [torrent_id]) logger.info('{} has been resumed in deluge.', entry['title']) client.disconnect()
[docs] def on_task_learn(self, task, config): """Make sure all temp files are cleaned up when entries are learned.""" # If download plugin is enabled, it will handle cleanup. if 'download' not in task.config: download = plugin.get('download', self) download.cleanup_temp_files(task)
[docs] def on_task_abort(self, task, config): """Make sure normal cleanup tasks still happen on abort.""" self.on_task_learn(task, config)
[docs] def _format_label(self, label): """Make a string compliant with deluge label naming rules.""" # "No Label" is a special identifier to unset a label if label.lower() == 'no label': return 'No Label' return re.sub(r'[^\w-]+', '_', label.lower())
[docs] def _set_torrent_options(self, client, torrent_id, entry, opts): """Get called when a torrent was added to the daemon.""" entry['deluge_id'] = torrent_id if opts.get('move_completed_path'): client.call( 'core.set_torrent_options', [torrent_id], {'move_completed': True, 'move_completed_path': opts['move_completed_path']}, ) logger.debug( '{} move on complete set to {}', entry['title'], opts['move_completed_path'] ) if opts.get('label'): client.call('label.set_torrent', torrent_id, opts['label']) if opts.get('queue_to_top') is not None: if opts['queue_to_top']: client.call('core.queue_top', [torrent_id]) logger.debug('{} moved to top of queue', entry['title']) else: client.call('core.queue_bottom', [torrent_id]) logger.debug('{} moved to bottom of queue', entry['title']) status_keys = [ 'files', 'total_size', 'save_path', 'move_on_completed_path', 'move_on_completed', 'progress', ] status = client.call('core.get_torrent_status', torrent_id, status_keys) # Determine where the file should be move_now_path = None if opts.get('move_completed_path'): if status['progress'] == 100: move_now_path = opts['move_completed_path'] else: # Deluge will unset the move completed option if we move the storage, forgo setting proper # path, in favor of leaving proper final location. logger.debug( 'Not moving storage for {}, as this will prevent move_completed_path.', entry['title'], ) elif opts.get('path'): move_now_path = opts['path'] if move_now_path and os.path.normpath(move_now_path) != os.path.normpath( status['save_path'] ): logger.debug('Moving storage for {} to {}', entry['title'], move_now_path) client.call('core.move_storage', [torrent_id], move_now_path) big_file_name = '' if opts.get('content_filename') or opts.get('main_file_only'): # find a file that makes up more than main_file_ratio (default: 90%) of the total size main_file = None for file in status['files']: if file['size'] > (status['total_size'] * opts.get('main_file_ratio')): main_file = file break def file_exists(filename): # Checks the download path as well as the move completed path for existence of the file if os.path.exists(os.path.join(status['save_path'], filename)): return True if status.get('move_on_completed') and status.get('move_on_completed_path'): if os.path.exists(os.path.join(status['move_on_completed_path'], filename)): return True return None return False def unused_name(name): # If on local computer, tries appending a (#) suffix until a unique filename is found if client.host in ['127.0.0.1', 'localhost']: counter = 2 while file_exists(name): name = ''.join([ os.path.splitext(name)[0], ' (', str(counter), ')', os.path.splitext(name)[1], ]) counter += 1 else: logger.debug( 'Cannot ensure content_filename is unique when adding to a remote deluge daemon.' ) return name def rename(file, new_name): # Renames a file in torrent client.call('core.rename_files', torrent_id, [(file['index'], new_name)]) logger.debug('File {} in {} renamed to {}', file['path'], entry['title'], new_name) if main_file is not None: # proceed with renaming only if such a big file is found # find the subtitle file keep_subs = opts.get('keep_subs') sub_file = None if keep_subs: sub_exts = ['.srt', '.sub'] for file in status['files']: ext = os.path.splitext(file['path'])[1] if ext in sub_exts: sub_file = file break # check for single file torrents so we dont add unnecessary folders top_files_dir = '/' if os.path.dirname(main_file['path']) not in ('', '/'): # check for top folder in user config if ( opts.get('content_filename') and os.path.dirname(opts['content_filename']) != '' ): top_files_dir = os.path.dirname(opts['content_filename']) + '/' else: top_files_dir = os.path.dirname(main_file['path']) + '/' if opts.get('content_filename'): # rename the main file big_file_name = ( top_files_dir + os.path.basename(opts['content_filename']) + os.path.splitext(main_file['path'])[1] ) big_file_name = unused_name(big_file_name) rename(main_file, big_file_name) # rename subs along with the main file if sub_file is not None and keep_subs: sub_file_name = ( os.path.splitext(big_file_name)[0] + os.path.splitext(sub_file['path'])[1] ) rename(sub_file, sub_file_name) if opts.get('main_file_only'): # download only the main file (and subs) file_priorities = [ 1 if f == main_file or (f == sub_file and keep_subs) else 0 for f in status['files'] ] client.call( 'core.set_torrent_options', [torrent_id], {'file_priorities': file_priorities}, ) if opts.get('hide_sparse_files'): # hide the other sparse files that are not supposed to download but are created anyway # http://dev.deluge-torrent.org/ticket/1827 # Made sparse files behave better with deluge sparse_files = [ f for f in status['files'] if f != main_file and (f != sub_file or not keep_subs) ] rename_pairs = [ ( f['index'], top_files_dir + '.sparse_files/' + os.path.basename(f['path']), ) for f in sparse_files ] client.call('core.rename_files', torrent_id, rename_pairs) else: logger.warning( 'No files in "{}" are > {:.0f}% of content size, no files renamed.', entry['title'], opts.get('main_file_ratio') * 100, ) container_directory = pathscrub( entry.render(entry.get('container_directory') or opts.get('container_directory', '')) ) if container_directory: if big_file_name: folder_structure = big_file_name.split(os.sep) elif len(status['files']) > 0: folder_structure = status['files'][0]['path'].split(os.sep) else: folder_structure = [] if len(folder_structure) > 1: logger.verbose( 'Renaming Folder {} to {}', folder_structure[0], container_directory ) client.call( 'core.rename_folder', torrent_id, folder_structure[0], container_directory ) else: logger.debug( 'container_directory specified however the torrent {} does not have a directory structure; skipping folder rename', entry['title'], ) if opts.get('force_recheck'): client.call('core.force_recheck', [torrent_id]) logger.debug('Forced a data recheck on {}', entry['title'])
[docs] @event('plugin.register') def register_plugin(): plugin.register(InputDeluge, 'from_deluge', api_ver=2) plugin.register(OutputDeluge, 'deluge', api_ver=2)