# 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](https://git.sdf1.net/rays/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 ```sh 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 ``` 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 [--folder X] [--keywords a,b]` | subscribe; the id comes from the feed title | | `ipx rm ` | 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 ` | 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 ```toml [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 wins when longer organize = "feed" # "feed" | "date" max_total_gb = 50 # 0 = unlimited max_age_days = 30 # 0 = keep forever max_new_per_check = 3 # per feed, per scan. 0 = unlimited (pulls whole back catalogues) [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. ```sh $ 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. ## OPML Two different things, both supported: **Importing and exporting** a file copies subscriptions in or out once — `ipx import subs.opml`, `ipx export subs.opml`, or the OPML button in the UI. **Subscribing to an OPML URL** is a live subscription, as iPodderX had. Add the OPML's URL like any other feed; every scan re-reads it and keeps your feed list in step. An OPML is recognised by its content, so a URL without a `.opml` extension still works. The feeds inside it are **not written to `config.toml`** — the OPML is the source of truth, so they are re-derived each scan and held in the database. Your config keeps only what you chose. They show as a collapsible folder in the sidebar, download into one nested folder, and newly listed ones are scanned in the same run rather than waiting for the next interval. They inherit the subscription's settings; change anything on one and it gets its own config entry from then on. When a feed drops out of the OPML upstream: | it has downloads | what happens | |---|---| | no | unsubscribed and removed from the config | | yes | kept, flagged in the UI as no longer listed | A downloaded file is never left behind with nothing explaining where it came from. ## Web UI ```toml [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 `