Files
ipodderx-rs/PROGRESS.md
rays 665d5b8ecb Keep OPML feeds out of config, cap downloads, log daemon work
Writing 82 derived feeds into a hand-edited config.toml made it
unreadable. The OPML is the source of truth, so its feeds are re-derived
each scan and held in the database, inheriting the subscription's
settings; editing one promotes it to a real entry. A migration moves
existing children out -- 611 lines to 38 -- keeping all entries and files.

max_new_per_check defaulted to unlimited, so subscribing to an OPML of 82
feeds pulled whole back catalogues. It now defaults to 3 via [general],
capping every feed that does not set its own, and the pending queue orders
by publish date so a cap of 3 means the three newest.

Scans and downloads travelled as socket events only, so the log view
showed no daemon activity. They are mirrored into tracing, with routine
skips at debug -- at 82 feeds those alone would flush the buffer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 15:15:54 +00:00

807 lines
47 KiB
Markdown

# Progress
Running record of what has actually landed. Newest entry first.
The full design and step list live in the plan file at
`/config/.claude/plans/i-want-to-create-playful-quiche.md`.
## Build order
- [x] **1. Repo skeleton** — git init (`main`), `cargo init --name ipx`, deps pinned, LICENSE,
README, this file.
- [x] **2. `config.rs` + `db.rs`** — TOML config structs + SQLite schema.
- [x] **3. `feed.rs`** — conditional GET, RSS-then-Atom parse, persist entries.
- [x] **4. `download.rs`** — downloads, filters, dedupe.
- [x] **5. `retention.rs`** — oldest-first quota + age reaper.
- [x] **6. `ipc.rs` + daemon** — UDS JSON-lines server, TTL scheduler, CLI-proxies-to-daemon.
- [x] **7. `torrent.rs`** — librqbit, seed to ratio/time, stall abort. (swarm download unverified —
see the step 7 entry)
- [x] **8. OPML + polish** — import/export, add/rm/status, tracing setup, systemd units, README.
### Phase 2 — web front end
Decided with Ray: axum serving plain HTML/JS (no WASM toolchain), running **inside the daemon**
process so it reads SQLite and the event bus directly, LAN-bindable with a shared token.
- [x] **9. Config hot-reload + web skeleton.** `Ctx.cfg` becomes `RwLock<Arc<Config>>` so the UI can
edit feeds without a daemon restart. `[web]` config section (enabled/bind/token, token
auto-generated and saved on first run). axum server started by `ipx daemon`, token checked by
middleware, `?token=` sets a cookie so `<audio>` requests authenticate too.
*Done when:* `ipx daemon` serves a page on the configured bind, and a wrong token gets 401.
- [x] **10. Browsing.** `/api/feeds`, `/api/feeds/:id/entries`, entry detail. Descriptions are
untrusted feed HTML — sanitized with `ammonia` before they reach the page.
*Done when:* the Glass Cannon feed's 131 entries browse and read correctly.
- [x] **11. Media actions.** Range-request audio streaming (`tower-http` ServeFile) so seeking
works, download-on-demand for a pending enclosure, delete a file, mark read/flagged.
*Done when:* an episode plays and seeks in a browser, and delete reaps the row.
- [x] **12. Feed configuration.** Add/remove feeds and edit folder, keywords, allow_explicit,
auto_download, max_new_per_check from the UI, written back to config.toml and hot-reloaded.
*Done when:* flipping allow_explicit in the UI takes effect on the next scan with no restart.
- [x] **13. Live progress + polish.** SSE from the existing broadcast bus so downloads show live.
README section, screenshot-free usage notes.
*Done when:* starting a fetch from the UI shows progress advancing without a reload.
Note: `read`/`flagged` finally get a writer here. Retention orders by them (see the step 5 entry),
and until now nothing set them.
## Smoke tests
1. `ipx add <feed>` + `ipx fetch` → file in `download_dir/<Show>/`, row in `enclosures`.
2. `ipx fetch` again → no re-download, feed skipped for TTL.
3. `ipx daemon &` + `nc -U $XDG_RUNTIME_DIR/ipx.sock`, send `{"cmd":"fetch"}` → JSON events;
a concurrent `ipx fetch` proxies to the daemon instead of downloading in parallel.
4. Delete a downloaded file by hand, `ipx fetch` → NOT re-downloaded.
5. Torrent enclosure → downloads, moves, stops seeding at the configured ratio/time.
6. `ipx reap --dry-run` under quota pressure → oldest-first hit list; real run flips rows to
`reaped`.
---
## 2026-09-10 — OPML feeds out of the config, a real cap, and daemon output in the log
Three reports in quick succession, all fair.
**"Config is a mess."** It was: 611 lines, 85 feeds, 82 of them machine-generated, drowning the three
Ray actually chose. Writing derived data into a hand-edited file was the wrong call. OPML children
now live in the `feeds` table (`managed = 1`, `group_id`), are re-derived every scan, and inherit the
subscription's settings — so there is nothing to store but a URL and a parent. Editing one promotes
it to a real config entry, so `config.toml` only ever holds decisions. A one-time migration moves
existing children out: **611 lines -> 38**, 85 feeds -> 3, with all 3487 entries and 216 files intact.
**"Never download more than 3."** `max_new_per_check` defaulted to *unlimited*, so subscribing to an
OPML of 82 feeds pulled every back catalogue it could reach — 216 files, 22 GB before it was caught.
There is now `[general] max_new_per_check = 3`, used whenever a feed does not set its own, so all 83
uncapped feeds were capped without touching a line of their config. `pending()` also orders by publish
date now: a cap of 3 meant "the three recorded first", not the three newest.
**"No daemon output in the log."** Scans and downloads travel as events to the socket, not through
tracing, so the log view showed only startup and HTTP lines. `Emitter::emit` mirrors them now — and
levelling matters at this scale: at 82 feeds, one INFO line per feed per tick for "not due yet"
flushed the 2000-line buffer of anything useful in minutes, so routine skips and progress are DEBUG,
real activity is INFO, failures WARN.
A splice while refactoring cut `reject` and `fetch_one` out of main.rs, and recovering them from git
over-copied three more. Both caught by the compiler, restored, and verified byte-identical against
`git show HEAD:src/main.rs` rather than eyeballed.
---
## 2026-09-10 — Subscribing to an OPML, not just importing one
Asked whether this version could do what iPodderX did: subscribe to an OPML and get a folder of the
feeds inside it. It could not — `ipx import` was one-shot from a file and nothing ever re-read it.
The original checked for a URL ending in `.opml` and re-fetched it on every scan
(`iPXClass.py:34-40`).
Now: a feed whose body sniffs as OPML is a subscription list. Sniffing the body rather than the URL
extension also catches an OPML served from a `.rss` or extension-less URL, which the original missed.
Every scan re-reads it; listed feeds become real config entries with `group = "<opml-id>"` inheriting
the parent's settings, land in one nested folder, and are scanned in the same run rather than waiting
an interval.
Removal policy, Ray's rule: **a downloaded file is never orphaned.** Gone from the OPML with nothing
downloaded -> unsubscribed and removed. Gone but with downloads -> kept and flagged `orphaned`, with
the UI saying why. Verified end to end on a local fixture: the feed with 1 download was kept and
flagged, the one with 0 was removed, the file untouched.
`folder_for` had to change: it sanitized the whole folder string, so `sub/Show` would have collapsed
into one directory. Each path segment is sanitized separately now, with a test that `../../etc/Show`
cannot climb out of the download directory.
Also: `Db::memory()` now runs `migrate()` like a real open. It did not, so a column added only in the
migration passed the tests while being absent in production — which is exactly backwards.
Live result: Ray's `lists.opml.org` subscription expanded into 82 child feeds, all grouped, 1431
entries, no feed errors, worker still responsive mid-scan.
---
## 2026-09-10 — Log view in the app, and Docker
**Log view.** A ring buffer (2000 lines) fed by a `tracing` layer, exposed at `/api/logs` with a
sequence cursor so the UI polls for "everything after N" without duplicates. Tailing a file was the
obvious approach and the wrong one: under Docker the logs go to stdout and there is no file. An
`access_log` middleware adds a line per HTTP request, so the daemon and the web side share one
stream — which is what Ray asked for. That middleware skips `/api/logs` itself, or the panel's
2-second poll would generate a line per poll forever, a log of nothing but its own requests.
UI: sidebar **Log** button, level and text filters, follow-tail toggle, copy button, colour-coded
levels.
**Restart safety.** Detached torrents introduced a `downloading` state, and a row left in it by a
restart would sit there forever — the pending queue skips it and nothing else revisits it.
`requeue_interrupted()` resets those at startup, leaving alone any row that already has a file.
**Docker.** Multi-stage build (deps cached separately from source), `debian:bookworm-slim` runtime,
114 MB. Entrypoint writes a starter config bound to `0.0.0.0` on first run, because a container's
loopback is unreachable from outside, and drops to `PUID:PGID` via gosu. Healthcheck is
`ipx status`, which goes through the control socket to the worker and so catches a *wedged* daemon,
not just a dead one.
Built and actually run, not just written: image builds, container starts, writes its config, serves
the UI (401 without a token, 200 with), `/api/logs` answers, and the process runs as
`uid=99(ipx) gid=100(users)` with files owned `99:100` and the token file at mode 600.
Worth noting for future testing here: this session's Docker socket belongs to the Unraid host, so
`-v /tmp/...:/config` binds a path on the *host*, not one visible from inside this container. The
mount looks empty from here while being perfectly correct — verify inside the container instead.
---
## 2026-09-10 — Torrents: they work, and one was freezing the whole daemon
Asked "is torrents working?". The honest answer had been "unverified since step 7" — so it got tested
properly.
**They work.** End to end against a real swarm: Debian 13.6.0 netinst, 791,674,880 bytes, completed,
`file` confirms a bootable ISO, row marked `done`. Metadata resolution, transfer, placement and
completion all good.
**But the daemon was wedged.** Ray's CT-log feed had 47 pending torrents and nothing was progressing.
The worker was blocked: a `status` command over the control socket got no reply in 15s. Torrents ran
*inline on the single command worker*, so one torrent stopped every feed scan, every HTTP download
and every status command behind it — for up to `stall_mins` (30 default) waiting on metadata, and
worse, `Torrents::fetch` blocks on seeding for up to `seed_time_mins` (60 default) **after**
finishing. One successful torrent could have frozen podcast fetching for an hour.
Torrents are now detached: `spawn_torrent` runs the job on its own task behind a 2-permit semaphore,
the row is marked `downloading` so a rescan does not queue it twice, and the worker moves straight
on. A one-shot CLI run still runs them inline, or the process would exit mid-download. Verified: the
worker now answers `status` in 0.0s.
A diagnostic misstep worth recording: `/api/settings` returning 200 was taken as proof the worker was
alive. It is not — the web server is a separate task, so HTTP stays responsive while the queue is
completely stuck. Probing the control socket is the real test.
**Those CT-log torrents are ~557 GB each, 47 of them.** The RSS advertises `length="0"`, so nothing
warns you; the size only appears once metadata resolves. `auto_download` on that feed is set to
false as a hold, since restarting would otherwise have begun a half-terabyte transfer immediately
(the scheduler's first tick fires at startup). Flip it back in the feed's Settings when wanted.
Also noted: an aborted torrent leaves librqbit's pre-created file placeholders behind in the
destination folder (zero-length). Not cleaned up yet.
---
## 2026-09-10 — Schedule pickers, and progress painting every row
**Pickers.** "Check feeds every" is a number input plus a unit dropdown (minutes/hours/days/weeks)
in both global and per-feed Settings, replacing free text. `parse_interval` gained weeks first — it
only knew m/h/d, so the backend would have rejected the new option. The per-feed dropdown's first
entry is "Use the default — every N", which disables the number and sends `null`; that rides on the
null-clearing path fixed earlier today, and would not have worked before it. `splitEvery` picks the
largest unit that divides evenly, so 120 reads "2 hours" rather than "120 min". The API now returns
`schedule_mins` so the page does not re-implement the parser.
**Bug: one download painted every pending row's progress bar.** Reported after adding the TWiT feed.
The `progress` event carried feed/url/file but no enclosure id, so the handler had nothing to target
and set the width on all of them — the code even carried a comment admitting it applied "to whatever
is downloading now". Adding a feed therefore looked like it was downloading the entire back
catalogue.
`Event::Progress`, `DownloadDone` and `DownloadError` now carry `enclosure`, threaded through both
the HTTP and torrent paths (`db::Pending` gained its row id to make that possible), and the handler
targets `.dlbar[data-bar="<id>"]` alone. A test asserts the id is on the wire. Idle bars are
transparent and only get a track while live, so a row that is not downloading shows nothing.
`tests/page-smoke.js` also drives each modal with live-shaped data now — load-time smoke never
reaches code that only runs when a dialog opens, which is exactly where these changes landed.
Verified live: weeks round-trips (`every 2w` -> 20160 min), both pickers render, clearing an
override returns to the global default. `cargo test` 37/37.
---
## 2026-09-10 — The whole UI was dead, and server-side tests could not see it
Reported as "my feeds seem to have disappeared", then "settings and dark/light mode don't do
anything either". One cause for all three.
`$('#prefs').onclick = prefsModal` referenced a function that did not exist. An uncaught
`ReferenceError` stops the entire script, and that line sits above the theme toggle, the feed
filter, the SSE connection and the `loadFeeds()` call that fills the sidebar — so everything below
it silently never ran.
**Cause: a patch anchored on something already deleted.** The scheduling UI was inserted with
`str.replace` anchored on `function toggleSettings(){`, which belonged to the *old* basic UI that
the full rewrite had already removed. Python's replace matches nothing and says nothing, and there
was no assert — so `prefsModal`, `everyText`, `due` and `globalEvery` were never added, while the
line *calling* `prefsModal` went in fine via a different anchor that did match.
**Why it got through.** Every check was server-side: curl for status codes, JSON shape, config
contents. All passed, because the server was fine. `node --check` also passed — it parses, and a
ReferenceError is a runtime failure. The page was never executed.
`tests/page-smoke.js` now runs the real page script against a stub DOM, fails on anything thrown,
and flags handlers wired to elements that do not exist. Confirmed non-vacuous by reintroducing the
exact bug: exit 1 pointing at the offending line, clean once restored. Run it with
`node tests/page-smoke.js`.
Wiring is also defensive now — `on(sel, ev, fn)` logs and skips rather than throwing, so one dead
reference cannot blank the app again.
---
## 2026-09-10 — Scheduling, and two bugs it uncovered
**Scheduling.** The original engine had none — it only skipped feeds on `<ttl>`; the schedule lived
in the Cocoa GUI that was never open-sourced, so there was no original behaviour to match. Scope
agreed with Ray: interval only, global default plus per-feed override, no quiet hours.
`general.schedule` takes "every 30m", "every 4h", "1d", or a bare number of minutes;
`feeds.<id>.schedule` overrides it. The legacy `interval_mins` is still read so existing configs
work, and saving from the UI migrates it. A malformed value in the file warns and falls back rather
than stopping the daemon; a malformed value over the API is a 400.
Precedence, which is a judgement call: an explicit per-feed schedule wins outright, including over
the publisher's `<ttl>`. With no per-feed schedule, `<ttl>` still raises the interval when the
publisher asks to be polled less often. Visible in practice — TWiT resolves to 720 min from its own
ttl against a 60 min global.
UI: a gear opens global Settings (schedule, quota, max age); each feed's Settings gains a schedule
field; the feed header shows "every 4 hours · next in 37m".
**Bug 1: `null` never cleared anything.** For `Option<Option<T>>`, serde maps JSON `null` onto the
*outer* `None`, which the handler reads as "field absent, leave alone" — so `Some(None)` was
unreachable and every clear was a no-op returning 204. That silently affected `folder`, `schedule`
and `max_new_per_check`; unsetting any of them from the UI did nothing and looked like it worked.
Fixed with a `double_option` deserializer, and pinned by a test covering absent / null / value.
**Bug 2: the daemon ignored SIGTERM while working.** `tokio::select!` races its branches only at
selection time; once inside `run(...).await` the shutdown future was not polled at all, so a signal
queued behind a 90 MB download. Under systemd every restart would have hit `TimeoutStopSec` and been
SIGKILLed mid-transfer.
This one cost real time: `pkill` appeared to work but didn't, a stale daemon kept port 8099, and
because the restart also `rm -f`'d the socket it defeated the "already listening" guard and a second
daemon started. Every PATCH "verified" after that went to the *old* binary — so bug 1 was reported
fixed while completely untested. Lesson: confirm the process actually died and the socket guard is
doing its job, rather than trusting the restart.
The stop signal now lives in a `watch` channel raced *inside* each job. Measured: SIGTERM mid-
download exits in 1s (was: blocked until the transfer finished). An abandoned download leaves a
partial in `.ipx-incomplete`, discarded on the next attempt.
`cargo test` 37/37.
---
## 2026-09-10 — App name, and a transparent icon
Title is `iPodderX` in the browser tab and the sidebar. The heading's `text-transform: uppercase`
had to go with it, or the camel case would have rendered as `IPODDERX`.
The icon's white background is gone. A blanket `-transparent white` would have punched holes
straight through the device, whose body is also white — so the background is flood-filled inward
from all four corners at 12% fuzz, which only clears the *connected* region and stops at the dark
outline. Then trimmed and scaled to 128px.
Checked by compositing onto the real `#0e131b` rather than trusting the alpha channel: no white
plate, and no pale fringe from the JPEG artifacts along the outline. The `.logo` rule lost its
`background:#fff` plate and switched to `object-fit:contain`.
Both files are tracked: `ipodderx-icon.jpg` is the untouched archival original, `ipodderx-icon.png`
the transparent version derived from it, so the provenance chain survives. The PNG is embedded as a
data URI for the sidebar mark and the favicon.
---
## 2026-09-10 — Colour scheme derived from the icon
Palette sampled from `web/ipodderx-icon.jpg` with ImageMagick rather than eyeballed. The icon gives
three families: the silver device ramp (`#1A1A1A` `#606060` `#929292` `#ADADAD` `#D6D6D6` `#F5F5F5`),
the screen blues (`#2D5391` `#5574A9` `#7595CA` `#92B2E6` `#B4CAED`), one amber from the EQ bars
(`#F49E2C`), and a blue-grey `#95A0B1`.
Dark theme: `--bg` is the screen navy taken right down, `--fg` the device highlight, `--dim` the
blue-grey verbatim, `--accent` the screen blue, `--accent2` the amber. Light theme flips to the
device body — white panels, `#D6D6D6` edges, `#1A1A1A` text — with the deeper `#2D5391` as accent,
since the pale screen blue vanishes on white. Text sitting on an accent fill moved from a hardcoded
`#0b0e14` to a `--ink` token.
Two calls: the amber doubles as the pending state, being the only warm note in the icon and exactly
where the eye should go. And since the icon has no green or red, the downloaded/error colours are
tuned to the palette's saturation and temperature rather than invented from scratch.
**Contrast was checked, not assumed, and it failed first time.** `--faint` — which carries dates,
durations and file sizes at small size — came out at 3.92 dark and 3.11 light, both under AA. Moved
along the icon's own grey ramp to `#7a8799` and `#767676`. Every pair in both themes now clears
WCAG AA; the lowest anywhere is 4.54.
---
## 2026-09-10 — The real iPodderX icon
The sidebar mark was an invented `ix` placeholder. The original icon is not in this repo or the
legacy one: only the Python engine was open-sourced, never the Cocoa bundle whose Resources held the
`.icns` (the legacy repo's history contains nothing but .py/.so/.po/.mo/LICENSE/README).
Recovered from the Internet Archive's capture of ipodderx.com: `_graphics/iPXicon.jpg`, 173x165 --
the white iPod-style device with the antenna and EQ display. That is the largest surviving copy; the
site's 2005 `favicon.ico` is far too small to use. Saved as `web/ipodderx-icon.jpg` so the file lives
in the tree, and embedded as a base64 data URI (12.7 KB) for both the sidebar mark and the favicon,
which avoids an extra asset route.
`.logo` changed from a gradient chip to a white rounded tile, since the icon is photo-style on a
light ground.
Provenance is Ray's own: the app was Slakinski & Trometer / Thunderstone Media, and this is its
successor.
---
## 2026-09-10 — Feed URL is editable, with a copy button
The URL in a feed's Settings is now an editable field paired with Copy. Entries and download
history are keyed by feed id, not URL, so changing it keeps everything — which is the point, since
a Patreon feed URL carries an auth token that gets rotated.
Two things that would otherwise have bitten:
- **`navigator.clipboard` does not exist here.** It requires a secure context, and this is served
over plain HTTP on a LAN address, so the button would have silently done nothing. Falls back to a
hidden textarea plus `execCommand('copy')`, and reports Copied/Failed either way.
- **Changing the URL clears the stored ETag/Last-Modified.** Those validators belong to the old URL;
carrying them over could yield a bogus 304 against the new one and make a working feed look empty.
`check_url` is a tested pure function: rejects empty, unparseable and non-http(s) URLs (so
`file:///etc/passwd` is refused), rejects a URL another feed already uses, and allows a feed to keep
its own URL unchanged. Validation failures and unknown feed ids now return **400**, not 500 — bad
input is the caller's mistake, and only genuine server faults are logged as errors.
Verified live: the three rejection cases return 400 with readable messages, a no-op save returns 204,
and the feed still reports 131 entries / 11 downloaded. `cargo test` 34/34.
---
## 2026-09-10 — Fixed: pressing play made an episode vanish from the list
Reported as "where did Session Zero go, and why do parts share a number?".
Two separate things, one of them a real bug.
**The bug.** `play()` marked an episode read the moment playback started. The default view is the
Unread tab, so pressing play removed the episode from the list you were looking at — indistinguishable
from it going missing. Confirmed from the data: all four read entries had `position = 0`, meaning
playback never passed the 10-second save threshold. They had been started, never listened to.
Marking read now happens when an episode is actually consumed — on `ended`, or once playback passes
90% — never on play. Two earlier PROGRESS entries described mark-on-play as a deliberate feature;
it was a design error, and this supersedes them.
Also restored the four affected rows to unread, since their read flag was purely an artifact of the
bug (`UPDATE entries SET read=0 WHERE read=1 AND position=0` — no genuinely-played episode could
match, since playing one writes a position).
**Not a bug: repeated episode numbers.** Each story part ships as two items — the main episode and a
shorter `Junk in the Trunk` companion — with distinct GUIDs, durations and files, and the publisher
gives both the same `itunes:episode`. Zero duplicated titles across all 131 entries. `Session Zero`
additionally carries a season with no episode number and drops the "Part N" naming, so it does not
match the pattern its siblings follow.
Verified: the arc shows 11/11 items under the default Unread view again.
---
## 2026-09-10 — Titles: RSS `<title>` is the only source
Confirmed against the live feed rather than assumed: 131 feed items, 131 stored, **0 mismatches**
entry titles are byte-for-byte what the RSS `<title>` publishes, separators included. `feed.rs` never
consults `itunes:title`, and season/episode live in their own columns, rendered as chips rather than
folded into the title.
Pinned with a test: a fixture whose `itunes:title` differs from its `<title>` must still yield the
RSS title, and an item with `itunes:season` but no `itunes:episode` keeps a null episode instead of
inventing one.
That null-episode case is real in the wild — this feed's `Music from a Darkened Room | Session Zero`
carries S8 with no episode number, while Parts 1-5 get E1-E5, and it drops the "Part N" convention
its siblings use. The arc is 11 items: Session Zero plus five parts, each part being a main episode
and its shorter `Junk in the Trunk` companion.
---
## 2026-09-10 — Phase 3: full-featured UI
Rewrite of `web/index.html` (~714 lines) plus the backend it needed.
**New stored metadata**, all of which the real feed carries on all 131 episodes and none of which
was being captured: feed and per-episode artwork (`itunes:image`), duration (`itunes:duration`,
parsed from either raw seconds or a `1:34:09` clock), season/episode numbers, and a playback
`position` so episodes resume.
**Schema migration.** `Db::open` now runs `migrate()`: `PRAGMA table_info` then `ALTER TABLE ADD
COLUMN` for anything missing, since `CREATE TABLE IF NOT EXISTS` does nothing to an existing table.
Tested on a *copy* of the live database first. Accidentally SIGPIPE'd it partway through (a `head -4`
on the output) which usefully proved it is idempotent and self-heals: the next run added the
remaining columns, 131 entries and 6 downloads intact.
**New endpoints:** entry filters (all/unread/downloaded/flagged), case-insensitive search over title
and notes, pagination totals, `POST .../position`, `read-all`, `download-latest`, and OPML
import/export over HTTP.
**UI:** persistent player bar surviving navigation (play/pause, ±15s/30s, 0.8-2.5x speed, volume,
scrubber, all remembered in localStorage), resume playback via `sendBeacon` every 10s, MediaSession
for lock-screen controls, keyboard shortcuts (space, arrows, `/`, Esc), artwork everywhere with
generated-initials fallback, filter tabs, episode search, load-more pagination, live progress bars
off SSE, real modals, toasts, light/dark toggle, and a mobile layout with a collapsing sidebar.
**Bug found and fixed in review: filtering was broken while searching worked.** `count_entries`
dropped the search clause when no term was given but still bound `?2`; rusqlite rejects a parameter
the statement never mentions -- *"Wrong number of parameters passed to query. Got 2, needed 1"*. So
`?q=...` worked and a plain Unread/Downloaded/Flagged filter 500'd. The clause is now always present
with `?2 = ''` short-circuiting it. Regression test runs all four filters with and without a search
term and asserts the page and the count agree.
**A non-bug worth recording:** artwork looked broken on the live DB (0/131) while a fresh DB filled
all 131. The parser was fine -- the live DB simply had not been rescanned yet, since backfill happens
through `record_entry`'s update path. After a forced scan: 131/131 image, duration and season.
Verified live: all four filters return sane totals (131/129/10/0), search works (`session zero` -> 2,
`trunk` -> 10), `/media/1` with a Range header returns 206, index serves 34 KB. `cargo test` 32/32.
---
## 2026-09-10 — Fixed: the UI's Download button downloaded the wrong episodes
Reported from the running instance: clicking Download on *Music from a Darkened Room | Session Zero*
showed progress, then left the episode at `pending`.
**Cause, and it was a design error in step 11, not a glitch.** `POST /api/enclosures/{id}/download`
requeued the row to `pending` and asked for an ordinary scan, justified at the time as "needed no
new machinery — the queue is the table". But a scan means *take the lowest-id pending rows, up to
`max_new_per_check`*. Against a 125-item backlog with a cap of 2, that is ids 7 and 8; the requested
row 11 was never a candidate. The progress on screen was two other episodes downloading past it.
The queue can express what is outstanding but not what was *asked for*. `Command::Download {
enclosure }` is now its own command: it fetches that one row immediately, ignoring both queue order
and the per-scan cap, still through the single worker so it cannot overlap a scan. Torrent
enclosures route to `torrent_one` the same way a scan would.
Verified live: enclosure 11 went `pending` -> `done`, 90.3 MB on disk, and nothing else was pulled
in its place. `cargo test` 30/30, with the new command's wire form pinned.
Worth remembering: **the daemon scans immediately on its first tick**, so every restart costs
`max_new_per_check` episodes on the normal path.
---
## 2026-09-10 — Phase 2: web front end (steps 9-13)
axum in the daemon process, plain HTML/JS in `web/index.html` (embedded with `include_str!`), no
WASM toolchain. One binary still.
**Config hot-reload.** `Ctx.cfg` is now `RwLock<Arc<Config>>`; `ctx.cfg()` hands out a snapshot, so
no guard is ever held across an await. The UI rewrites config.toml and calls `reload_cfg`, and a
running daemon picks the change up on its next scan. The CLI mutation commands (`add`/`rm`/`import`)
now clone a snapshot, edit, and save.
**Auth.** `[web] enabled/bind/token`; the token is minted from `/dev/urandom` on first run, written
back to config.toml, and the URL printed. `?token=` sets a year-long cookie — it has to be a cookie
because an `<audio>` element cannot send a header. Comparison is constant-time. An empty token makes
the server refuse to serve rather than serve open.
**Endpoints.** `/api/feeds` (GET/POST), `/api/feeds/{id}` (PATCH/DELETE),
`/api/feeds/{id}/entries`, `/api/entries/{feed}/{guid}/flags`, `/api/enclosures/{id}/download`,
`/api/enclosures/{id}` (DELETE), `/api/fetch`, `/api/events` (SSE off the existing broadcast bus),
`/media/{id}` (tower-http `ServeFile`, so Range works).
**`read`/`flagged` finally have a writer** — the UI sets them, and playing an episode marks it read.
Retention has ordered by these since step 5 with nothing to set them.
**Download-on-demand needed no new machinery**: requeue the row to `pending` and kick a scan, since
the queue is the table. That also un-skips an enclosure a filter rejected under older settings.
Verified against the live Glass Cannon feed, daemon on 127.0.0.1:8749:
- auth: no token 401, wrong token 401, right token 200 + cookie, API then works on the cookie alone;
`/media/1` unauthenticated is 401.
- browsing: 131 entries page with enclosures attached.
- **XSS**: injected `<script>alert(1)</script><img src=x onerror=alert(2)>` into a stored
description; it reaches the page as `<p>hi</p><img src="x">` — script tag and handler both gone.
- media: 200 with `accept-ranges: bytes`; `Range: bytes=1000000-1000999` returns 206 with the right
`content-range`, so seeking works.
- config: PATCH -> 204, written to config.toml, and the running daemon reports the new values with
no restart.
- SSE: 105 events for one download (101 progress), then `download_done`/`feed_done`/`scan_done`.
- flags: 204, unread count dropped 131 -> 130. Delete: file gone, row `reaped`/`path=NULL`.
`cargo test` 30/30.
**Caveats.** It is plain HTTP — on a LAN bind the token crosses the network in the clear, and a feed
URL may itself carry a credential (Patreon's does), which the `/api/feeds` response includes. A
reverse proxy with TLS is the answer if that matters. There is no per-user anything; the token is
all-or-nothing access.
---
## 2026-09-10 — Tested against a real feed (Patreon / Glass Cannon)
First run against a live subscriber feed: 131 items, 6.17 GB total, all `audio/mpeg`, no `<ttl>`.
Parsed clean, titled `Get in the Trunk | Anthology Series | Delta Green`, downloaded a valid MP3
(`file` confirms ID3v2.3, MPEG layer III). A second scan pulled the *next* episode, confirming
`max_new_per_check` defers rather than drops. Over the socket: 101 progress events for a 79 MB
episode -- whole-percent throttling behaving exactly as intended -- then `download_done`,
`feed_done`, `scan_done`.
**Fixed: stripped separators left doubled spaces.** The feed title separates words with `|`, a
forbidden filename character, so the folder came out `Get in the Trunk Anthology Series Delta
Green`. The Python had the same wart.
Forbidden characters are now split in two. Separators (`/ \\ | :`) become `-`; the rest
(`? * < > " '`) are simply dropped. A run of dashes and spaces then collapses to `" - "` when the
run contained whitespace and to a bare `-` when it did not, so:
| input | output |
|---|---|
| `Get in the Trunk \| Anthology Series \| Delta Green` | `Get in the Trunk - Anthology Series - Delta Green` |
| `Ep 12: The One` | `Ep 12 - The One` |
| `AC/DC` | `AC-DC` |
| `well-known.mp3` | `well-known.mp3` |
| `../../etc/passwd` | `etc-passwd` |
Leading dashes and dots are trimmed too -- a filename starting with `-` trips up CLI tools.
**Worth knowing, not a bug:**
- The channel-level `itunes:explicit` is `true`, so with the default `allow_explicit = false` all
131 episodes are skipped. Because filter verdicts are recorded once at discovery, flipping the
setting afterwards does not re-evaluate enclosures already marked `skipped` -- they would need a
`UPDATE enclosures SET state='pending'`. Worth a `ipx retry <feed>` command if this bites.
- Patreon sends neither `etag` nor `last-modified` on a GET (a `last-modified` does appear on HEAD,
which is what made it look briefly like a storage bug). So this feed can never 304: every poll
re-fetches ~160 KB and re-parses 131 entries, and `interval_mins` is the only thing limiting the
rate. Cheap, but it means conditional GET buys nothing here.
---
## 2026-09-09 — Step 8: OPML and polish
`ipx add <url>` fetches the feed to name it from its own title (`Accidental Tech Podcast` ->
`accidental-tech-podcast`); a feed that cannot be reached is still added, named from its URL, rather
than refused. `ipx rm` leaves downloads and history alone, so re-adding a feed does not re-pull its
back catalogue. `ipx import`/`export` walk nested OPML folder outlines and skip URLs already
subscribed. `tracing` logs to stderr, `IPX_LOG` sets the filter. `contrib/` has a systemd user unit
for the daemon, plus a timer and one-shot service for the no-daemon style (with the caveat that
without a daemon there is no socket for a UI).
Verified: `cargo test` 27/27. Round-trip — exported 3 feeds with real titles, removed one,
re-imported: exactly the missing one came back, no duplicates, config still mode 0600. Quickstart
from a genuinely empty home with no env overrides: `list` on a missing config, `add`, `fetch`
(1 downloaded, 1 explicit skipped, 1 torrent 404), `list`; every path resolved under `$HOME`.
---
## 2026-09-09 — Step 7: torrent.rs
`src/torrent.rs`: a librqbit `Session` started lazily on first torrent (binding ports and starting a
DHT for a config that has never seen a torrent would be rude). `.torrent` URLs and magnets both go
through `AddTorrent::from_url`. Progress is polled once a second and reported through the same
`Progress` events HTTP downloads use.
Downloads go **straight into the feed folder** rather than staging and moving. The plan said move
then seed, which cannot work — seeding serves the files it downloaded, so moving them first breaks
it. In-place also removes a copy the original had to do.
Seeding stops at `seed_ratio` or `seed_time_mins`, whichever comes first, then the torrent is
released from the session (files kept).
**Bug the smoke test caught:** the stall budget only covered the download loop, but resolving a
magnet's metadata happens inside `add_torrent`, which against a dead swarm never returns — a
torrent nobody seeds wedged the scan indefinitely. `add_torrent` is now wrapped in the same budget.
Verified: `cargo test` 27/27 (ratio incl. the divide-by-zero case, stall_mins = 0 not meaning
"abort instantly"). Routing: with `enabled = false` a torrent enclosure is marked
`skipped/torrents disabled` and never attempted. Session startup works here. Stall abort measured
end to end: with `stall_mins = 1`, a dead magnet failed at 20:58:23 -> 20:59:24, exactly 61s, and
the row recorded `error / no metadata after 1 minutes, gave up`.
**Not verified: an actual successful swarm download.** This sandbox has no reachable peers, so
smoke 5's happy path — payload lands, seeding stops at the ratio — has not been run. The code paths
either side of it are tested; the swarm itself needs a real network. Worth running once against a
live torrent feed before trusting it.
---
## 2026-09-09 — Step 6: ipc.rs + daemon
`src/ipc.rs`: `Event` and `Command` as serde-tagged enums (`{"ev":...}` / `{"cmd":...}`), one JSON
object per line over a Unix socket. `Emitter` is the single output path — it broadcasts to socket
clients, prints the human rendering to a terminal, or both, so the scan code no longer knows how it
is being watched. That replaces `printMSG` and its `;;1;;1;;100.00;;42.31` sentinels.
`main.rs` restructured around a `Ctx` (config, db, client, emitter). `ipx daemon` binds the socket,
serves clients, and runs a 60s ticker that defers to each feed's TTL. Commands from every client
funnel through one mpsc queue into a single worker, which is what stops two scans overlapping.
Any CLI subcommand with a wire form probes the socket first and proxies to a running daemon;
`--local` forces the work to happen in-process. SIGINT/SIGTERM remove the socket on the way out.
**Bug the smoke test caught:** `fetch` runs a retention sweep first, and that sweep was emitting the
terminal `ReapDone`. A UI waiting for its fetch to finish would have stopped reading before the scan
started. Only a standalone `ipx reap` emits it now.
Verified: `cargo test` 24/24. Smoke 3 in full — daemon starts and binds; a raw socket client sending
`{"cmd":"fetch","force":true}` gets `feed_start` → 14 throttled `progress` events → `download_done`
`feed_done``scan_done`; `{"cmd":"status"}` answers `{"ev":"status","feeds":1,...}`. With the
daemon up, `ipx fetch` from the CLI logged `command from a client cmd=Fetch { .. }` in the daemon
and rendered the streamed events, so it proxied rather than downloading in parallel. Socket removed
on SIGTERM; with no daemon the same command runs locally.
Deferred: `{"cmd":"cancel","enclosure":N}` from the plan's protocol is **not implemented**
downloads run sequentially in one worker, so there is nothing to cancel concurrently yet. It wants
a per-download cancellation token, which is worth doing when downloads go parallel. Say if you want
it sooner.
Note: the event stream is a broadcast, so a CLI client seeing a busy daemon also sees that other
work. Fine for a terminal; a UI wanting strict request/response would want per-request ids.
Also note: the daemon reads config once at startup — changing `config.toml` needs a restart.
Next: step 7 — `torrent.rs`.
---
## 2026-09-09 — Step 5: retention.rs
`src/retention.rs`: reconcile pass (rows claiming a file that is gone become `reaped`, fixing the
step-4 wart), age sweep, quota sweep keeping the original's 50 MB headroom pad, and entry pruning.
`ipx reap [--dry-run]`; a sweep also runs before every `fetch`, as the Python did per download.
`pick()` and `aged()` are pure so the ordering rules are testable without touching a disk.
**Judgement call worth Ray's eye.** The Python meant to reap only `read = 1 AND flagged = 0` but
never managed it — a missing `plistlib` import and an `EntreiesData` typo made that filter throw on
every candidate, so with a `.ipxd` present nothing was ever deleted. Requiring `read = 1` here would
be equally dead, because nothing marks episodes read until a UI exists. So: **`flagged` is the
keep-forever marker, and `read` only decides what goes first** (`ORDER BY read DESC, downloaded_at
ASC`). Quota therefore actually reclaims space headless. Say the word if you would rather unread
episodes were never touched.
Second call: `max_age_days` deletes *files* older than the cutoff, not just fileless entries as the
plan's wording had it — "keep 30 days of episodes" is what the setting reads like on a NAS.
Verified: `cargo test` 21/21, including the two tests encoding the exact bug the Python had —
flagged files are never offered, and read sort ahead of unread. Smoke 6 with three 30 MB episodes
against a 0.1 GB quota (52.4 MB ceiling after the pad): dry run listed ep1+ep2 and deleted nothing
(3 files still on disk), the real run deleted exactly those two oldest, left ep3, flipped both rows
to `reaped` with `path = NULL`. A full re-parse with the conditional-GET headers cleared then
re-downloaded nothing.
Next: step 6 — `ipc.rs` + daemon.
---
## 2026-09-09 — Step 4: download.rs
`src/download.rs`: streaming download to `<download_dir>/.ipx-incomplete/` (same filesystem as the
destination, so filing it is a rename, not the original's copy-then-unlink), content sniffing,
then `place()`. Filename comes from the URL's last path segment, percent-decoded, unless
`Content-Disposition` names one (RFC 5987 `filename*=` preferred). The sanitizer keeps UTF-8 —
`latin1_to_ascii` existed because 2004 filesystems demanded ASCII — strips the same characters
`stringCleaning()` did plus control chars, and adds a real 255-byte cap the Python never had,
preserving the extension across truncation.
Sniffing replaces `detectFileType()`, which called a `typeFile` module that was already missing in
2008 and so always answered `'data'`. Two rules survive: an HTML body is a failed download (login
wall/error page), and a torrent body is a torrent whatever the MIME claimed.
Filters run once at discovery and are recorded in `enclosures.state`; the download queue is then
just "everything still `pending`", so an enclosure held back by `max_new_per_check` is picked up by
the next scan instead of being lost. Keywords are OR'd across keywords and AND'd within one — the
original's nested loop let a later keyword silently undo an earlier miss.
Verified: `cargo test` 16/16. Smoke against a local server, five enclosures, each filter path hit:
`ep1.mp3 -> done`, `ep2.mp3 -> skipped (explicit)`, `ep2.mp3?v=3 -> skipped (no keyword match)`,
`paywall.html -> error (HTML page, not media)`, `ep5.torrent -> torrent (deferred to step 7)`.
Smoke 2 and 4 pass, and because a plain rerun 304s before parsing, dedupe was proved separately by
clearing the stored etag/last-modified and re-parsing all five entries: 0 downloaded, hand-deleted
file not refetched, `.ipx-incomplete` left empty.
Known wart: `ipx list` counts `path IS NOT NULL`, so a hand-deleted file still reads as downloaded.
Reconciling rows against the filesystem belongs in step 5.
Next: step 5 — `retention.rs`.
---
## 2026-09-09 — Step 3: feed.rs
`src/feed.rs`: conditional GET (`If-None-Match` + `If-Modified-Since`, optional basic auth) and a
RSS-first / Atom-fallback parser normalising both into `ParsedFeed`/`Entry`/`Enclosure`. Feed-level
`itunes:explicit` overrides the entry level, as the original did. `<ttl>` is captured. RSS
`content:encoded` wins over `description`. Atom enclosures come only from `rel="enclosure"` links.
GUID: the original hashed the title or description when no guid existed; here the chain is
guid → permalink → enclosure URL → title, all stable identifiers, so no hashing and no MD5
dependency. An entry with none of them has nothing to download and is dropped.
`db.rs` gained `http_state`, `record_feed`, `touch_feed`, `set_feed_error`, `record_entry`,
`record_enclosure`. A changed title/description flips `read` back to 0 — what the original's
textDiff was ultimately for, minus the `<ins>`/`<del>` markup, which belongs in the UI.
`main.rs` gained `ipx fetch [FEED] [--force]`. A failing feed records its error and the scan
continues.
Verified: `cargo test` 10/10. Gate met against a local `python3 -m http.server` serving the
fixtures — first run inserted 4 entries + 4 enclosures across an RSS and an Atom feed; second run
showed both skip paths, `atomcast: not modified` (304) and `testcast: not due for 45m` (the feed's
own ttl=45 beating `interval_mins = 0`).
Deferred: nothing downloads yet — enclosure rows land in state `pending`. That is step 4.
Next: step 4 — `download.rs`.
---
## 2026-09-09 — Step 2: config.rs + db.rs
`src/config.rs`: serde structs for `[general]`, `[torrent]` and `[feeds.<id>]` with defaults, `~`
expansion, `IPX_CONFIG` / `IPX_DATA_DIR` overrides, `save()` at mode 0600, `Feed::password()`
(`password_env` beats a literal `password`), `Torrent::ports()` parsing `"6881-6889"`. A missing
config file loads as an empty one so a fresh install works. Retention defaults are 0/0
(unlimited, keep forever) — nothing gets deleted until Ray asks for it.
`src/db.rs`: schema exactly as planned, WAL + `busy_timeout`, `Db::open()` idempotent, connection
behind a `Mutex`, `feed_summary()` for `list`, `now()` helper. `enclosures.url` is UNIQUE — the
dedupe key that replaces `history.dat`.
`src/main.rs`: clap skeleton with `ipx list`. Only the subcommands that work exist; the rest arrive
with their steps.
Verified: `cargo test` 5/5 green (config defaults, port-range fallback incl. reversed range,
password_env precedence, schema idempotency, enclosure-url uniqueness). Gate met — `ipx --config
<scratch> list` printed both feeds and created `state.db` once across two runs.
Deferred: nothing. Two dead-code warnings (`Config::save`, `Feed::password`) are expected; steps 3
and 8 consume them.
Next: step 3 — `feed.rs`.
Also added `chrono` 0.4 (std, clock) for RFC-2822 pubDate parsing in step 3.
---
## 2026-09-09 — Step 1: repo skeleton
Repo created at `/src/ipodderx-rs`, default branch `main`, `cargo init --name ipx` (edition 2024,
rustc 1.95.0). LICENSE (MIT, carrying the 2010 copyright), README with the lineage note, and this
file. Hello-world `main.rs` builds.
Dependency versions pinned today — the later steps are written against these APIs:
| crate | version | notes |
|---|---|---|
| tokio | 1.53.1 | rt-multi-thread, macros, fs, io-util, net, sync, time, signal |
| reqwest | 0.13.5 | `default-features = false`; features **`rustls`** (not `rustls-tls` — renamed in 0.13), http2, gzip, stream, json, charset, **`system-proxy`** (env-var proxy pickup is opt-in in 0.13) |
| rss | 2.1.1 | default features; the `with-syndication` feature name in my notes does not exist — Atom is handled by the separate crate |
| atom_syndication | 0.12.10 | |
| rusqlite | 0.40.2 | `bundled` |
| librqbit | 9.0.1 | `default-features = false`; features `rust-tls`, `http-api-client`. The default `default-tls` feature pulls reqwest/native-tls **and** an OpenSSL sha1 backend (`crypto-hash`), which fails to build without pkg-config/OpenSSL headers. API not yet exercised — step 7 |
| serde 1.0.229 / serde_json 1.0.151 / toml 1.1.5 | | |
| clap | 4.6.6 | derive |
| infer 0.22.0 / dirs 7.0.0 / anyhow 1.0.104 / opml 1.1.6 | | |
| tracing 0.1.44 / tracing-subscriber 0.3.23 | | env-filter |
Deferred: nothing.
Gotcha worth keeping: three feature names in the plan were wrong against current crate versions —
`reqwest/rustls-tls` is now `rustls`, env-var proxy support moved behind `system-proxy`, and
`rss/with-syndication` does not exist. `librqbit`'s default features drag in OpenSSL; `rust-tls`
is the fix. Whole tree is rustls-only now, no C TLS dependency.
Next: step 2 — `config.rs` + `db.rs`. (done)