Workflows - Kheopsian/Hydranos GitHub Wiki
Applies to Hydranos 4.4.
For anyone who wants Hydranos to act on torrents without a script: stop seeding after a while, tag torrents, call a webhook on completion, add trackers. You will learn what each trigger, condition and action does, how to test a rule, and what is still missing in 4.4. Terms: Concepts and Glossary.
A workflow has three parts:
- a trigger (the Runs field): on a timer, when a download completes or when a torrent is added;
- a condition tree (When): field comparisons grouped with AND / OR / NOT;
- a list of actions (Then), carried out in order on every torrent that matches.
Workflows live in the store (hydra.db), not in default.toml. A workflow saved without "enabled": true is off. They act on the torrents of this instance's engines (race, hoard, extras), never on rows merged from a declared node.
flowchart LR
T1["Timer (every interval_secs, min 60)"] --> ALL["Every torrent copy in every local engine"]
T2["A download completes"] --> EV["Event queue (store table workflow_events)"]
T3["A torrent is added"] --> EV
EV --> ONE["That one torrent"]
ALL --> WHEN["When: condition tree"]
ONE --> WHEN
WHEN -->|no match| NOP["Nothing, nothing logged"]
WHEN -->|match| DONE{"Every non-webhook action<br/>already as asked?"}
DONE -->|"yes, and no webhook to send"| SKIP["Skipped, nothing logged"]
DONE -->|"yes, event trigger with a webhook"| THEN
DONE -->|no| CAP{"Timer: At most reached<br/>this pass?"}
CAP -->|yes| STOP["Rest of the pass left for next time"]
CAP -->|no| THEN["Then: actions still to do, in order,<br/>webhooks last"]
THEN --> ACT["Activity log: applied or failed"]
THEN -.->|"move files to a folder"| JOB["Jobs tab: move_data job"]
Tab Workflows, button + New workflow: Name, Runs, for timers Runs every (seconds, minimum 60, default 900) and At most (torrents per pass, default 500), then When, Then, and Preview / Save / Cancel. Translated in every language: Name, Save, Cancel, the list's Actions header; in French also Runs, the trigger choices, Preview, Every (on completion, when added), none of these (NOT), the hardlink checkbox of move files to a folder, the action notes and a group's Remove. List buttons, a row's Remove, all of these (AND) / any of these (OR), and field, operator and action labels stay English.
| Runs | Wire value | When it fires | What it evaluates |
|---|---|---|---|
| on a timer |
schedule (default) |
When now − last run ≥ interval_secs (60 s floor). The timer first ticks about three minutes after start, then once a minute. Due workflows run in turn. |
Every torrent copy in every local engine. At most cap torrents with work to do per pass. |
| when a download completes | completed |
Once per download, when its last piece is verified. A torrent added with its data already on disk, or rechecked whole, never fires it. | That one torrent. |
| when a torrent is added | added |
Once per torrent, after the engine holds it. Fires for adds from the + Add Torrent tab, the REST API, the qBittorrent shim (Sonarr, Radarr, autobrr), magnets once resolved, watched folders, the MCP server, RSS rules (and the RSS tab's Download) and a fetch from a declared node. The import wizard and data moves do not fire it. | That one torrent. |
Details that matter:
- A timer workflow converges: a torrent already "as asked" (stopped, tagged…) is skipped and not logged, until it drifts back (start by hand a torrent a workflow stops, and the next pass stops it again).
- The last run is recorded before the pass, so a slow pass is never started twice. A newly enabled timer workflow that never ran is due at the next tick.
- Events are written to the store first, so a restart loses none. An event whose torrent is not loaded yet waits up to one hour, then is dropped. Event workflows have no interval and no cap.
- On an event trigger, an empty When is allowed and means "every download that completes" or "every torrent added". To get one in the editor, click Remove on the condition row that a new workflow starts with. On a timer, an empty When is refused (it would match the whole catalogue).
- Hardlink fields (
link_count,external_links,freeable_bytes,data_missing) are refused on event triggers: the index has not measured a torrent that just arrived.
When starts with one root group. Each group is all of these (AND), any of these (OR) or none of these (NOT). Inside it, + Condition adds a row (field, operator, value) and + Group adds a nested group. Remove deletes a row or a group (the root group cannot be removed). Empty groups are dropped when you save.
none of these (NOT) matches when not one of its rows holds; it is saved as {"kind":"not","of":{"kind":"any","of":[…]}}. A NOT written through the API opens as a none of these (NOT) group (whatever it wraps becomes that group's one child when it is not an OR), so saving it from the editor keeps its meaning.
The editor builds its lists from GET /api/workflows/fields. These are all the fields:
| UI label | Field | Kind | What it reads |
|---|---|---|---|
| torrent name / info hash |
name / info_hash
|
text | Metainfo name / hex hash. |
| category | category |
text | This copy's category. |
| tags | tags |
tags | The torrent's tags. |
| state | state |
text | The state the list shows: seeding, downloading, stopped, queued, checking_files, error. |
| engine | engine |
text | Engine id (race, hoard, vpn1…). |
| save path | save_path |
text | Save path stored for this copy. |
| tracker | tracker_host |
text | Host (no scheme, no port) of the first tracker in the live list only. |
| has a tracker error | tracker_error |
bool | The last announce returned an error. |
| tracker error message | tracker_error_msg |
text | Its text. |
| has a torrent error | torrent_error |
bool | The engine marks the torrent in error. |
| stopped by hand | user_paused |
bool | Your stop intent as stored, not the engine's momentary state. |
| has several files | multi_file |
bool | More than one file. |
| private | private |
bool | Private flag of the metainfo. Shown as private, because it has no label. |
| progress | progress |
percent | Share of the payload held: verified pieces, (size − bytes left) ÷ size, 0-100. A torrent reads 100 while it seeds; a torrent whose pieces are not known yet (still checking) reads 0 — and so does a stopped torrent with no piece map (see the known bug below). Use completed_age to mean complete. |
| ratio | ratio |
number | The list's ratio: uploaded ÷ downloaded, or ÷ data held when under 1 % was downloaded (a cross-seed). Kept by every move (Set location…, a category move, move files to a folder, a graduation, Move to engine ›); a copy made by Duplicate to engine › starts at 0 (Jobs). |
| size / uploaded / downloaded |
total_size / total_uploaded / total_downloaded
|
size | Engine counters. |
| upload rate / download rate |
upload_rate / download_rate
|
size (bytes/s) | Current rates. |
| connected peers | num_peers |
number | Peers connected now. |
| seeds the tracker reports | num_seeds |
number | Seeders reported by the tracker (same as swarm_seeds), not connected peers. |
| seeds in swarm / leechers in swarm |
swarm_seeds / swarm_leechers
|
number | Seeders / leechers reported by the tracker. |
| time spent seeding | seeding_time |
duration | The engine's accumulated seeding time since completion: grows only while seeding and not stopped. |
| time since added | added_age |
duration | Now − add time. |
| time since completed | completed_age |
duration | Now − completion time. A torrent that never completed has none. |
| free space where it is stored | free_space |
size | Free space on the save path's filesystem. On a timer, measured only when a condition uses it. |
| hardlinks to its files | link_count |
number | Every hardlink, Hydranos's own included. Two cross-seeds of each other both read 2. |
| hardlinks from outside Hydranos | external_links |
number | 0 means only Hydranos points at these files. |
| space deleting would really free | freeable_bytes |
size | Only files nothing else points at. |
| its files are missing from disk | data_missing |
bool | True when the hardlink index could find not one of the torrent's files. A torrent with only some files gone reads false (the Hardlink index line counts those apart, as with some files missing). Not measured yet = false. |
Operators (the editor shows the label, the API takes the id):
| Kind | Operators (label → id) |
|---|---|
| text | is eq, is not ne, contains contains, does not contain not_contains, starts with starts_with, ends with ends_with, matches regex matches
|
| number, percent, size | is eq, is not ne, is more than gt, is at least ge, is less than lt, is at most le
|
| duration | is older than gt, is at least ge, is newer than lt, is at most le, is eq, is not ne
|
| bool | is eq, is not ne (value true / false) |
| tags | has tag has_tag, does not have tag not_has_tag
|
Values:
-
Durations:
2d,36h,90m,45sor plain seconds (the editor has a unit selector). -
Sizes:
KB/MB/GB/TBare powers of 1000,KiB/MiB/GiB/TiBpowers of 1024:500GB≠500GiB. -
Bools:
true,yesor1mean true; anything else means false. -
Text: comparisons ignore case, except
matches, which is a case-sensitive regular expression (Rustregexsyntax; prefix(?i)to ignore case). Tag comparisons are exact, case included. - For category, tags, tracker and engine, the editor offers a drop-down of known values.
-
"Never happened" matches nothing. A torrent that never completed fails every
completed_agecomparison,is notincluded. A torrent the hardlink index has not measured yet fails everylink_count,external_linksandfreeable_bytescondition;data_missingreads false for it, sodata_missing is falsematches it. Inside a none of these (NOT) group the result flips: NOTcompleted_age is newer than 1dor NOTexternal_links is more than 0matches every torrent never completed or not measured yet. Write the positive form (external_links is 0) instead of a NOT.
Hardlink index. Hardlink fields come from a background index (starts ~3 min after boot, re-measures entries older than 24 h; HYDRANOS_LINK_SCAN_THREADS, default 16). The line Hardlink index: X of Y torrents measured under the list shows its progress. A pass re-measures its candidates before acting, and a delete decided on hardlink facts is refused if a new link appeared since.
| Label in Then | Wire type and fields |
What it does |
|---|---|---|
| stop the torrent | {"type":"pause"} |
Stores the stop intent for this copy and stops it in its engine, as the Stop button does. No disk effect. |
| start the torrent | {"type":"resume"} |
The reverse. |
| set category (files stay) | {"type":"set_category","to":"tv"} |
Changes this copy's category label only: no file move, no engine change (to move data: move files to a folder or Change category + move files ›). |
| move files to a folder | {"type":"set_location","to":"/data/archive","allow_breaking_hardlinks":false} |
Queues a move_data job (Jobs tab), same plan and refusals as Set location…. "Already there"/"already queued" count as success. The path must be absolute, plain names only. On Windows a drive (D:\archive) or UNC path is accepted; \archive is not absolute there. A hardlinked file needing a cross-disk copy fails the torrent, unless allow_breaking_hardlinks is true (checkbox copy hardlinked files across disks anyway (breaks the link, uses the space twice)). |
| add trackers | {"type":"add_trackers","urls":["udp://tracker.example.org:6969/announce"],"list_url":""} |
Adds each new URL as its own tier. list_url fetches a list (one URL per line, # comments skipped, ≤ 500 URLs, cached 24 h). Never applied to a private torrent. Every added tracker learns this instance's IP. See Trackers and Announces. |
| add tags / remove tags | {"type":"add_tags","tags":["noHL"]} |
Rewrites the torrent's tags on every copy. In the editor, separate tags with commas. |
| delete the torrent | {"type":"delete","with_files":false} |
Removes the matched copy like a manual removal. also delete the files on disk sets with_files. Files another loaded torrent reads at the same path are kept (this torrent's copy in another engine included, when it points at the same folder); a copy with its own payload elsewhere protects nothing (see Jobs, Bulk Actions and Moving Data). Must be the only action.
|
| call a webhook | {"type":"webhook","url":"https://hooks.example.org/hydranos"} |
POSTs JSON (see below) through the daemon's own way out ([daemon] egress, see VPN and Egress). The URL must be http:// or https://. |
How actions run:
- A torrent's actions run in order. The first failure stops the rest for it, and it counts as
failed. - Webhooks always run after the other actions, whatever their position in Then.
- On a timer, a webhook is sent only in a pass where another action of the same workflow changes the torrent. A timer workflow whose only actions are webhooks is refused (it would call the URL on every pass). On an event trigger, the webhook is always sent, and a webhook alone is allowed.
- If the webhook fails after a tag was written, the tag stays, and the next pass (torrent "as asked") does not retry the webhook.
- Each try has a 10 s timeout; a failure is retried after 2 s and 5 s, a 4xx is not. The URL never appears in errors. A dead endpoint slows the pass.
- When the kill switch leaves the daemon no way out, the webhook is not sent at all (no retry): the torrent counts as
failedwithwebhook: <reason>. - There is no action to run a command, recheck, reannounce, set limits, or move a torrent to another engine.
Webhook body:
{"event": "completed", "workflow": "Notify on completion", "at": 1790000000,
"content": "Some.Torrent finished downloading (4.37 GiB)", "text": "…same…", "message": "…same…",
"torrent": {"info_hash": "0123…", "name": "Some.Torrent", "category": "tv", "tags": [],
"engine": "race", "save_path": "/data/race/tv", "state": "seeding", "tracker": "tracker.example.org",
"size": 4692251443, "progress": 100, "ratio": 0.0, "uploaded": 0, "downloaded": 4692251443,
"seeding_time": 0}}event is matched (timer), completed or added. The summary is repeated under content (Discord), text (Slack, Mattermost) and message (Gotify), so these take the URL directly. Unknown numbers are null.
| Control | Where | Route | What it does |
|---|---|---|---|
| Preview | editor |
POST /api/workflows/preview (body = the workflow, saved or not) |
Validates like a save, runs the same evaluation as a real pass, changes no torrent. Timer: N matched, M would change, K already as asked, (capped), would free X GB for a delete, first 10 names. when a download completes: on every torrent whose progress is under 100, stopped ones included — which also takes in stopped complete torrents with no piece map (see progress), so the count can overstate. when a torrent is added: on the torrents added in the last 24 hours. |
| Dry-run | list row (timer only) | POST /api/workflows/:id/run?dry=1 |
Same evaluation, on the saved version. The answer appears as raw JSON in a browser alert: dry_run, matched, would_apply, skipped, capped, rechecked, no_longer_true. A dry-run that would apply nothing (no match, or every match already as asked) writes an Activity row with outcome dry_run_no_match. |
| Run now | list row (timer only) | POST /api/workflows/:id/run |
A real pass, enabled or not. It records the last run and returns matched, applied, skipped, failed, capped, rechecked, no_longer_true. |
Dry-run and Run now are hidden for event workflows (the API answers 400): use Preview. "Would free" adds up each matched torrent's full size: it overstates for hardlinked or shared files. For a hardlink rule, Preview and Dry-run also re-measure the candidates and write the fresh values to the hardlink index. That, plus the dry_run_no_match row, is all they write.
The Activity table under the list shows the workflow log: When, Workflow, Torrent, Action, Outcome, Detail. It loads the latest 500 rows. Rows older than 7 days are pruned every minute.
- One row per torrent acted on. Action joins the actions done with
+(pause,resume,category,add_tags,remove_tags,delete,webhook,set_location,add_trackers), e.g.add_tags+webhook. - The same table holds what RSS rules did: Workflow
RSS: <rule name>(RSS: (manual)for the RSS tab's Download), Actionrss_add, Torrent the article's title, Outcomeapplied,skipped(Detailalready in this client) orfailedwith the reason (a transient failure ends with; tried again at the next pass). - For a workflow, Outcome is
appliedorfailed. Forfailed, Detail gives the error (for examplewebhook: HTTP 500 after 3 tries; a 4xx readswebhook: HTTP 404). A private torrent is never a failure: add trackers skips it as "as asked". - Torrents already "as asked" and passes that matched nothing are not logged (except a Dry-run with nothing to apply:
dry_run_no_match). -
appliedfor move files to a folder means "job queued"; follow the move on the Jobs tab.
Each example gives the editor steps and the same workflow as API JSON. The API takes "enabled": true; the editor saves a new workflow switched off, so tick Enabled in the list after the first save (saving an edit keeps the workflow's state).
-
+ New workflow. Name:
Stop after 14 days on tracker.example.org. Runs: on a timer. Runs every:3600. -
When (all of these (AND)): tracker · is ·
tracker.example.org. Click + Condition: time spent seeding · is older than ·14days. - Then: stop the torrent.
- Preview, then Save, then tick Enabled.
{
"name": "Stop after 14 days on tracker.example.org",
"enabled": true,
"trigger": "schedule",
"interval_secs": 3600,
"cap": 500,
"when": {"kind": "all", "of": [
{"kind": "cond", "field": "tracker_host", "op": "eq", "value": "tracker.example.org"},
{"kind": "cond", "field": "seeding_time", "op": "gt", "value": "14d"}
]},
"then": [{"type": "pause"}]
}tracker reads only the first tracker. Hydranos also has built-in share limits, per engine and per torrent (Queue, Share and Speed Limits); a workflow like this one is for rules they cannot express (per tracker, per category, tags…).
Finds payloads your media library no longer uses (e.g. after Sonarr deleted its hardlink).
-
+ New workflow. Name:
Tag noHL. Runs: on a timer. Runs every:21600. -
When: hardlinks from outside Hydranos · is ·
0. + Condition: state · is ·seeding. -
Then: add tags ·
noHL. - Preview. Check the Hardlink index line under the list: torrents not measured yet never match. Then Save and tick Enabled.
{
"name": "Tag noHL",
"enabled": true,
"trigger": "schedule",
"interval_secs": 21600,
"cap": 500,
"when": {"kind": "all", "of": [
{"kind": "cond", "field": "external_links", "op": "eq", "value": "0"},
{"kind": "cond", "field": "state", "op": "eq", "value": "seeding"}
]},
"then": [{"type": "add_tags", "tags": ["noHL"]}]
}The tag stays when the condition stops holding; a second workflow (external_links is more than 0, has tag noHL → remove tags) clears it. Review before deleting.
-
+ New workflow. Name:
Notify on completion. Runs: when a download completes. -
When: click Remove on the starting condition row to act on every download, or keep one condition such as category · is ·
tv. -
Then: call a webhook ·
https://hooks.example.org/hydranos. -
Preview (it runs on the torrents whose
progressis under 100, see Preview), Save, tick Enabled.
{
"name": "Notify on completion",
"enabled": true,
"trigger": "completed",
"when": {"kind": "all", "of": []},
"then": [{"type": "webhook", "url": "https://hooks.example.org/hydranos"}]
}The body is the JSON shown under Actions. Torrents added with their data already on disk never fire this trigger.
-
+ New workflow. Name:
Public trackers on add. Runs: when a torrent is added. -
When: private · is ·
false. -
Then: add trackers. Enter one announce URL per line, e.g.
udp://tracker.example.org:6969/announce, or put the URL of a list in the second box. - Preview (it shows what would have happened to the last day's arrivals), Save, tick Enabled.
{
"name": "Public trackers on add",
"enabled": true,
"trigger": "added",
"when": {"kind": "all", "of": [
{"kind": "cond", "field": "private", "op": "eq", "value": "false"}
]},
"then": [{"type": "add_trackers",
"urls": ["udp://tracker.example.org:6969/announce"],
"list_url": ""}]
}The action skips private torrents even without the condition. Always give a port for udp:// URLs. A failed list fetch falls back to the last good copy; with none, the action fails.
The editor cannot reorder workflows (position is never sent).
Known bug in 4.4:
progresslooks like it reads 100 for any complete torrent — it reads 0 for a stopped torrent with no piece map: a seed-mode add or cross-seed added stopped, or a complete torrent reloaded after a restart and stopped (only a seeding torrent is known to hold everything without one). A rule such asprogress is 100misses them andprogress < 100catches them — to mean complete, testcompleted_age(set for a seed-mode add and kept across a restart);state is seedingleaves out stopped torrents.
All routes need the API key (X-Api-Key header or apikey query parameter). Full conventions are on REST API.
| Method and route | Body / answer |
|---|---|
GET /api/workflows |
List: id, name, enabled, position, trigger, interval_secs, last_run, when, then, cap. |
POST /api/workflows |
Create or update (same id = update). Body: name (required), enabled (default false), trigger (default schedule), interval_secs (default 900, raised to 60), cap (default 500; 0 becomes 500), when, then, optional id, position. The workflow is compiled first: an invalid one gets 400 with the reason. |
DELETE /api/workflows/:id |
Delete; 404 if unknown. |
POST /api/workflows/preview |
Body = a workflow. Answer as in Preview above, plus freed_bytes and sample (≤ 200 torrents); event triggers add trigger and downloading / recent. |
POST /api/workflows/:id/run[?dry=1] |
Run now or dry-run (timer workflows only; event workflows get 400). |
GET /api/workflows/fields |
The field catalogue the editor uses (name, label, kind, operators, hint…). |
GET /api/workflows/activity |
Latest 500 Activity rows: at, workflow_name, info_hash, torrent_name, action, outcome, detail. |
GET /api/workflows/links |
Hardlink index status (catalogue, measured, data_missing, sweep…). |
Condition tree: {"kind":"all","of":[…]}, {"kind":"any","of":[…]}, {"kind":"not","of":{…}}, {"kind":"cond","field":"…","op":"…","value":"…"}.
Save and Preview also refuse an unknown field, a value that does not parse and a bad regex, besides the shapes listed under Triggers and Actions.
# Preview a draft
curl -s -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d @stop-after-14d.json http://localhost:8199/api/workflows/preview
# Save it (enabled if the file says "enabled": true)
curl -s -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d @stop-after-14d.json http://localhost:8199/api/workflowsTo change an existing workflow, GET the list, edit the object and POST it back whole, with its id (last_run is ignored).