From 4cf568b2631f61d65a2ced4dfe62a27aec757ae8 Mon Sep 17 00:00:00 2001 From: rays Date: Tue, 13 Oct 2026 12:21:49 +0000 Subject: [PATCH] Home page: a Docs page, for installing ipx and ipx-web and using their APIs and the directory's (#222) Co-Authored-By: Claude Opus 5.5 --- site/build.sh | 2 +- site/docs.html | 218 ++++++++++++++++++++++++++++++++++++++++++++++++ site/index.html | 1 + site/site.css | 23 +++++ 4 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 site/docs.html diff --git a/site/build.sh b/site/build.sh index 7a774ac..187053a 100755 --- a/site/build.sh +++ b/site/build.sh @@ -5,7 +5,7 @@ set -e here=$(dirname "$0") rm -rf "$here/dist" mkdir -p "$here/dist/fonts" -cp "$here/index.html" "$here/site.css" "$here/wave.svg" "$here/gitea.svg" "$here/_headers" "$here/dist/" +cp "$here/index.html" "$here/docs.html" "$here/site.css" "$here/wave.svg" "$here/gitea.svg" "$here/_headers" "$here/dist/" # The reader's own typeface, the same file ipx-web serves. cp "$here/../web/InterVariable.woff2" "$here/dist/fonts/inter.woff2" cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$here/dist/" diff --git a/site/docs.html b/site/docs.html new file mode 100644 index 0000000..c988d31 --- /dev/null +++ b/site/docs.html @@ -0,0 +1,218 @@ + + + + + +iPX docs + + + + + + + +
+ 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.

+
+
+ +
+

iPX is free and open source under the MIT licence. The full documentation is in the repository.

+
+ + diff --git a/site/index.html b/site/index.html index fed1760..5165ee3 100644 --- a/site/index.html +++ b/site/index.html @@ -14,6 +14,7 @@
iPX diff --git a/site/site.css b/site/site.css index 8cd4d0d..103ecec 100644 --- a/site/site.css +++ b/site/site.css @@ -90,6 +90,29 @@ h2{margin:0 0 1.5rem;color:var(--navy);font-weight:800;font-size:clamp(1.75rem,4 .also dd{margin:.45rem 0 0;max-width:34rem} +/* Docs: one column of prose at a reading width; code and tables scroll inside themselves at + phone width rather than the page scrolling sideways. */ +.doc{max-width:50rem} +.doc .title{font-size:clamp(3.5rem,12vw,6rem);line-height:.9;letter-spacing:-.05em;margin:2rem 0 0} +.lede{margin:1.5rem 0 0;color:var(--muted);font-size:1.125rem} +.toc{margin:2rem 0 0;padding:1rem 1.25rem;background:var(--sky);border-radius:.9rem} +.toc ol{margin:0;padding-left:1.25rem} +.toc li{padding-block:.15rem} +.doc section{padding-block:3.5rem 0} +.doc h2{scroll-margin-top:1rem} +.doc h3{margin:2.25rem 0 .5rem;color:var(--navy);font-weight:750;font-size:1.25rem;line-height:1.25} +.doc p{margin:.75rem 0} +.doc code{font:500 .9em/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} +.doc :not(pre)>code{padding:.08em .3em;background:var(--sky);border-radius:.3em;overflow-wrap:anywhere} +.doc pre{margin:1rem 0;padding:1rem 1.15rem;background:var(--navy);color:#e6f1fa;border-radius:.8rem;overflow-x:auto} +.doc pre code{font-size:.85rem} +.scroll{margin:1rem 0;overflow-x:auto} +.doc table{width:100%;border-collapse:collapse;font-size:.95rem} +.doc th,.doc td{padding:.55rem .75rem .55rem 0;border-bottom:1px solid var(--rule);text-align:left;vertical-align:top} +.doc th{color:var(--navy);font-weight:700} +.doc td:first-child code{white-space:nowrap} +.bar nav a[aria-current]{text-decoration:underline;text-decoration-color:var(--needle);text-decoration-thickness:2px} + footer{margin-top:5rem;padding-block:1.75rem 3rem;border-top:1px solid var(--rule);color:var(--muted);font-size:.95rem} footer p{margin:0}