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:
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)
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.
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
.
If you want to skip the auto-detection of rtorrent's URL, simply pass
in your own to connect()
.
See the
examples
directory for some useful python scripts.