rays 9dc4c1ddfa Add feed check scheduling, global with per-feed override
general.schedule and feeds.<id>.schedule take "every 30m", "4h", "1d" or
bare minutes. The legacy interval_mins is still read. An explicit per-feed
schedule wins over the publisher's ttl; without one, ttl still raises the
interval when they ask to be polled less often.

Fixes two bugs found while testing it:

null never cleared a field. serde maps JSON null onto the outer None of an
Option<Option<T>>, so "clear this" was indistinguishable from "not
supplied" and every clear silently no-opped with a 204.

The daemon ignored SIGTERM while working. select! races branches only at
selection time, so a signal queued behind an in-flight download and the
process had to be SIGKILLed. The stop signal now cancels work in progress:
SIGTERM mid-download exits in 1s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 02:25:38 +00:00
2026-09-09 20:34:09 +00:00
2026-09-09 19:34:25 +00:00
2026-09-10 00:55:16 +00:00
2026-09-10 00:55:16 +00:00
2026-09-09 19:34:25 +00:00
2026-09-10 00:55:16 +00:00

ipodderx-rs

A headless podcatcher: scans RSS/Atom feeds, downloads enclosures (HTTP and BitTorrent), files them into per-feed folders, and reaps old episodes to stay under a disk quota. Runs as a one-shot CLI or as a daemon with a Unix-socket JSON event stream for a UI to attach to.

Lineage

This is a modern Rust rewrite of ipodderx-core, the Python 2 engine behind iPodderX (2004-2008, Ray Slakinski & August Trometer), which was open-sourced under the MIT License in 2010.

What carries over: the feed scan and TTL handling, GUID/URL dedupe, per-feed and per-date download folders, keyword filters, the explicit-content filter, torrent enclosures, and "SmartSpace" -- the oldest-first disk quota reaper.

What does not: iTunes and iPhoto export via AppleScript, text-to-speech enclosures, the Windows WMP/COM paths, XML plists and Python pickles for state, the directory.iPodderX.com survey ping, 3DES-encrypted preferences, and the printMSG stdout protocol (replaced by a JSON-lines socket).

Quickstart

cargo install --path .

ipx add https://atp.fm/rss          # names the feed from its own title
ipx list
ipx fetch                           # scan now
ipx daemon                          # or run continuously, honouring each feed's <ttl>

Config lives at ~/.config/ipx/config.toml (mode 0600, since it may hold feed passwords); state at ~/.local/share/ipx/state.db. Override with IPX_CONFIG and IPX_DATA_DIR. Set IPX_LOG=ipx=debug for verbose logging on stderr.

Commands

command what it does
ipx add <url> [--folder X] [--keywords a,b] subscribe; the id comes from the feed title
ipx rm <feed> unsubscribe; downloads and history are kept
ipx list / ipx status subscriptions and their state
ipx fetch [FEED] [--force] scan; --force ignores the TTL
ipx reap [--dry-run] run retention now
ipx import/export <file.opml> move subscriptions in or out
ipx daemon scheduler plus the control socket

Any command with a wire form probes the socket first: if a daemon is running it does the work, and the CLI just renders the events it streams back. --local forces in-process execution.

Configuration

[general]
download_dir   = "~/Podcasts"
socket         = "/run/user/1000/ipx.sock"   # default: $XDG_RUNTIME_DIR/ipx.sock
interval_mins  = 60          # default poll; a feed's own <ttl> wins when longer
organize       = "feed"      # "feed" | "date"
max_total_gb   = 50          # 0 = unlimited
max_age_days   = 30          # 0 = keep forever

[torrent]
enabled        = true
seed_ratio     = 1.0         # stop seeding at this ratio ...
seed_time_mins = 60          # ... or after this long, whichever comes first
port_range     = "6881-6889"
stall_mins     = 30          # give up on a torrent making no progress

[feeds.atp]
url                = "https://atp.fm/rss"
folder             = "Accidental Tech Podcast"  # default: the feed title
keywords           = ["deep dive"]   # OR across keywords, AND within one
allow_explicit     = false
auto_download      = true
max_new_per_check  = 3        # the rest wait for the next scan
username           = "ray"                  # optional HTTP basic auth
password_env       = "IPX_ATP_PASS"         # or a literal `password`

Retention keeps files that are flagged in the database, and deletes read episodes before unread ones, oldest first.

Socket protocol

Newline-delimited JSON over a Unix socket, both directions.

$ printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock
{"ev":"feed_start","feed":"atp"}
{"ev":"progress","feed":"atp","url":"...","file":"ep1.mp3","done":8192,"total":3000000}
{"ev":"download_done","feed":"atp","url":"...","path":"...","bytes":3000000}
{"ev":"feed_done","feed":"atp","new":1,"downloaded":1,"failed":0,"torrents":0}
{"ev":"scan_done","feeds":1}

Commands: fetch (optional feed, force), reap (optional dry_run), status. Events: feed_start, feed_skip, feed_done, feed_error, progress, download_done, download_error, torrent_deferred, reaped, reap_done, scan_done, status, error. scan_done, reap_done and status are terminal -- a client that asked for work stops there.

Progress is throttled to whole percents. The stream is a broadcast, so a client attached to a busy daemon also sees that daemon's other work.

Web UI

[web]
enabled = true
bind    = "0.0.0.0:8080"   # 127.0.0.1:8080 by default
token   = ""               # generated and written back on first run

ipx daemon then serves it in the same process (ipx daemon --web ADDR overrides the bind for one run). On first start it mints a token, saves it to config.toml, and prints the URL to open:

web ui token generated. Open:
  http://0.0.0.0:8080/?token=1f4c…

?token= sets a year-long cookie, so you only paste it once per browser. Everything is behind that token, including /media/... — a cookie rather than a header precisely because an <audio> element cannot send headers.

Browse feeds, read show notes, play episodes in the browser (Range requests are served, so seeking works), download or delete individual files, mark episodes read or flag them to keep, and edit a feed's folder/keywords/explicit/auto-download/limit settings. Config edits are written to config.toml and hot-reloaded — no daemon restart.

Show notes are feed-supplied HTML from an untrusted source; they are sanitized with ammonia server-side before they reach the page.

It is plain HTTP. On a LAN bind, the token and everything else crosses the network in the clear — and a feed URL can itself contain a credential (Patreon's, for one, carries an auth token). Put it behind a reverse proxy with TLS if that matters to you.

Running it as a service

contrib/ has a systemd user unit for the daemon, and a timer plus one-shot service if you would rather run periodic scans with no daemon (in which case there is no socket for a UI to attach to).

License

MIT. See LICENSE.

Description
No description provided
Readme MIT 9.5 MiB
Languages
Rust 49%
JavaScript 27.3%
TypeScript 15.4%
CSS 6.8%
HTML 1.1%
Other 0.4%