Skip to content
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)