Writing a FatRat Extension
This page is for people who write extensions. To install and manage extensions someone else wrote, see Extensions.
A FatRat plugin is a Python module that teaches FatRat about links it does not otherwise understand — typically a file host, where the link the user has is a web page and the real download URL has to be scraped out of it. The plugin does the scraping; the downloading stays FatRat's job, so a plugin's transfers get segmenting, speed limits, resume and the rest without asking.
The shape of one
import fatrat
@fatrat.download_plugin(name="Example.com", url_pattern=r"https?://example\.com/f/\w+")
class Example(fatrat.DownloadPlugin):
async def process_link(self, url):
page = await self.fetch(url)
token = page.search(r'name="token" value="([^"]+)"')
if not token:
raise fatrat.PluginError("no download token on the page")
await self.wait(30)
answer = await self.solve_captcha(page.urljoin("/captcha.png"))
final = await self.fetch(url, data={"token": token, "captcha": answer})
self.start_download(fatrat.DownloadUrl(url=final.json()["link"], referrer=url))
Plugin code runs on FatRat's main thread, so it must never block it: anything slow is awaited. Everything you can await is something FatRat does for you — a page fetch, a countdown, a captcha — and the coroutine resumes when it is done.
What a download plugin can use
await self.fetch(url, data=..., headers=...) |
Fetch through FatRat: this transfer's cookie jar, the configured proxy,
FatRat's user agent. data makes it a POST (a mapping is
form-encoded). Gives a Response with .text,
.content, .headers, .json(),
.search(pattern), .urljoin(url) and
.filename. |
await self.wait(seconds) |
A countdown the user can watch, instead of a transfer that looks stuck. |
await self.solve_captcha(url) |
Hands the image to whichever captcha front-end is configured. Raises
fatrat.CaptchaError if nobody solved it. |
self.start_download(url_or_DownloadUrl) |
Hand the resolved link back and finish. Your cookies come along. |
self.report_file_name(name) |
Name the transfer when the link does not carry a usable name. |
self.vars["key"] |
Values saved with the transfer, still there after a restart. |
fatrat.settings["myplugin/user"] |
FatRat's settings, including whatever your plugin.json declares. |
await fatrat.run_in_thread(fn, ...) |
The one way to leave the main thread, for genuinely CPU-bound work. It must not touch FatRat's API. |
self.log(message), self.fail(message) |
Write to this transfer's log; fail it with a message the user reads. |
Two more methods are worth overriding: final_check(path), which
gets the finished file so you can reject the HTML error page a host served with
a 200, and on_failed(), to drop state when a transfer fails.
Anything you raise out of process_link fails the transfer with that
text, so write those messages for a user rather than for a log.
Packaging
A plugin is a directory with a plugin.json manifest next to the
module:
{
"schema": 1,
"name": "example",
"version": "1.0.0",
"api": "1",
"entry": "example.py",
"license": "GPL-3.0-only",
"config": [
{"type": "text", "key": "example/user", "title": "Username"},
{"type": "password", "key": "example/password", "title": "Password"}
]
}
The config list is your settings page: FatRat renders it in the
desktop settings dialog and in the web interface, and the values land in the
settings keys you name.
Testing a plugin without FatRat
FatRat ships a small development kit, fatrat_devkit, that runs a
plugin under plain python3 (3.11 or newer): no FatRat build, no
restart, no desktop. It stands in for FatRat's side of the API, so your
process_link() really runs — pages are fetched over the
network, a countdown can be skipped, a captcha is asked for on the terminal
— and what the plugin hands back is printed instead of downloaded. It is
installed with FatRat under share/fatrat/data/python; in a source
checkout it is data/python.
export PYTHONPATH=/usr/share/fatrat/data/python # or .../fatrat/data/python in a checkout python3 -m fatrat_devkit check ./my-repo python3 -m fatrat_devkit match ./my-repo 'https://example.com/f/abc123' python3 -m fatrat_devkit download ./my-repo 'https://example.com/f/abc123'
PATH is a plugin directory (with a plugin.json) or a
whole repository (with a fatrat-plugins.json).
check PATH |
Validates the manifests the way FatRat reads them, imports the plugin
and checks what a plugin author tends to get wrong: a missing or
non-async process_link(), a
url_pattern that is not a regular expression, a plugin
that registers nothing, an entry module that is not there, a
config entry with an unknown type or no
options. Exits non-zero when FatRat would refuse to load
it; warnings (a missing license, a settings key outside your
plugin's namespace) do not. |
match PATH URL |
Which plugins would accept the URL, and how well. Use it to get a
url_pattern right before anything is fetched. FatRat
anchors the pattern at both ends; so does this. |
download PATH URL |
Runs a download plugin and prints the link it resolved, the file
name, referrer and anything it kept in self.vars. The
plugin's log goes to stderr as it happens. With
--download DIR the resolved link is also fetched into
DIR and final_check() runs on the
file. |
extract PATH URLsearch PATH QUERYaccount PATHupload PATH FILE |
The same for the other plugin kinds. |
The commands that run a plugin take these options:
-s KEY=VALUE, --settings FILE.json |
Settings the plugin reads through fatrat.settings, such as
-s example/user=me. true and
false become booleans. |
--captcha TEXT, --captcha-prompt |
Answer every captcha with TEXT, or save each image to a
temporary file and ask on the terminal. With neither, a captcha fails
with CaptchaError. |
--real-wait |
Countdowns are skipped by default (the plugin still sees them finish, and they are listed in the log); this waits them out. |
-p NAME |
Choose a plugin or class when the path holds several of that kind. |
--json, -q, --timeout SECONDS |
Machine-readable output, a silent log, and a limit on how long a run may take. |
It exits 0 when the plugin succeeded, 1 when it failed (with the message the user would see; an unexpected exception also prints its traceback) and 2 when it could not be loaded.
Unit tests
For tests that must not touch the network, give the same machinery canned
responses. Import fatrat_devkit before
fatrat — the plugin base classes are chosen when
fatrat is first imported — then drive your plugin class with a
Host:
from fatrat_devkit import Host, MockTransport # first
from example import Example # then your plugin
def test_resolves_the_link():
host = Host(transport=MockTransport({
"https://example.com/f/abc": '<input name="token" value="T">',
("POST", "https://example.com/f/abc"): '{"link": "https://cdn.example.com/abc.bin"}',
}), settings={"example/user": "me"})
result = host.run_download(Example, "https://example.com/f/abc")
assert result.ok, result.error
assert result.download.url == "https://cdn.example.com/abc.bin"
assert result.requests[1].form["token"] == "T"
A canned response is a body, a dict with status,
body and headers, a callable that receives the
Request, an exception to raise, or a list of those answered in
turn. An HTTP status of 400 or more fails the fetch with
NetworkError, as it does in FatRat. The returned
Result carries ok, error,
download, file_name, vars,
waits, requests and log; the other plugin
kinds have run_extract, run_search,
run_account_status and run_upload. Without
pytest it works the same under python3 -m unittest.
The devkit is an approximation, not FatRat: pages are fetched with Python's
urllib rather than FatRat's network stack (so proxy settings, the
exact user agent and redirect handling can differ), FatRat's captcha dialogs are
replaced by the terminal, and url_pattern is matched with Python's
re where FatRat uses Qt's regular expressions. Before you publish,
install the plugin into FatRat once and try it for real.
Dependencies
Most plugins need nothing but the standard library and fatrat.
When yours genuinely needs a library, list it in the manifest:
"dependencies": ["feedparser"]
FatRat will not install those packages as a side effect of installing your
plugin: it names them and asks first, because they come from PyPI rather than
from the repository the user chose. They are then fetched with pip into a
_vendor directory inside your plugin, so two plugins may depend on
different versions of the same package, and removing a plugin removes its
dependencies with it. Keep the list short: every entry is one more thing the
user has to trust.
Publishing
There is no central plugin index. You publish a repository — a
git repository, or any directory — with a
fatrat-plugins.json at its root listing what it offers, and users
add it by URL under Settings → Extensions. FatRat records the exact
commit it installed, so updates are something a user chooses.
The FatRat source ships a complete example under
examples/plugin-repo/: a repository manifest and a commented
dummy plugin that exercises fetching, a countdown, a captcha and
final_check. Adding that directory as a repository is also the way
to develop a plugin, since a local repository is used where it lies.
A plugin runs with your full user privileges — the same trust you extend
to anything else you install from a git repository. FatRat shows the source and
the revision before it installs anything, and a plugin can be switched off
again under Settings → Extensions, or all of them at once with
fatrat --no-python.