Docs
iPX is two containers: ipx, which checks your podcasts, downloads them and answers the API, and ipx-web, which serves the page and passes the API on to ipx. The directory is a third, which you only need to run if you want a directory of your own.
Install ipx and ipx-web
You need Docker with Compose. There are no published images yet, so you build both from the source:
git clone https://git.sdf1.net/rays/ipx.git
cd ipx
docker build -t ipx .
docker build -f Dockerfile.web -t ipx-web .
The repository has a sample docker-compose.yml that runs both, with the database in SQLite and everything ipx keeps in three folders beside the file:
services:
ipx:
image: ipx
container_name: ipx
restart: unless-stopped
environment:
PUID: "99" # who owns the files it writes
PGID: "100"
TZ: "UTC"
ports:
- "6881:6881/tcp" # BitTorrent
- "6881:6881/udp"
volumes:
- ./config:/config # config.toml, written on first start
- ./data:/data # the database
- ./downloads:/downloads
ipx-web:
image: ipx-web
container_name: ipx-web
restart: unless-stopped
environment:
IPX_UPSTREAM: "ipx:8099"
ports:
- "8099:8080" # the page
depends_on:
- ipx
docker compose up -d
ipx has no port of its own: everything reaches it through ipx-web, on port 8099. Open http://<your server>:8099 and sign in as admin with the password ipodderx, then change it:
echo -n 'a good password' | docker exec -i ipx ipx user passwd admin
The password is read from what you pipe in, so it stays out of your shell's history.
Adding people
An admin adds people in Admin, Accounts, or from the command line:
echo -n 'their password' | docker exec -i ipx ipx user add sam
Each person starts with no podcasts and subscribes to their own. Two people subscribing to the same show cost nothing extra: it is fetched once and each file is kept once.
Settings
How often podcasts are checked, how much disk to use, how many new episodes to download and the rest are in Admin on the page, and take effect straight away. /config/config.toml holds only what ipx needs before it can read its database: where downloads go, BitTorrent, and who gets in.
| Environment variable | What it does |
|---|---|
IPX_DATABASE_URL | A postgres://user:password@host:5432/database address, to keep everything in Postgres instead of SQLite |
PUID, PGID | The user and group that own the files ipx writes |
IPX_LOG_FORMAT | json logs one JSON object a line, for Loki and the like |
OTEL_EXPORTER_OTLP_ENDPOINT | Sends traces of requests, checks and downloads to an OpenTelemetry collector; set WEB_TRACING=on on ipx-web too |
IPX_UPSTREAM | On ipx-web: where ipx is, ipx:8099 by default |
Reaching it from outside
The page is plain HTTP. If anyone will reach it from outside your network, put HTTPS in front of it: a reverse proxy you already run, or a Cloudflare tunnel. People can then sign in through Cloudflare Access or Authentik instead of a password; single sign-on says how.
Updating
git pull
docker build -t ipx .
docker build -f Dockerfile.web -t ipx-web .
docker compose up -d
Your podcasts, accounts and downloads are in the three folders and are kept.
Use ipx from a script
Anything the page does, a script can do through the same API.
A token
In Settings, under API tokens, give a token a name and press Make. It is shown once, so copy it then. It acts as you: it sees your podcasts and changes them, and it is an admin only if you are. Revoke it in the same place.
curl -H "Authorization: Bearer ipx_…" http://<your server>:8099/api/feeds
Behind Cloudflare Access, a token alone does not get past Access. Give the script an Access service token as well, and send its CF-Access-Client-Id and CF-Access-Client-Secret beside the token.
The API
Requests and answers are JSON. A feed's id is the one in its list's address, such as 99-invisible; an episode is named by its feed and its guid, the guid URL-encoded.
| Request | What it does |
|---|---|
GET /api/me | Who you are, and the version |
GET /api/feeds | Your podcasts, each with its unread and downloaded counts |
POST /api/feeds | Subscribe: {"url": "…"}, a feed's address or a site that links one; optional folder, keywords |
DELETE /api/feeds/{id} | Unsubscribe. The files stay for anyone else who subscribes |
PATCH /api/feeds/{id} | Your settings for a podcast |
GET /api/feeds/{id}/entries | A podcast's episodes, 50 at a time: offset, limit, filter (unread, downloaded or flagged), q to search, sort and dir |
GET /api/entries | The same, across all your podcasts |
POST /api/entries/{feed}/{guid}/flags | Mark an episode read or kept: {"read": true}, {"flagged": true} |
POST /api/entries/{feed}/{guid}/position | Where you are in it: {"secs": 1234} |
POST /api/feeds/{id}/read-all | Mark a podcast's episodes read; POST /api/read-all marks them all |
POST /api/enclosures/{id}/download | Download an episode's file now |
GET /media/{id} | The file itself, with seeking |
POST /api/fetch | Check for new episodes now |
GET /api/opml, POST /api/opml | Your subscriptions as OPML, out or in |
GET /api/events | A live stream (server-sent events) of checks and downloads as they happen |
For example, the newest unread episodes across everything you follow:
curl -H "Authorization: Bearer ipx_…" \
"http://<your server>:8099/api/entries?filter=unread&limit=10"
The command line
The same ipx program that runs the server is its command line. Run it in the container with docker exec ipx ipx …; when the server is running, it does the work and the command shows what happened.
| Command | What it does |
|---|---|
ipx list | Every podcast and its state |
ipx status | How many podcasts, how many files waiting and downloaded |
ipx fetch [id] [--force] | Check everything that is due, or one podcast; --force checks it even if it is not due |
ipx add <url>, ipx rm <id> | Add a podcast, or take it out |
ipx import <file>, ipx export <file> | OPML in or out |
ipx reap [--dry-run] | Clear old files now, to stay within the space you set |
ipx user add|list|passwd|rm | Accounts |
A program on the same machine can also talk to the running server over its control socket, one JSON command a line, and read back one event a line:
printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock
{"ev":"feed_start","feed":"atp"}
{"ev":"download_done","feed":"atp","enclosure":42,"path":"…","bytes":3000000}
{"ev":"feed_done","feed":"atp","new":1,"downloaded":1,"failed":0,"torrents":0}
{"ev":"scan_done","feeds":1}
The commands are fetch (optional feed, force), download (enclosure), reap (optional dry_run) and status.
Use the directory
The directory gathers the public podcasts that iPX servers' listeners follow, and how many follow each. It never holds a file and never learns who listens.
Browsing it
Anyone can browse directory.ipxreader.app without signing in: the most followed, the categories, everything A to Z, and a page for each podcast with its latest episodes and its address, to subscribe to in any podcast app.
Connecting your iPX
In iPX, an admin opens Admin, Directory and enters the directory's address, https://directory.ipxreader.app, and the token its operator gave your server. Everyone on your server then has the Directory at the top of their list, and subscribes from it in one click.
Turn on sharing to add your server's podcasts to it. iPX sends only the podcasts' addresses and how many people on your server follow each, never who. It leaves out anything that looks private: a podcast someone marked Private, one with a login or a key in its address, and paid feeds such as Patreon's.
Its API
Everything under /v1 takes a token, sent as Authorization: Bearer <token>. A token reads (ro), reads and shares (rw), or also removes and blocks podcasts (rwa).
| Request | Token | What it does |
|---|---|---|
GET /v1/me | ro | The token's name and access |
GET /v1/feeds | ro | Every podcast, A to Z: id, url, title, image, category, subscribers |
GET /v1/popular?limit=10 | ro | The most followed, up to 100 |
GET /v1/feeds/{id} | ro | One podcast, with its description and latest twenty episodes |
POST /v1/snapshot | rw | Share: [{"url": "…", "subscribers": 3}], replacing what this token shared before |
DELETE /v1/feeds/{id} | rwa | Remove a podcast and keep it out |
POST /v1/feeds/{id}/refresh | rwa | Check a podcast now |
GET /v1/blocked, DELETE /v1/blocked/{id} | rwa | What is kept out; let one back in |
GET /v1/instances, DELETE /v1/instances/{name} | rwa | The servers sharing; drop one's share |
A podcast's id is the first 16 hex digits of the SHA-256 of its address, with the scheme and host in lower case and no default port or fragment, so every server arrives at the same one. Descriptions are the publisher's own HTML: sanitize them before you show them.
curl -H "Authorization: Bearer <token>" "https://directory.ipxreader.app/v1/popular?limit=5"
Running your own
The directory is in the same repository. Build it, give it a database (Postgres or SQLite) and a config file, and make a token for each server that will use it:
docker build -f Dockerfile.directory -t ipx-directory .
services:
ipx-directory:
image: ipx-directory
container_name: ipx-directory
restart: unless-stopped
environment:
DIRECTORY_DATABASE_URL: "postgres://ipx_directory:password@db-host:5432/ipx_directory"
ports:
- "8100:8100"
volumes:
- ./directory:/config:ro # directory.toml
# directory/directory.toml
bind = "0.0.0.0:8100"
database_url_env = "DIRECTORY_DATABASE_URL"
docker exec ipx-directory ipx-directory token new my-server --access rw
That prints the token, to give to the server, and the lines to add to directory.toml, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every six hours.