#! /usr/bin/python3
"""Torrent Virtual FileSystem for Midnight Commander

The script requires Midnight Commander 3.1+
(http://www.midnight-commander.org/), Python 2.7+ (http://www.python.org/),
module eff_bdecode.py (from effbot.org/zone/bencode.htm) is now included
in the code.

For mc 4.7+ just put the script in $HOME/[.local/share/].mc/extfs.d.
For older versions put it in /usr/[local/][lib|share]/mc/extfs
and add a line "torrent" to the /usr/[local/][lib|share]/mc/extfs/extfs.ini.
Make the script executable.

For mc 4.7+ run this "cd" command in the Midnight Commander (in the "bindings"
file the command is "%cd"): cd file/torrent://; In older versions it is
cd file#torrent, where "file" is the name of your torrent metafile.

See detailed installation instructions at
https://phdru.name/Software/mc/torrent_INSTALL.html.

The VFS lists all files and directories from the torrent metafile; all files
appear empty, of course, but the sizes are shown. Filenames are reencoded from
the metafile's encoding/codepage to the current locale.

Along with the files/directories in the torrent metafile the VFS also presents
meta information - in the form of files in .META directory. The size and
contents of these files are taken from the corresponding fields in the torrent
metafile. The script doesn't check if the torrent consists of a .META file or
directory (quite unlikely).

Date/time for all directories/files is set to the value of 'creation date'
field, if it exists; if not date/time is set to the last modification time of
the torrent file itself.

The filesystem is, naturally, read-only.

"""

__version__ = "1.4.1"
__author__ = "Oleg Broytman <phd@phdru.name>"
__copyright__ = "Copyright (C) 2010-2025 PhiloSoft Design"
__license__ = "GPL"


from datetime import datetime
from os.path import dirname, getmtime
import os
import subprocess
import sys
from time import localtime, asctime

if 'MC_TEST_EXTFS_LIST_CMD' in os.environ:
    from time import gmtime as localtime
else:
    from time import localtime

try:
    import locale
    use_locale = True
except ImportError:
    use_locale = False

if use_locale:
    # Get the default charset.
    try:
        if sys.version_info[:2] < (3, 11):
            lcAll = locale.getdefaultlocale()
        else:
            lcAll = []
    except locale.Error as err:
        #print("WARNING:", err, file=sys.stderr)
        lcAll = []

    if len(lcAll) == 2:
        default_encoding = lcAll[1]
    else:
        try:
            default_encoding = locale.getpreferredencoding()
        except locale.Error as err:
            #print("WARNING:", err, file=sys.stderr)
            default_encoding = sys.getdefaultencoding()
else:
    default_encoding = sys.getdefaultencoding()

if default_encoding is None:
    default_encoding = "utf-8"

import logging
logger = logging.getLogger('torrent-mcextfs')
log_err_handler = logging.StreamHandler(sys.stderr)
logger.addHandler(log_err_handler)
logger.setLevel(logging.INFO)

if len(sys.argv) < 3:
    logger.critical("""\
Torrent Virtual FileSystem for Midnight Commander version %s
Author: %s
%s

This is not a program. Put the script in $HOME/[.local/share/].mc/extfs.d or
/usr/[local/][lib|share]/mc/extfs. For more information read the source!""",
                    __version__, __author__, __copyright__)
    sys.exit(1)

locale.setlocale(locale.LC_ALL, '')

PY3 = (sys.version_info[0] >= 3)
if PY3:
    def output(s):
        sys.stdout.buffer.write(s.encode(default_encoding, 'replace') + b'\n')
else:
    def output(s):
        sys.stdout.write(s + '\n')

# ---------- eff_bdecode.py ----------
# effbot.org/zone/bencode.htm
#
# Copyright (C) 1995-2013 by Fredrik Lundh
#
# By obtaining, using, and/or copying this software and/or its associated
# documentation, you agree that you have read, understood, and will comply with
# the following terms and conditions:
#
# Permission to use, copy, modify, and distribute this software and its
# associated documentation for any purpose and without fee is hereby granted,
# provided that the above copyright notice appears in all copies, and that both
# that copyright notice and this permission notice appear in supporting
# documentation, and that the name of Secret Labs AB or the author not be used
# in advertising or publicity pertaining to distribution of the software
# without specific, written prior permission.
#
# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS
# SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN
# NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR ANY SPECIAL,
# INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
# OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
# PERFORMANCE OF THIS SOFTWARE.

from functools import partial
import re


def tokenize(text, match=re.compile(b"([idel])|(\\d+):|(-?\\d+)").match):
    i = 0
    while i < len(text):
        m = match(text, i)
        s = m.group(m.lastindex)
        i = m.end()
        if m.lastindex == 2:
            yield "s"
            yield text[i:i+int(s)]
            i = i + int(s)
        else:
            yield s.decode('ascii')


def decode_item(next_, token):
    if token == "i":
        # integer: "i" value "e"
        data = int(next_())
        if next_() != "e":
            raise ValueError
    elif token == "s":
        # string: "s" value (virtual tokens)
        data = next_()
    elif token == "l" or token == "d":
        # container: "l" (or "d") values "e"
        data = []
        tok = next_()
        while tok != "e":
            data.append(decode_item(next_, tok))
            try:
                tok = next_()
            except StopIteration:
                break
        if token == "d":
            data = dict(zip(data[0::2], data[1::2]))
    else:
        raise ValueError
    return data


def eff_bdecode(text):
    try:
        src = tokenize(text)
        data = decode_item(partial(next, src), next(src))
        for token in src:  # look for more tokens
            raise SyntaxError("trailing junk")
    except (AttributeError, ValueError, StopIteration):
        raise SyntaxError("syntax error")
    return data
# ---------- /eff_bdecode.py ----------


def mctorrent_list():
    """List the entire VFS"""

    info = torrent['info']
    if 'name' not in info and 'name.utf-8' not in info:
        torrent_error('Unknown name')

    codepage = torrent.get('codepage', None)
    encoding = torrent.get('encoding', None)
    if not encoding and codepage:
        encoding = str(codepage)

    name = info['name']
    name_utf8 = info.get('name.utf-8', None)

    dt = None
    if 'files' in info:
        files = info['files']
        paths = []
        for file in files:
            if 'path' not in file and 'path.utf-8' not in file:
                torrent_error('Unknown path')
            if 'length' not in file:
                torrent_error('Unknown length')
            if 'path.utf-8' in file:
                if name_utf8:
                    path = '/'.join([name_utf8] + file['path.utf-8'])
                else:
                    path = '/'.join([name] + file['path.utf-8'])
            else:
                if name_utf8:
                    path = '/'.join([name_utf8] + path)
                else:
                    path = '/'.join([name] + file['path'])
            length = file['length']
            paths.append((path, length))
    else:  # One-file torrent
        if 'length' not in info:
            torrent_error('Unknown length')
        length = info['length']
        if name_utf8:
            name = name_utf8
        paths = [(name, length)]

    meta = []
    for name in 'announce', 'announce-list', 'codepage', 'comment', \
                'created by', 'creation date', 'encoding', \
                'nodes', 'publisher', 'publisher-url':
        if name == 'comment' and 'comment.utf-8' in torrent:
            data = torrent['comment.utf-8']
            meta.append(('.META/' + name, len(data)))
        elif name in torrent:
            if name == 'announce-list':
                data = decode_announce_list(torrent[name])
            elif name == 'codepage':
                data = str(torrent[name])
            elif name == 'creation date':
                dt = torrent[name]
                data = decode_datetime_asc(dt)
                dt = decode_datetime(dt)
            elif name == 'nodes':
                data = ['%s:%s' % (host, port) for host, port in torrent[name]]
                data = '\n'.join(data)
            else:
                data = torrent[name]
            meta.append(('.META/' + name, len(data)))

    if 'private' in info:
        meta.append(('.META/private', 1))

    if 'piece length' in info:
        meta.append(('.META/piece length', len(str(info['piece length']))))

    paths += meta
    dirs = set()
    for name, size in paths:
        if '/' in name:
            dirs.add(dirname(name))

    if not dt:
        dt = decode_datetime(getmtime(sys.argv[2]))

    for name in sorted(dirs):
        output("dr-xr-xr-x 1 0 0 0 %s %s" % (dt, name))

    for name, size in sorted(paths):
        output("-r--r--r-- 1 0 0 %d %s %s" % (size, dt, name))


def mctorrent_copyout():
    """Extract a file from the VFS"""

    torrent_filename = sys.argv[3]
    real_filename = sys.argv[4]
    data = None

    for name in 'announce', 'announce-list', 'codepage', 'comment', \
                'created by', 'creation date', 'encoding', \
                'nodes', 'publisher', 'publisher-url':
        if name == 'comment' and 'comment.utf-8' in torrent:
            data = torrent['comment.utf-8']
        elif torrent_filename == '.META/' + name:
            if name in torrent:
                if name == 'announce-list':
                    data = decode_announce_list(torrent[name])
                elif name == 'codepage':
                    data = str(torrent[name])
                elif name == 'creation date':
                    data = decode_datetime_asc(torrent[name])
                elif name == 'nodes':
                    data = ['%s:%s' % (host, port)
                            for host, port in torrent[name]]
                    data = '\n'.join(data)
                else:
                    data = str(torrent[name])
            else:
                torrent_error('Unknown ' + name)
            break

    if torrent_filename in ('.META/private', '.META/piece length'):
        info = torrent['info']
        if torrent_filename == '.META/private':
            if 'private' not in info:
                torrent_error('Private absent')
        if torrent_filename == '.META/piece length':
            if 'piece length' not in info:
                torrent_error('Piece length absent')
        data = str(info[torrent_filename[len('.META/'):]])

    if not torrent_filename.startswith('.META/'):
        data = ''

    if data is None:
        torrent_error('Unknown file name')
    else:
        outfile = open(real_filename, 'wt')
        outfile.write(data)
        outfile.close()


def mctorrent_copyin():
    """Put a file to the VFS"""
    sys.exit("Torrent VFS doesn't support adding/overwriting files "
             "(read-only filesystem)")


def mctorrent_rm():
    """Remove a file from the VFS"""
    sys.exit("Torrent VFS doesn't support removing files/directories "
             "(read-only filesystem)")


mctorrent_rmdir = mctorrent_rm


def mctorrent_mkdir():
    """Create a directory in the VFS"""
    sys.exit("Torrent VFS doesn't support creating directories "
             "(read-only filesystem)")


def torrent_error(error_str):
    logger.critical("Error parsing the torrent metafile: %s", error_str)
    sys.exit(1)


def decode_dict(d, encoding):
    new_d = {}
    for k in d:
        v = d[k]
        k = k.decode(encoding)
        if isinstance(v, dict):
            v = decode_dict(v, encoding)
        elif isinstance(v, list):
            v = decode_list(v, encoding)
        elif isinstance(v, bytes):
            v = v.decode(encoding)
        new_d[k] = v
    return new_d


def decode_list(l, encoding):
    new_l = []
    for v in l:
        if isinstance(v, dict):
            v = decode_dict(v, encoding)
        elif isinstance(v, list):
            v = decode_list(v, encoding)
        elif isinstance(v, bytes):
            v = v.decode(encoding)
        new_l.append(v)
    return new_l


def decode_torrent():
    helper = os.environ.get('MC_TEST_EXTFS_LIST_CMD')
    try:
        if helper is not None:
            data = subprocess.check_output(helper.split())
        else:
            torrent_file = open(sys.argv[2], 'rb')
            data = torrent_file.read()
            torrent_file.close()
        torrent = eff_bdecode(data)
    except IOError as error_str:
        torrent_error(error_str)

    del torrent[b'info'][b'pieces']
    if b'info' not in torrent:
        torrent_error('Info absent')

    if PY3:
        codepage = torrent.get(b'codepage', None)
        encoding = torrent.get(b'encoding', None)
        if encoding:
            encoding = encoding.decode('ascii')
        elif codepage:
            encoding = codepage.decode('ascii')
        else:
            for encoding in ('ascii', 'utf-8', default_encoding):
                try:
                    return decode_dict(torrent, encoding)
                except UnicodeDecodeError:
                    pass
            torrent_error('UnicodeDecodeError')
        return decode_dict(torrent, encoding)

    return torrent


def decode_datetime_asc(dt):
    try:
        return asctime(localtime(float(dt)))
    except ValueError:
        return datetime.max.ctime()


def decode_datetime(dt):
    try:
        Y, m, d, H, M = localtime(float(dt))[0:5]
    except ValueError:
        return datetime.max.ctime()
    if Y > 9999:
        Y = 9999
    return "%02d-%02d-%d %02d:%02d" % (m, d, Y, H, M)


def decode_announce_list(announce):
    return '\n'.join(a[0] for a in announce if a)


command = sys.argv[1]
procname = "mctorrent_" + command

g = globals()
if procname not in g:
    logger.critical("Unknown command %s", command)
    sys.exit(1)

torrent = decode_torrent()

try:
    g[procname]()
except SystemExit:
    raise
except Exception:
    logger.exception("Error during run")
