iPX

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 variableWhat it does
IPX_DATABASE_URLA postgres://user:password@host:5432/database address, to keep everything in Postgres instead of SQLite
PUID, PGIDThe user and group that own the files ipx writes
IPX_LOG_FORMATjson logs one JSON object a line, for Loki and the like
OTEL_EXPORTER_OTLP_ENDPOINTSends traces of requests, checks and downloads to an OpenTelemetry collector; set WEB_TRACING=on on ipx-web too
IPX_UPSTREAMOn 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.

RequestWhat it does
GET /api/meWho you are, and the version
GET /api/feedsYour podcasts, each with its unread and downloaded counts
POST /api/feedsSubscribe: {"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}/entriesA podcast's episodes, 50 at a time: offset, limit, filter (unread, downloaded or flagged), q to search, sort and dir
GET /api/entriesThe same, across all your podcasts
POST /api/entries/{feed}/{guid}/flagsMark an episode read or kept: {"read": true}, {"flagged": true}
POST /api/entries/{feed}/{guid}/positionWhere you are in it: {"secs": 1234}
POST /api/feeds/{id}/read-allMark a podcast's episodes read; POST /api/read-all marks them all
POST /api/enclosures/{id}/downloadDownload an episode's file now
GET /media/{id}The file itself, with seeking
POST /api/fetchCheck for new episodes now
GET /api/opml, POST /api/opmlYour subscriptions as OPML, out or in
GET /api/eventsA 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.

CommandWhat it does
ipx listEvery podcast and its state
ipx statusHow 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|rmAccounts

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

RequestTokenWhat it does
GET /v1/meroThe token's name and access
GET /v1/feedsroEvery podcast, A to Z: id, url, title, image, category, subscribers
GET /v1/popular?limit=10roThe most followed, up to 100
GET /v1/feeds/{id}roOne podcast, with its description and latest twenty episodes
POST /v1/snapshotrwShare: [{"url": "…", "subscribers": 3}], replacing what this token shared before
DELETE /v1/feeds/{id}rwaRemove a podcast and keep it out
POST /v1/feeds/{id}/refreshrwaCheck a podcast now
GET /v1/blocked, DELETE /v1/blocked/{id}rwaWhat is kept out; let one back in
GET /v1/instances, DELETE /v1/instances/{name}rwaThe 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.