Skip to content

Workflows

Kheopsian edited this page Oct 13, 2026 · 3 revisions

🇬🇧 English · 🇫🇷 Français

Workflows

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.

What a workflow is

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"]
Loading

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.

Triggers

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.

Conditions

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, 45s or plain seconds (the editor has a unit selector).
  • Sizes: KB/MB/GB/TB are powers of 1000, KiB/MiB/GiB/TiB powers of 1024: 500GB ≠ 500GiB.
  • Bools: true, yes or 1 mean true; anything else means false.
  • Text: comparisons ignore case, except matches, which is a case-sensitive regular expression (Rust regex syntax; 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_age comparison, is not included. A torrent the hardlink index has not measured yet fails every link_count, external_links and freeable_bytes condition; data_missing reads false for it, so data_missing is false matches it. Inside a none of these (NOT) group the result flips: NOT completed_age is newer than 1d or NOT external_links is more than 0 matches 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.

Actions

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 failed with webhook: <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.

Preview, dry-run, run now

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.

Activity

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), Action rss_add, Torrent the article's title, Outcome applied, skipped (Detail already in this client) or failed with the reason (a transient failure ends with ; tried again at the next pass).
  • For a workflow, Outcome is applied or failed. For failed, Detail gives the error (for example webhook: HTTP 500 after 3 tries; a 4xx reads webhook: 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).
  • applied for move files to a folder means "job queued"; follow the move on the Jobs tab.

Examples

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).

1. Stop seeding after 14 days on one tracker

  1. + New workflow. Name: Stop after 14 days on tracker.example.org. Runs: on a timer. Runs every: 3600.
  2. When (all of these (AND)): tracker · is · tracker.example.org. Click + Condition: time spent seeding · is older than · 14 days.
  3. Then: stop the torrent.
  4. 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…).

2. Tag torrents with no hardlink outside Hydranos

Finds payloads your media library no longer uses (e.g. after Sonarr deleted its hardlink).

  1. + New workflow. Name: Tag noHL. Runs: on a timer. Runs every: 21600.
  2. When: hardlinks from outside Hydranos · is · 0. + Condition: state · is · seeding.
  3. Then: add tags · noHL.
  4. 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.

3. Call a webhook when a download completes

  1. + New workflow. Name: Notify on completion. Runs: when a download completes.
  2. When: click Remove on the starting condition row to act on every download, or keep one condition such as category · is · tv.
  3. Then: call a webhook · https://hooks.example.org/hydranos.
  4. Preview (it runs on the torrents whose progress is 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.

4. Add public trackers to new public torrents

  1. + New workflow. Name: Public trackers on add. Runs: when a torrent is added.
  2. When: private · is · false.
  3. 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.
  4. 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.

Known bugs and limits in 4.4

The editor cannot reorder workflows (position is never sent).

Known bug in 4.4: progress looks 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 as progress is 100 misses them and progress < 100 catches them — to mean complete, test completed_age (set for a seed-mode add and kept across a restart); state is seeding leaves out stopped torrents.

API

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/workflows

To change an existing workflow, GET the list, edit the object and POST it back whole, with its id (last_run is ignored).

Hydranos 4.4 · 🇬🇧

Start

Install

Use

Integrate

Network & machines

Operate

Reference


Hydranos 4.4 · 🇫🇷

Démarrer

Installer

Utiliser

Intégrer

Réseau & machines

Exploiter

Référence


Clone this wiki locally