- Add feed lists what other accounts subscribe to, most subscribers
first, and subscribes you by id (GET /api/popular, POST
/api/popular/{id}). Rows never carry a URL. Feeds from an OPML and
anything that looks private (a login, credentials in the URL, a key
such as auth= or token=) are never listed, and the subscribe route
checks the id against the same list.
- CHANGELOG.md follows Keep a Changelog 1.1.0: 0.1.0 (2026-09-09, the
CLI), 0.2.0 (2026-09-10, the web UI), 0.3.0 (2026-09-11, accounts and
sharing). The long-form entries moved unchanged to docs/history.md.
- Cargo.toml is 0.3.0. CLAUDE.md says how to add an entry and cut a
release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
6.9 KiB
Working on ipodderx-rs
Notes for whoever picks this up next. Read docs/architecture.md for how the thing is built; this file is about working on it without repeating mistakes that have already been made here.
Where things are
Production is the iPodderX container on Tower (192.168.1.130), the ipodderx service of the
Arcane project content: /mnt/fast/arcane/projects/content/compose.yaml. That file is what runs;
docker-compose.yml in this repo is a copy, and editing it changes nothing in production.
| Host | In the container | |
|---|---|---|
| Image | 192.168.1.130:5000/ipodderx:latest |
|
| Config | /mnt/fast/appdata/ipodderx/config.toml |
/config/config.toml |
| Database | /mnt/user/ipodderx/state.db |
/data/state.db |
| Downloads | /mnt/user/ipodderx/downloads |
/downloads |
| Web UI | 192.168.1.130:8099, also ipodderx.sdf1.net via a Cloudflare tunnel |
0.0.0.0:8099 |
Deploying a change is: build and push the image, then pull it and recreate the container.
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx
docker logs --tail 20 iPodderX
Name the service. A bare up -d recreates every container in content, beets and immich
included. Run pull before up, because up reuses whatever latest the host already has.
The healthcheck runs ipx status against the control socket, so (healthy) in docker ps means
the worker is alive, not just the web port. The container restarts on its own after a reboot.
Before the container, ipx ran by hand in code-server, with its files in /config/.config/ipx/ and
/config/.local/share/ipx/. Those are still there and the container does not read them. If you run
a daemon by hand for testing, stop it with pkill -x ipx, never pkill -f ipx. -f matches
the shell running the command and kills the session (exit 144). This has happened more than once.
Before you touch the page
web/index.html is include_str!d into the binary, so every page change needs a rebuild before
it is visible. It is one file: markup, CSS and script.
After any edit to it:
node tests/page-smoke.js
That loads the script against a stub DOM and checks every selector it wires at load actually
exists. It exists because a patch once anchored on a deleted function, String.replace silently
matched nothing, and the whole UI died with a ReferenceError while every server-side test passed.
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
(sed -n 'START,ENDp'), match it verbatim, and assert the replacement happened rather than hoping.
Tests
cargo test # ~51 tests: parsing, filters, retention, schedules, SQL, per-user state
node tests/page-smoke.js
npx playwright test # 16 browser tests against a real daemon on fixture feeds
Things about the browser suite that have cost time:
- It starts its own daemon and database under
/tmp/ipx-ui-test, wiped once per run. Playwright re-imports the config in every worker, soprepare()guards onTEST_WORKER_INDEX— without that guard a worker deleted the database out from under the running daemon, which then kept serving from the unlinked inode while everything else saw an empty file. - Tests share that daemon and run in order. A test that opens an item marks it read and changes what later tests see. Write assertions that do not depend on what ran before, or normalise the state first.
- Fixture feeds must not share an enclosure URL, because
enclosures.urlis globally unique and whichever feed is scanned first claims it. webServerstarts beforeglobalSetup, which is why the fixture config is written at config-load time instead.
Non-trivial logic leaves one runnable check behind. Pure functions (merge_policy, pick,
matches_keywords, parse_interval) are the easiest place to put it.
Things that are true and easy to get wrong
enclosures.urlis globally UNIQUE. It is the dedupe key and the reason one file serves every subscriber. Two feeds publishing the same URL means only the first one scanned shows it.entries.read,entries.flaggedandentries.positionare dead columns. Read state lives inentry_stateper user. Two bugs have already come from queries still reading the old ones (retention, and the entry pruner) — grep before adding a third.- The catalogue is config.toml; the subscriptions are in the database. A feed exists once;
subscriptions(user_id, feed_id)says who wants it and with what settings. OPML children are derived and never written to config. - One fetch serves everyone, so scan policy is a union of subscribers' wants (
merge_policy). Anyone wanting an item is enough to fetch it. - The UI hiding a control is not enforcement. Admin-only actions check
user.is_adminin the handler and return403. - A
tokio::select!only races its branches at the point of selection. A long download has to watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason. - Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons fighting over the database, with the stale one still holding the port.
/api/settingsanswering200does not mean the worker is alive — it is a different task. Probe the control socket (ipx status) to check that.
House style
Comments explain why, not what. If a line looks odd, the comment says what went wrong without it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and addressed to the person using it.
Every change gets one line under ## [Unreleased] in CHANGELOG.md, in its
Keep a Changelog group: Added, Changed, Deprecated,
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
say, such as what was wrong before or what it cost to find out, write it up at the top of
docs/history.md, dated. That record has been more useful than the git log more
than once.
Cutting a release: rename [Unreleased] to ## [X.Y.Z] - YYYY-MM-DD and open a new empty
[Unreleased] above it, bump version in Cargo.toml, tag the commit vX.Y.Z, and update the
compare links at the bottom of the changelog.
Deliberate simplifications get a ponytail: comment naming the ceiling and the upgrade path, e.g.
// ponytail: global connection mutex, move to a pool if feed count makes it contend.
Known gaps
- Cloudflare's
Cf-Access-Jwt-Assertionis not verified — ipx trusts the hop plustrusted_proxies(documented in docs/sso.md). - A feed's
<description>subtitle is dropped whenevercontent:encodedexists, which loses Substack-style subtitles.