putiorr is a put.io bridge for Sonarr, Radarr, Lidarr, Readarr, and other
apps that can use a Transmission download client.
It pretends to be Transmission on the *arr side, sends magnet/torrent grabs to put.io, downloads completed put.io files to a local folder, and exposes enough queue state for completed-download handling to import media into your normal library.
The common target setup is:
put.io -> putiorr -> SSD staging folder -> *arr import -> HDD/NAS library
This keeps slow library storage out of the active download path while still letting Radarr, Sonarr, Lidarr, and similar apps move or hardlink/copy media exactly the way they normally do.
Version 1 focuses on the core bridge workflow and reliability over feature breadth:
- durable SQLite state
- Transmission-compatible RPC endpoints
- put.io OAuth from the web UI
- multiple *arr profiles from one putiorr instance
- per-profile put.io folders, download folders, and RPC paths
- WebSocket dashboard updates
- file-level local download progress and speed
- safe handling of
delete-local-data - automatic cleanup of completed
prowlarr-profile transfers (removed from the list and put.io, downloaded files kept on disk)
Implemented Transmission RPC methods:
session-gettorrent-addtorrent-gettorrent-remove
This is not a full Transmission replacement. It implements the pieces needed by the *arr completed-download workflow.
The repository includes a local stack in putiorr-compose.
It starts putiorr, Radarr, Sonarr, Lidarr, and Prowlarr with ports in the
17010-17020 range.
cd putiorr-compose
cp .env.example .env
docker compose up -d --buildOpen:
- putiorr: http://127.0.0.1:17010
- Radarr: http://127.0.0.1:17011
- Sonarr: http://127.0.0.1:17012
- Prowlarr: http://127.0.0.1:17013
- Lidarr: http://127.0.0.1:17014
The compose stack is development-oriented. It builds the Dockerfile dev
target, bind-mounts src/, runs pnpm run dev, and enables browser reload for
the putiorr UI.
See putiorr-compose/README.md for the exact
Radarr, Sonarr, Lidarr, and Prowlarr settings.
Release images are published to GitHub Container Registry:
ghcr.io/ptheofan/putiorr
Publishing happens when a GitHub Release is published. Use a semver tag with a
leading v; the workflow builds the Dockerfile production target for
linux/amd64 and linux/arm64.
Release tags produce image tags like:
ghcr.io/ptheofan/putiorr:vMAJOR.MINOR.PATCH
ghcr.io/ptheofan/putiorr:MAJOR.MINOR.PATCH
ghcr.io/ptheofan/putiorr:MAJOR.MINOR
ghcr.io/ptheofan/putiorr:latest
Prereleases do not receive the latest tag.
package.json is the source of truth for the app version. Before publishing a
release, run the guarded release script with the intended semver tag. The script
updates release metadata, and the Release Gate GitHub Actions workflow can
then validate main with the matching tag.
The release gate uses the semver package to compare versions. It checks that:
package.jsoncontains a valid semver version.- the requested release tag matches
package.jsonexactly, with a leadingv. package.jsonis not older than the latest GitHub Release.- the release workflow still runs the release gate before publishing images.
The release workflow also runs the release gate after a GitHub Release is published, then runs the same lint/test gates as pull requests before publishing an image.
For local or test runs where the GitHub API should be pinned, set
RELEASE_GATE_LATEST_RELEASE_TAG:
RELEASE_GATE_LATEST_RELEASE_TAG=vMAJOR.MINOR.PATCH pnpm release:gate
pnpm release:gate
pnpm lint
pnpm testUse the guarded gh wrapper when an agent or maintainer should create a release
from the local checkout:
pnpm release:create -- vMAJOR.MINOR.PATCH
pnpm release:create -- vMAJOR.MINOR.PATCH --yes
pnpm release:create -- vMAJOR.MINOR.PATCH --yes --publishThe script accepts a release tag, updates package.json to match it with
semver, requires a clean and up-to-date main checkout, and runs:
pnpm release:gate
pnpm lint
pnpm testIf package.json changed, the script stops after the checks so the metadata can
be committed and merged. Once main already contains the target version, rerun
the same command. Without --yes, the script only prints the
gh release create command it would run. With --yes, it creates a draft
release by default. Add --publish only when the release should be published
immediately and the container publishing workflow should start.
Most real installs should share one staging path between putiorr and each *arr app, then mount final media libraries only where the importer needs them.
Example host paths:
/volume1/docker/putiorr-config # putiorr SQLite state
/volumeSSD/putiorr # fast SSD download/staging folder
/volumeNAS/media/movies # final Radarr library on NAS/HDD
/volumeNAS/media/series # final Sonarr library on NAS/HDD
/volumeNAS/media/music # final Lidarr library on NAS/HDD
Example container paths:
putiorr: /putiorr
radarr: /putiorr and /movies
sonarr: /putiorr and /series
lidarr: /putiorr and /music
The important rule is that the download path reported by putiorr must be a path
Radarr/Sonarr/Lidarr can also see. If Radarr receives /putiorr/radarr, the
Radarr container must have that same path mounted.
After publishing a release, use the GHCR image:
services:
putiorr:
image: ghcr.io/ptheofan/putiorr:latest
container_name: putiorr
restart: unless-stopped
environment:
TZ: Europe/Athens
PUTIORR_LISTEN_HOST: 0.0.0.0
PUTIORR_LISTEN_PORT: 9091
PUTIORR_STATE_PATH: /data/putiorr-config/putiorr.sqlite
PUTIORR_TARGET_DIR: /putiorr
PUTIORR_PUBLIC_URL: https://putiorr.example.com
PUTIORR_WORKERS: "4"
PUTIORR_CLEANUP_REMOTE_FILES: "true"
ports:
- "17010:9091"
volumes:
- /volume1/docker/putiorr-config:/data/putiorr-config
- /volumeSSD/putiorr:/putiorrRadarr, Sonarr, and Lidarr need the same staging mount:
services:
radarr:
image: lscr.io/linuxserver/radarr:latest
volumes:
- /volume1/docker/radarr:/config
- /volumeSSD/putiorr:/putiorr
- /volumeNAS/media/movies:/movies
sonarr:
image: lscr.io/linuxserver/sonarr:latest
volumes:
- /volume1/docker/sonarr:/config
- /volumeSSD/putiorr:/putiorr
- /volumeNAS/media/series:/series
lidarr:
image: lscr.io/linuxserver/lidarr:latest
volumes:
- /volume1/docker/lidarr:/config
- /volumeSSD/putiorr:/putiorr
- /volumeNAS/media/music:/musicOpen the putiorr web UI and use Connect with put.io.
The UI opens put.io's OAuth authorization page. There are two supported redirect modes.
Hosted relay mode is the default. The Docker image bakes in putiorr's public put.io app id and GitHub Pages relay URL, so normal installs do not need OAuth environment variables.
Register this callback URL in the put.io app:
https://ptheofan.github.io/putiorr/putio-oauth-relay.html
These values are baked into the image and should not be added to normal
docker-compose.yml environment blocks. Forks or custom builds can override
them with Docker build arguments when they intentionally use a different put.io
OAuth app or relay.
The Put.io connection dialog also has an Advanced section with editable OAuth relay URL and App Id fields. Saving there stores an instance-level override in the SQLite state database. Reset clears that override and restores the baked defaults.
Only the put.io Client ID is public and safe to bake in. Do not put the client secret or the app owner's OAuth token in GitHub Actions, GitHub Pages, or any static file. The relay is static JavaScript and only passes the returned token back to the user's putiorr instance.
If you do not use the hosted relay, put.io must redirect directly to the putiorr instance:
https://your-putiorr-host/api/oauth/callback
Set PUTIORR_PUBLIC_URL to the externally reachable putiorr base URL and add
the callback URL above to your put.io OAuth app. After authorization, putiorr
stores the OAuth token in its SQLite database. The token survives container
restarts as long as PUTIORR_STATE_PATH is on a persistent volume.
Manual token paste is also available in the UI.
Self-hosted OAuth redirect requires your own put.io OAuth app id and an empty
PUTIORR_PUTIO_OAUTH_RELAY_URL. The old default 3270 belongs to put.io's
Swagger test API and will reject your self-hosted callback URL.
Each profile maps one *arr app to:
- a put.io destination folder
- a local download folder
- a Transmission RPC endpoint path
Existing *arr clients may keep Transmission's default /transmission/rpc
endpoint. On that shared endpoint, putiorr links torrent-add to the profile
whose unique category appears in download-dir and verifies the matching label
when the client sends one. The app User-Agent scopes later listing and removal
operations to that persisted profile association. A profile-specific endpoint,
when configured, remains authoritative and rejects a conflicting category.
Recommended profiles:
| App | put.io folder | Download folder | RPC endpoint |
|---|---|---|---|
| Radarr | putiorr |
/putiorr |
/radarr/transmission/rpc |
| Sonarr | putiorr |
/putiorr |
/sonarr/transmission/rpc |
| Lidarr | putiorr |
/putiorr |
/lidarr/transmission/rpc |
| Readarr | putiorr |
/putiorr |
/readarr/transmission/rpc |
No URL Base change is required for one profile per *arr app with unique categories. URL Base is an advanced setting that can optionally select the profile-specific endpoint; this is required when multiple profiles represent the same app identity, such as two Sonarr instances.
The *arr download client category creates the final staging subfolder. For example:
Radarr category: radarr -> /putiorr/radarr
Sonarr category: sonarr -> /putiorr/sonarr
Lidarr category: lidarr -> /putiorr/lidarr
That gives each app a separate staging folder while still using one shared SSD mount. Profiles may also share the same put.io folder. If two profiles request the same put.io transfer, putiorr keeps one remote transfer and independent profile associations/local staging copies; removing one does not remove the other.
Add a Transmission download client.
If Radarr is in the same compose network as putiorr:
Name: putiorr
Host: putiorr
Port: 9091
Use SSL: off
Username: blank unless configured
Password: blank unless configured
Category: radarr
Directory: /putiorr
URL Base: /radarr/transmission
If Radarr is outside the compose network, use the host/IP and published port instead:
Host: your-nas-hostname-or-ip
Port: 17010
URL Base: /radarr/transmission
Set Radarr's movie root folder to /movies.
With completed-download handling enabled, Radarr imports from /putiorr/radarr
to /movies and then removes imported files from staging according to Radarr's
normal settings.
Add a Transmission download client.
If Sonarr is in the same compose network as putiorr:
Name: putiorr
Host: putiorr
Port: 9091
Use SSL: off
Username: blank unless configured
Password: blank unless configured
Category: sonarr
Directory: /putiorr
URL Base: /sonarr/transmission
If Sonarr is outside the compose network:
Host: your-nas-hostname-or-ip
Port: 17010
URL Base: /sonarr/transmission
Set Sonarr's series root folder to /series.
With completed-download handling enabled, Sonarr imports from /putiorr/sonarr
to /series and then removes imported files from staging according to Sonarr's
normal settings.
Add a Transmission download client.
If Lidarr is in the same compose network as putiorr:
Name: putiorr
Host: putiorr
Port: 9091
Use SSL: off
Username: blank unless configured
Password: blank unless configured
Category: lidarr
Directory: /putiorr
URL Base: /lidarr/transmission
If Lidarr is outside the compose network:
Host: your-nas-hostname-or-ip
Port: 17010
URL Base: /lidarr/transmission
Set Lidarr's music root folder to /music.
With completed-download handling enabled, Lidarr imports from /putiorr/lidarr
to /music and then removes imported files from staging according to Lidarr's
normal settings.
Prowlarr does not usually need a putiorr profile. It connects to Radarr/Sonarr/Lidarr, syncs indexers, and those apps send accepted grabs to putiorr through their Transmission download-client settings.
In the bundled compose stack, configure Prowlarr apps with Docker-internal URLs:
Radarr URL: http://radarr:7878
Sonarr URL: http://sonarr:8989
Lidarr URL: http://lidarr:8686
Use the API keys from each app's settings page.
putiorr reports combined progress as two phases:
0-50%: put.io remote transfer
50-100%: local download from put.io to the staging folder
The web dashboard shows both put.io progress and local file progress. For multi-file releases, expand the files list to see per-file status, bytes, and local speed.
Some *arr apps only read torrent-level Transmission fields, so they may display one shared percentage for all queue items from a multi-file torrent. The putiorr dashboard is the source of truth for per-file local download progress.
Local downloads are written as .part files and resumed with HTTP Range
requests. If putiorr or the container restarts while a file is downloading, the
next worker continues from the existing partial file.
Slow-speed reset is optional. Set a nonzero threshold to enable it. After the startup grace window, if a file stays below the threshold for the configured duration, putiorr closes that file download and resumes it from the partial file. Files smaller than the configured minimum size are ignored by the reset policy.
| Variable | Default | Description |
|---|---|---|
PUTIORR_LISTEN_HOST |
0.0.0.0 |
HTTP/RPC bind host |
PUTIORR_LISTEN_PORT |
9091 |
HTTP/RPC bind port |
PUTIORR_TARGET_DIR |
./downloads |
Default local download root |
PUTIORR_STATE_PATH |
./data/putiorr.sqlite |
SQLite state database |
PUTIORR_PUTIO_TOKEN |
unset | Optional initial put.io token; UI-stored token wins after OAuth |
PUTIORR_PUTIO_APP_ID |
9354 |
Public putorr put.io OAuth app id |
PUTIORR_PUBLIC_URL |
request host | Public putiorr base URL used to build the self-hosted put.io OAuth callback |
PUTIORR_PUTIO_OAUTH_RELAY_URL |
https://ptheofan.github.io/putiorr/putio-oauth-relay.html |
Hosted relay URL registered as the put.io OAuth callback; set empty for self-hosted redirect |
PUTIORR_PUTIO_FOLDER |
putiorr |
Default put.io folder for the default profile |
PUTIORR_DEFAULT_PROFILE_NAME |
Custom |
Name for the fallback custom profile |
PUTIORR_DEFAULT_PROFILE_TYPE |
custom |
Type for the default profile |
PUTIORR_DEFAULT_RPC_PATH |
/transmission/rpc |
Default Transmission RPC path |
PUTIORR_PROFILES_JSON |
[] |
Optional seed profiles as JSON |
PUTIORR_WORKERS |
4 |
Concurrent local file downloads |
PUTIORR_POLL_INTERVAL_MS |
30000 |
Backend put.io polling interval, minimum 5000. The web UI receives updates over WebSocket. |
PUTIORR_SLOW_SPEED_THRESHOLD_BYTES_PER_SECOND |
0 |
Local file speed threshold. 0 disables slow-speed reset. |
PUTIORR_SLOW_SPEED_DURATION_SECONDS |
120 |
Seconds below the threshold before resetting the file connection |
PUTIORR_SLOW_SPEED_GRACE_SECONDS |
30 |
Startup grace window before slow-speed checks begin |
PUTIORR_SLOW_SPEED_MIN_SIZE_BYTES |
104857600 |
Do not slow-reset files smaller than this size |
PUTIORR_CLEANUP_REMOTE_FILES |
true |
Delete put.io files/transfers after local completion |
PUTIORR_RPC_USERNAME |
unset | Optional HTTP basic auth username |
PUTIORR_RPC_PASSWORD |
unset | Optional HTTP basic auth password |
PUTIORR_REFRESH_ON_RPC |
false |
Refresh put.io state during RPC torrent-get calls |
PUTIORR_LIVE_RELOAD |
enabled outside production | Dev-only browser reload hook |
Install dependencies:
corepack enable
pnpm installRun locally:
cp .env.example .env
pnpm run devOpen http://127.0.0.1:9091.
Run tests:
pnpm lint
pnpm testBuild and run the development compose stack:
cd putiorr-compose
cp .env.example .env
docker compose up -d --buildputiorr is licensed under the GNU Affero General Public License v3.0. See
LICENSE.