diff --git a/stig/commands/base/file.py b/stig/commands/base/file.py index 1ea2211b..7ef532e1 100644 --- a/stig/commands/base/file.py +++ b/stig/commands/base/file.py @@ -11,11 +11,14 @@ import asyncio +from subprocess import Popen, PIPE + from . import _mixin as mixin from .. import CmdError, CommandMeta from ... import objects from ...completion import candidates from ._common import make_COLUMNS_doc, make_SCRIPTING_doc, make_X_FILTER_spec +from natsort import humansorted from ...logging import make_logger # isort:skip log = make_logger(__name__) @@ -166,3 +169,162 @@ def completion_candidates_posargs(cls, args): elif args.curarg_index == 3: torrent_filter = args[2] return candidates.file_filter(args.curarg, torrent_filter) + + +class FOpenCmdbase(metaclass=CommandMeta): + name = 'fileopen' + aliases = ('fopen',) + provides = set() + category = 'file' + description = 'Open files using an external command' + examples = ('fileopen "that torrent" *.mkv', + 'fileopen "that torrent" *.mkv mpv' + 'fileopen "that torrent" *.mkv mpv --fullscreen',) + argspecs = ( + {'names': ('--quiet', '-q'), + 'description': 'Suppress stdout from the external command. Pass twice to also suppress stderr', + 'action': 'count', 'default': 0}, + make_X_FILTER_spec('TORRENT', or_focused=True, nargs='?'), + make_X_FILTER_spec('FILE', or_focused=True, nargs='?'), + {'names': ('COMMAND',), + 'description': 'Command to use to open files. Default: xdg-open', + 'nargs': '?' + }, + {'names': ('OPTS',), + 'description': "Options for the external command.", + 'nargs': 'REMAINDER' + }, + ) + + async def run(self, quiet, TORRENT_FILTER, FILE_FILTER, COMMAND, OPTS): + default_command = 'xdg-open' + if COMMAND is None: + command = default_command + else: + command = COMMAND + opts = [] + if OPTS is not None: + opts = OPTS + utilize_tui = not bool(TORRENT_FILTER) + try: + tfilter = self.select_torrents(TORRENT_FILTER, + allow_no_filter=False, + discover_torrent=True) + + # If the user specified a filter instead of selecting via the TUI, + # ignore focused/marked files. + log.debug('%sdiscovering file(s)', '' if utilize_tui else 'Not ') + ffilter = self.select_files(FILE_FILTER, + allow_no_filter=True, + discover_file=utilize_tui) + except ValueError as e: + raise CmdError(e) + + if not utilize_tui: + self.info('Opening %s from torrents %s with %s %s' % + ('all files' if ffilter is None else ffilter, tfilter, + command, opts)) + + self.info('Opening %s from torrents %s with %s %s' % + ('all files' if ffilter is None else ffilter, tfilter, + command, " ".join(opts))) + files = await self.make_file_list(tfilter, ffilter) + + def pipelog(pipe, logger): + s = pipe.readline() + for ln in s.split("\n"): + if len(ln): + logger(ln) + + def closepipes(proc): + loop = asyncio.get_running_loop() + if proc.poll() is None: + loop.call_later(0.1, lambda: closepipes(proc)) + return + loop.remove_reader(proc.stdout) + loop.remove_reader(proc.stderr) + + + # TODO separate options for stdout/stderr + stdoutlogger = lambda s: self.info(command + ": " + s) + if quiet >= 1: + stdoutlogger = lambda s: None + stderrlogger = lambda s: self.error(command + ": " + s) + if quiet >= 2: + stderrlogger = lambda s: None + loop = asyncio.get_running_loop() + try: + if command == default_command: + for f in files: + result = Popen([default_command, f], + stdout=PIPE, + stderr=PIPE, + text=True) + loop.add_reader(result.stdout, pipelog, result.stdout, stdoutlogger) + loop.add_reader(result.stderr, pipelog, result.stderr, stderrlogger) + loop.call_soon(lambda: closepipes(result)) + else: + result = Popen([command] + opts + list(files), + stdout=PIPE, + stderr=PIPE, + text=True) + loop.add_reader(result.stdout, pipelog, result.stdout, stdoutlogger) + loop.add_reader(result.stderr, pipelog, result.stderr, stderrlogger) + loop.call_soon(lambda: closepipes(result)) + except FileNotFoundError: + self.error("Command not found: %s" % command) + return None + + async def make_file_list(self, tfilter, ffilter): + response = await self.make_request( + objects.srvapi.torrent.torrents(tfilter, keys=('name', 'files')), + quiet=True) + torrents = response.torrents + + if len(torrents) < 1: + raise CmdError() + + filelist = [] + for torrent in humansorted(torrents, key=lambda t: t['name']): + files, filtered_count = self._flatten_tree(torrent['files'], ffilter) + filelist.extend(files) + filelist = map( + lambda f: objects.pathtranslator.to_local(str(f)), + filelist + ) + if filelist: + return filelist + else: + if str(tfilter) != 'all': + raise CmdError('No matching files in %s torrents: %s' % (tfilter, ffilter)) + else: + raise CmdError('No matching files: %s' % (ffilter)) + + def _flatten_tree(self, files, ffilter=None): + flist = [] + filtered_count = 0 + + def _match(ffilter, value): + if ffilter is None: + return True + try: + return ffilter.match(value) + except AttributeError: + pass + try: + return value['id'] in ffilter + except (KeyError, TypeError): + pass + return False + for key,value in humansorted(files.items(), key=lambda pair: pair[0]): + if value.nodetype == 'leaf': + if _match(ffilter, value) and value['size-downloaded'] == value['size-total']: + flist.append(value['path-absolute']) + else: + filtered_count += 1 + + elif value.nodetype == 'parent': + sub_flist, sub_filtered_count = self._flatten_tree(value, ffilter) + flist.extend(sub_flist) + + return flist, filtered_count diff --git a/stig/commands/cli/file.py b/stig/commands/cli/file.py index 66c85c19..594a58ce 100644 --- a/stig/commands/cli/file.py +++ b/stig/commands/cli/file.py @@ -26,6 +26,7 @@ class ListFilesCmd(base.ListFilesCmdbase, mixin.only_supported_columns): provides = {'cli'} + async def make_file_list(self, tfilter, ffilter, columns): response = await self.make_request( objects.srvapi.torrent.torrents(tfilter, keys=('name', 'files')), @@ -91,3 +92,8 @@ def indent(node): class PriorityCmd(base.PriorityCmdbase, mixin.make_request, mixin.select_torrents, mixin.select_files): provides = {'cli'} + +class FOpenCmd(base.FOpenCmdbase, + mixin.make_request, mixin.select_torrents, + mixin.select_files): + provides = {'cli'} diff --git a/stig/commands/tui/file.py b/stig/commands/tui/file.py index b593cf53..5916bdf6 100644 --- a/stig/commands/tui/file.py +++ b/stig/commands/tui/file.py @@ -30,3 +30,12 @@ def make_file_list(self, tfilter, ffilter, columns): class PriorityCmd(base.PriorityCmdbase, mixin.polling_frenzy, mixin.make_request, mixin.select_torrents, mixin.select_files): provides = {'tui'} + +class FOpenCmd(base.FOpenCmdbase, mixin.make_request, mixin.select_torrents, mixin.select_files): + provides = {'tui'} + # When files are selected in the tui, the two first arguments, the torrent + # and the file(s) need to be filled in. That is, `fopen mpv` should mean + # `fopen torrent file mpv` + + async def run(self, quiet, COMMAND, TORRENT_FILTER, FILE_FILTER, OPTS): + await base.FOpenCmdbase.run(self, quiet, TORRENT_FILTER=COMMAND, FILE_FILTER=FILE_FILTER, COMMAND=TORRENT_FILTER, OPTS=OPTS)