Skip to content

Add a command fileopen to open files in external programs #200

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
162 changes: 162 additions & 0 deletions stig/commands/base/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
6 changes: 6 additions & 0 deletions stig/commands/cli/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down Expand Up @@ -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'}
9 changes: 9 additions & 0 deletions stig/commands/tui/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)