Skip to content

Custom Code

pyrosimple offers a couple ways to extend its functionality if you know a little Python.

Custom fields

The config.py script can be used to add custom logic to your setup. The most common use for this file is adding custom fields.

To add user-defined fields you can put code describing them into your ~/.config/pyrosimple/config.py file. You can then use your custom field just like any built-in one, e.g. issue a command like rtcontrol --from-view incomplete \* -qco partial_done,name (see below examples). They're also listed when you call rtcontrol --help-fields.

Here's an example of adding a simple custom field:

config.py
from pyrosimple.torrent import engine
def _custom_fields():
    from pyrosimple.torrent import engine
    from pyrosimple.util import fmt, matching

    # Add a single field, which is matched like a number,
    # and accessed by performing a single RPC call.
    yield engine.ConstantField(
        int, # The type of the field, supported types are: int, str, set, bool, list, untyped
        "piece_size", # Name of the field
        "Piece size for the item", # The description for --help
        matcher=matching.FloatFilter, # The filter type to use when matching the field, see pyrosimple.util.matching for a list of filters
        accessor=lambda o: o.rpc_call("d.size_chunks"), # How to actually access the method. `o` is a pyrosimple.torrent.RtorrentItem
        requires=["d.size_chunks"], # Optional, but great speeds any rtcontrol commands by allowing prefetching
    )

    # Insert any other custom fields here

# Register our custom fields to the proxy
for field in _custom_fields():
    engine.TorrentProxy.add_field(field)
rtcontrol // -o piece_size

Examples

You can see how the built-in fields are defined in torrent/engine.py if you want to see more complete examples.

Tracker info

These allow you to see the number of downloaders, seeders and leechers on items, provided the tracker supports announce scrapes.

By default rTorrent only does a single scrape on restart or when an item is first added, which is why these fields aren't available by default. You'll need to set up your configuration as described in the rTorrent github wiki in order to see up-to-date values for these fields.

from pyrosimple.torrent import engine


def _custom_fields():
    from pyrosimple.torrent import engine
    from pyrosimple.util import fmt, matching

    def get_tracker_field(obj, name, aggregator=sum):
        "Get an aggregated tracker field."
        return aggregator(
            [t[0] for t in obj.rpc_call("t.multicall", ["", f"t.{name}="])]
        )

    yield engine.DynamicField(
        int,
        "downloaders",
        "number of completed downloads",
        matcher=matching.FloatFilter,
        accessor=lambda o: get_tracker_field(o, "scrape_downloaded"),
        requires=["t.multicall=,t.scrape_downloaded="],
    )
    yield engine.DynamicField(
        int,
        "seeds",
        "number of seeds",
        matcher=matching.FloatFilter,
        accessor=lambda o: get_tracker_field(o, "scrape_complete"),
        requires=["t.multicall=,t.scrape_complete="],
    )
    yield engine.DynamicField(
        int,
        "leeches",
        "number of leeches",
        matcher=matching.FloatFilter,
        accessor=lambda o: get_tracker_field(o, "scrape_incomplete"),
        requires=["t.multicall=,t.scrape_incomplete="],
    )
    yield engine.DynamicField(
        engine.untyped,
        "lastscraped",
        "time of last scrape",
        matcher=matching.TimeFilter,
        accessor=lambda o: get_tracker_field(o, "scrape_time_last", max),
        formatter=lambda dt: fmt.human_duration(float(dt), precision=2, short=True),
        requires=["t.multicall=,t.scrape_time_last="],
    )


# Register our custom fields to the proxy
for field in _custom_fields():
    engine.TorrentProxy.add_field(field)

Peer Information

Note that due to requiring a DNS lookup, peers_hostname may take a long time to display.

from pyrosimple.torrent import engine


def _custom_fields():
    import socket

    from pyrosimple.torrent import engine
    from pyrosimple.util import fmt, matching

    # Add peer attributes not available by default
    def get_peer_data(obj, name, aggregator=None):
        "Get some peer data via a multicall."
        aggregator = aggregator or (lambda _: _)
        result = obj.rpc_call("p.multicall", ["", "p.%s=" % name])
        return aggregator([i[0] for i in result])

    yield engine.OnDemandField(
        int,
        "peers_connected",
        "number of connected peers",
        matcher=matching.FloatFilter,
        requires=["d.peers_connected"],
    )
    yield engine.DynamicField(
        set,
        "peers_ip",
        "list of IP addresses for connected peers",
        matcher=matching.TaggedAsFilter,
        formatter=", ".join,
        accessor=lambda o: set(get_peer_data(o, "address")),
        requires=["p.multicall=,p.address="],
    )
    yield engine.DynamicField(
        set,
        "peers_hostname",
        "list of hostnames for connected peers",
        matcher=matching.TaggedAsFilter,
        formatter=", ".join,
        accessor=lambda o: set(
            [socket.gethostbyaddr(i)[0] for i in get_peer_data(o, "address")]
        ),
        requires=["p.multicall=,p.address="],
    )
    yield engine.DynamicField(
        set,
        "peers_client",
        "Client/version for connected peers",
        matcher=matching.TaggedAsFilter,
        formatter=", ".join,
        accessor=lambda o: set(get_peer_data(o, "client_version")),
        requires=["p.multicall=,p.client_version="],
    )
    # Insert any other custom fields here


# Register our custom fields to the proxy
for field in _custom_fields():
    engine.TorrentProxy.add_field(field)

Checking for specific files

from pyrosimple.torrent import engine


def _custom_file_fields():
    import fnmatch
    import re

    from pyrosimple.torrent import engine
    from pyrosimple.util import fmt, matching

    def has_glob(glob):
        regex = re.compile(fnmatch.translate(glob))  # Pre-compile regex for performance

        # Return a function containing the compiled regex to match against
        def _has_glob_accessor(obj):
            return any([f for f in obj._get_files() if regex.match(f.path)])

        return _has_glob_accessor

    yield engine.DynamicField(
        engine.untyped,
        "has_nfo",
        "does download have a .NFO file?",
        matcher=matching.BoolFilter,
        accessor=has_glob("*.nfo"),
        formatter=lambda val: "NFO" if val else "!DTA" if val is None else "----",
    )
    yield engine.DynamicField(
        engine.untyped,
        "has_thumb",
        "does download have a folder.jpg file?",
        matcher=matching.BoolFilter,
        accessor=has_glob("folder.jpg"),
        formatter=lambda val: "THMB" if val else "!DTA" if val is None else "----",
    )


# Register our custom fields to the proxy
for field in _custom_file_fields():
    engine.TorrentProxy.add_field(field)

Partial Downloads

Note that the partial_done value can be a little lower than it actually should be, when chunks shared by different files are not yet complete; but it will eventually reach 100 when all selected chunks are downloaded in full.

# Add file checkers
from pyrosimple.torrent import engine


def _custom_partial_fields():
    from pyrosimple.torrent import engine
    from pyrosimple.util import fmt, matching

    # Fields for partial downloads
    def partial_info(obj, name):
        "Helper for partial download info"
        try:
            return obj._fields[name]
        except KeyError:
            f_attr = [
                "completed_chunks",
                "size_chunks",
                "size_bytes"
                "range_first",
                "range_second",
                "priority"
            ]
            chunk_size = obj.rpc_call("d.chunk_size")
            prev_chunk = -1
            size, completed, chunks = 0, 0, 0
            for f in obj._get_files(f_attr):
                if f.priority:  # selected?
                    shared = int(f.range_first == prev_chunk)
                    size += f.size_bytes
                    completed += f.completed_chunks - shared
                    chunks += f.size_chunks - shared
                    prev_chunk = f.range_second - 1

            obj._fields["partial_size"] = size
            obj._fields["partial_missing"] = (chunks - completed) * chunk_size
            obj._fields["partial_done"] = 100.0 * completed / chunks if chunks else 0.0

            return obj._fields[name]

    yield engine.DynamicField(
        int,
        "partial_size",
        "bytes selected for download",
        matcher=matching.ByteSizeFilter,
        accessor=lambda o: partial_info(o, "partial_size"),
    )
    yield engine.DynamicField(
        int,
        "partial_missing",
        "bytes missing from selected chunks",
        matcher=matching.ByteSizeFilter,
        accessor=lambda o: partial_info(o, "partial_missing"),
    )
    yield engine.DynamicField(
        float,
        "partial_done",
        "percent complete of selected chunks",
        matcher=matching.FloatFilter,
        accessor=lambda o: partial_info(o, "partial_done"),
    )


# Register our custom fields to the proxy
for field in _custom_partial_fields():
    engine.TorrentProxy.add_field(field)

Checking disk space

This custom field also introduces the concept of using custom settings from config.toml. The code below uses diskspace_threshold_mb to decide if there's enough extra space on the disk (in addition to the amount the torrent uses) to consider it valid. If that setting isn't defined, it defaults to 500.

This field is particularly powerful when combined with the QueueManager, to prevent it from accidentally filling up a disk.

from pyrosimple.torrent import engine


def _custom_disk_fields():
    import os

    import pyrosimple

    from pyrosimple.torrent import engine

    def has_room(obj):
        diskspace_threshold_mb = int(
            pyrosimple.config.settings.get("diskspace_threshold_mb", 500)
        )
        path = Path(obj.path)
        if not path.exists():
            path = Path(path.parent)
        if path.exists():
            stats = os.statvfs(path)
            return stats.f_bavail * stats.f_frsize - int(
                diskspace_threshold_mb
            ) * 1024**2 > obj.size * (1.0 - obj.done / 100.0)

    yield engine.DynamicField(
        engine.untyped,
        "has_room",
        "check whether the download will fit on its target device",
        matcher=matching.BoolFilter,
        accessor=has_room,
        formatter=lambda val: "OK" if val else "??" if val is None else "NO",
    )


# Register our custom fields to the proxy
for field in _custom_disk_fields():
    engine.TorrentProxy.add_field(field)

ruTorrent

Some ruTorrent plugins add additional custom fields:

from pyrosimple.torrent import engine

def _custom_rutorrent_fields():
    from pyrosimple.torrent import engine
    yield engine.DynamicField(
        int,
        "ru_seedingtime",
        "total seeding time after completion (ruTorrent variant)",
        matcher=matching.DurationFilter,
        accessor=lambda o: int(o.rpc_call("d.custom",["seedingtime"]) or "0")/1000,
        formatter=engine._fmt_duration,
        requires=["d.custom=seedingtime"]
    )
    yield engine.DynamicField(
        int,
        "ru_addtime",
        "date added (ruTorrent variant)",
        matcher=matching.DurationFilter,
        accessor=lambda o: int(o.rpc_call("d.custom",["addtime"]) or "0")/1000,
        formatter=engine._fmt_duration,
        requires=["d.custom=addtime"]
    )

for field in _custom_rutorrent_fields():
    engine.TorrentProxy.add_field(field)

As a library

The main interface has been designed to be deliberately simple if you wish to connect to rtorrent from within another Python program.

import pyrosimple
engine = pyrosimple.connect()
proxy = engine.open()

With this setup, engine can provide the same kind of high-level views and abstractions seen in rtcontrol.

engine.log("Hello world!") # Prints to console of rtorrent
print([item.done for item in engine.view("incomplete")]) # List the done percentage for torrents in the incomplete view

While proxy allows for low-level direct RPC calls, just like rtxmlrpc.

print(proxy.system.hostname())
print(proxy.d.multicall2('', 'main', 'd.hash='))

If you want to skip the auto-detection of rtorrent's URL, simply pass in your own to connect().

engine = pyrosimple.connect("scgi://localhost:9000")

See the examples directory for some useful python scripts.