# How ipx was built The long-form record, newest first: what landed each day, what was wrong before, and what it cost to find out. [CHANGELOG.md](../CHANGELOG.md) has the short version, by release; this is where the reasoning lives. New write-ups go at the top. See [README.md](../README.md) for what the thing is. ## 2026-09-11 — A Patreon creator is a list of shows Ray asked whether ipx could sync with Patreon. Not in full. The documented API (v2, the `identity.memberships` scope) lists the creators you back and whether each has a feed (`has_rss`), but no resource carries the `auth` token that makes a feed URL work. That token only comes from the creator's page. It is also one per membership, not one per account: techpod's differs from Glass Cannon's, so no single token finds everything you back. What does work is one creator at a time, which is what Ray wanted for Glass Cannon and its 33 shows: - `patreon.com/rss?auth=`, with no creator named, returns that token's creator. Its self link, about 660 bytes in, gives the campaign by number (`/rss/369921`). Patreon ignores `Range` here, so ipx reads the stream until the number appears and hangs up, instead of taking all 2.8 MB. - A show's `show=` number is a Patreon collection. Asked anonymously, the collection listing (`/api/collection?filter[campaign_id]=`) and a post's `collections` both hide patron-only ones: you get "FAQ". `/api/campaigns/?include=shows` lists every show, anonymously, in one response. - Every spelling works: `rss/glasscannon?auth=…&show=N`, `rss/369921?…` and `rss?auth=…&show=N` all serve the same 131 items. Enclosure URLs are the same in the creator feed and the show feed, and stable between fetches. That last point shaped the design. `enclosures.url` is unique, so whichever feed is scanned first owns the file. The first cut only asked a creator for its shows while it had no entries of its own, so that a creator already read as a plain feed, holding every show's episodes, would never be split into shows that came up empty. Within the hour that was the wrong call: Glass Cannon had gone into production on the build before this one, been read as one feed of 2,385 items, and the rule kept it that way. Finding anything in that heap was the problem Ray wanted solved. So a creator with more than one show is always a group, run through the same sync as an OPML (`sync_group`, split out of `sync_opml`). When it becomes one, its items are cleared and each show takes over the enclosures the creator holds as the show lists them (`Db::adopt`), downloaded files and everyone's read state included. One show leaves it a plain feed, which is what techpod already was. If the shows cannot be listed, a creator already split fails the scan rather than being read as one heap; one that never was is read as one feed until they can be. An answer without a `shows` list is an error, not "no shows". **Filter verdicts follow the settings.** Ray also reported that turning on Allow explicit and rescanning brought nothing back. An item was judged once, when first seen, and `skipped` was final. The 2026-09-10 entry below saw it coming ("worth a `ipx retry ` command if this bites"). It bit: 2,166 Glass Cannon items and all 88 of Shadowdark's sat at `skipped: explicit` with the setting on. Every scan now runs the filters again over what they skipped (not over `torrents disabled`, which is not a filter's call) and requeues what they now let through. Only that direction: a queued item is never pulled back, because Download latest and a manual download both work by queueing. **Two gaps beside it.** Add feed had no explicit box, so every new feed's first scan skipped all its explicit items; it has one now, stored on your subscription like the feed dialog's. And a feed inside a group ignored your settings on the group, though the group's dialog said they were inherited: settings live on each person's subscription, and nothing read the group's. `Db::subscribers` now fills what you have not set on the feed from your subscription to the group, and the feed list shows the same. **A name that was already used.** Replaying the split on a copy of the production database left one show with a Supercast episode in it. "Glass Cannon Live! Ascension | Pathfinder 2E" slugs to `glass-cannon-live-ascension-pathfinder-2`, the id of a Supercast feed of the same show that had been removed. Removing a feed keeps its rows on purpose, so that re-adding it does not fetch the back catalogue again, but choosing a new id only checked config.toml and derived feeds. The Patreon show took the old id and everything still filed under it. An id is now also taken when the database has a feed by that id at a different URL; the same URL may still have it back, which is the re-add case. Shows already added by hand are matched by token and show number, not by exact URL (`same_feed`), so a bare token does not add Get in the Trunk and Shadowdark a second time under another spelling. The show listing is Patreon's own undocumented web API. If it changes, only finding new shows stops. ## 2026-09-11 — One meaning per icon, sortable columns, and one player Ray asked for a pass over the whole UI: consistent icons, and buttons placed next to what they act on. The pass started from screenshots of every view and dialog, taken against the browser suite's own daemon and fixtures. Several things did not agree: - Unsubscribe was a minus in the toolbar but an x in the feed header. There it read as "close this page", and x also meant Cancel in every dialog. - Half the dialogs still had word buttons: Log, Add feed, Users, Unsubscribe, OPML, Popular's Subscribe, the Copy buttons and Sign out. - The player bar and the folder arrow were font characters, and so was the row's "playing" marker. Those came out thin and varied from font to font. - The OPML subscription page laid out its header differently from a feed's, and All Subscriptions had no buttons at all. The rule now is one meaning per icon. Minus unsubscribes, x closes or cancels, plus adds, subscribes or imports, and a dialog's confirm button carries the icon of what it does. The words go in the tooltip. The toolbar is grouped by what it acts on: add, unsubscribe and scan act on feeds; play, read and keep act on the selected item. Its read and keep buttons now show that item's state, as the item's own buttons already did. A browser test opens all eight dialogs and fails on any button without an icon and a tooltip. It also checks that the toolbar and the page use the same icon for the same action. The pass turned up three bugs: - **Escape ignored inside a dialog's text box.** The keydown handler returned early for any key typed in a text box, and Escape sat below that check. So Add feed, which opens with the cursor in its URL box, could not be closed with Escape. It showed up because the screenshot run stalled: the dialog stayed open, and every later click landed on its overlay until the run timed out. - **A white password box in the dark theme.** The input rule listed text, search and number fields, but not password ones. - **A stray "• •" under an item with no date.** Each detail carried its own trailing dot, whether or not the next one was there. All Subscriptions gained Mark everything read, as `POST /api/read-all`. It marks the feeds from the person's subscriptions, which is the same set the view's query is scoped by. It asks first, since one click covers every feed. **Sorting** happens on the server. A list arrives fifty at a time, so sorting in the page would only have reordered what was loaded. The column name picks one of a fixed set of SQL expressions (`order_sql`), so nothing the browser sends reaches the query, and an unknown name means newest first. Ties also fall back to newest first, so "Load more" does not skip or repeat items at a page boundary. A database test runs every column both ways. Size came out of the File column into its own, and small files show KB, since whole megabytes made every article image "0 MB". **The double play.** Ray found that pressing play in the Files pane played the file twice at once. The pane drew its own `