Commit Graph

105 Commits

Author SHA1 Message Date
4f8b3d6a1d Log as JSON when IPX_LOG_FORMAT=json (#91)
The log was text, so the Grafana dashboard picked lines apart with
regular expressions, and a change of wording would have blanked its
panels. With IPX_LOG_FORMAT=json each line is one JSON object: the
access log carries method, path, route, status and ms as fields (the
route passed from the routing layer in the response's extensions), and
each wire event its ev, feed, new, downloaded, failed, bytes, msg and
the rest (log_wire), beside the old message. The two startup lines that
were println! are logged, so no line breaks the JSON. Text stays the
default, for a terminal. The dashboard reads the fields with Loki's json
parser, and groups requests by route rather than path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:55:50 +00:00
9e7c93e149 Name request traces by route, and no colour codes off a terminal (#87, #88)
A request's trace was named by its path, so every item's GUID in
POST /api/entries/{feed_id}/{guid}/flags made a trace name of its own and
nothing grouped in Tempo. A route layer now renames it once routing has
matched. It renames the OpenTelemetry span directly: tracing-opentelemetry
drops a recorded otel.name once the span has been entered, and access_log
enters it before routing runs.

tracing-subscriber's fmt layer writes ANSI colour by default, so docker
logs and Loki (through Alloy) carried escape codes on every line, which
each query had to strip. Colour is now for a terminal only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:33:11 +00:00
1724346da7 Send OpenTelemetry traces over OTLP (#87)
ipx had no spans, only log lines, so there was no way to see where a
slow scan, download or request spent its time. With
OTEL_EXPORTER_OTLP_ENDPOINT set, the daemon now exports traces over
OTLP/HTTP (Tempo on Tower): a scan, each feed in it, the feed fetch and
site icon lookup, downloads, torrents, reaps, and web requests. Log lines
inside a span ride along as its events.

Only the daemon exports: the healthcheck runs ipx status every 30s and
would bury everything else. The web event stream and the log view's
polling get no span, for the same reason. The exporter shares ipx's
reqwest 0.13, so no second HTTP stack comes in.

The stderr log now prefixes lines inside a span with it, as
tracing-subscriber's fmt layer does (scan{only=None force=false}: ...).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:22:45 +00:00
8c5eddd783 Drop the start-up pass that folds files WordPress listed twice (#83)
Before 0.6.0 the parser took WordPress's numbered player URLs (?_=2) for
separate files and downloaded some episodes twice. Since then it drops
the repeats while reading (same_file_key), and merge_repeated_enclosures
cleaned up what was already stored. Production has run it; on every
start since it has only cost a query that finds nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:12:10 +00:00
469467bf04 A site icon is looked up again when the feed is read in full (#80)
The icon standing in for a feed's missing artwork was looked up once and
kept, so a site that changed or fixed its icon, or a feed that dropped
its own artwork, kept whatever was found first. A dead icon stored
before #79 would have stayed dead. It is now looked up whenever the
feed is read in full: when it has changed, or on a refresh someone asks
for, which reads in full since #77.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:51:19 +00:00
95a8633877 A site icon that is missing is not used (#79)
site_icon took the icon a site's page names in its <link> tags without
asking for it, so a dead one was stored and /favicon.ico never tried.
antirez.com names /images/favicon.png, which is a 404, while its
/favicon.ico is there; the feed showed no artwork, and since the lookup
happens once, never would. The named icon now has to answer with an
image, as /favicon.ico already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:41:57 +00:00
bae22e553e A refresh someone asks for reads the feeds in full (#77)
Check every feed, a feed's refresh, pull to refresh and ipx fetch --force
all send force, which only skipped the not-due wait: the request still
carried the stored ETag and Last-Modified, so an unchanged feed answered
304 and was not read. anil-dash got no site icon from a refresh for this
reason. A forced scan now drops the validators; the scheduled scan keeps
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:47:08 +00:00
2004da3459 Feeds from before site icons get one, without waiting for a post (#73)
The icon lookup ran only when a scan got the feed's body, and most feeds
answer 304 to their stored validators, so anildash.com and 68 others
stayed blank until their next post. A feed whose image was never looked
for is now refetched once without validators, as an empty one already
was. A miss is stored as "" (drawn as no art), so neither the refetch nor
the site lookup repeats on every scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:27:00 +00:00
c6980c355d Adding a site's address subscribes to the feed it links (#71, #72)
Both add paths, the CLI's and the web's, look behind the URL first: a web
page that names its feed with <link rel="alternate"> is swapped for that
feed, before the duplicate check so it finds a feed someone already has.
Before, the page itself was added and every scan failed on it.

alternate_feed_link found tags in a to_lowercase() copy and sliced the
original at those offsets; Unicode lowercasing changes some characters'
length, so a page with one before its <link> tags lost the href or
panicked off a char boundary. ASCII lowercasing keeps offsets aligned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:21:23 +00:00
ce221cee18 A feed with no artwork takes its site's icon (#70)
When a feed names no image and none is stored, the scan fetches the
channel's site link (RSS <link>, Atom rel=alternate) and uses the
apple-touch-icon or icon it names, falling back to /favicon.ico when that
answers with an image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:18:46 +00:00
f57f824535 Each browser keeps its own theme, in a cookie (#69)
The theme was kept on the account, so every browser signed in as the
same person got the same one: no Glass on the phone with Dracula on the
desktop. It is now the ipx_theme cookie (<theme>.<mode>), written by
theme.ts, and read by the server to draw the page in it from the first
frame as before. /api/me no longer reports or takes a theme, and
set_theme is gone.

A browser with no cookie yet is sent the theme the account kept, and
takes it as its cookie on that first load, so nobody loses their choice
in the move. users.theme and theme_mode are only read now, for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:27:20 +00:00
5be629427a /api/status, for Homepage's dashboard (#67)
The iPX tile on Homepage was a bare link: nothing in ipx gave a summary a
customapi widget could read. /api/status serves what `ipx status` prints
(feeds, items pending, files downloaded), from the same function the
control socket answers with, plus the version. It sits behind sign-in
like the rest of /api; Homepage sends the shared [web] token as the
ipx_token cookie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:10:05 +00:00
d8db785681 The tab's icon follows light and dark mode (#64)
The logo on the page switched with the mode (#63), but the tab's icon
was always favicon.png, the light logo. web/favicon-dark.png is
logo-dark.svg at 128px, served beside it, and the theme script points
the icon link at whichever matches data-mode, so it follows the theme
the account chose, not only the system. The sign-in page, with no
account, picks by the system's with two media-bound links.
/favicon.ico, which a browser asks for on its own, stays the light one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:12:11 +00:00
f9c9c2b7cc Modern in the logo's colours, and the logo in the page's mode (#63)
Modern's palette was sampled from the 2004 iPodderX icon: a neutral navy,
its screen blue and amber EQ bars. It now takes the new logo's colours,
the dark half from logo-dark.svg (navy ground, #8fc2ea scale, #ff6a1a
needle) and the light half from logo.svg (sky ground, #2f6aa0 scale, the
needle taken down to #c43e00 so white on it clears AA). The pending amber
and the error red moved apart from the needle's orange, and the sign-in
page's copy of the palette follows.

The pages always showed logo.svg, the light variant, even in a dark
theme; logo-dark.svg was never served. It is now, and the app and admin
pages show whichever matches data-mode, dark until the script says light,
as the palette is. The sign-in page, which has no account's theme, picks
by the system's with <picture>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:05:44 +00:00
4769a2ebe1 The logo beside the add-feed button, with the version on hover (#60)
The logo sat at the top of the feed list with "iPX" written beside it,
and the page showed the version nowhere. It is now in the top bar just
before the feed buttons, alone, and its tooltip names the app and its
version.

The version is filled in by the server as it sends the page, not by
build.mjs: build.rs reruns only when web/ or package-lock.json changes,
so a release that bumped only Cargo.toml would have kept the page naming
the one before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:45:35 +00:00
7796566dfc A logo drawn from the radio's screen, to app icon guidelines (#55)
The 2004 icon is a whole radio on a transparent background and not
square, so the tab icon was padded and iOS painted the home-screen icon
on white. web/logo.svg is the radio's screen alone, its tuning scale and
orange needle, laid out as Apple's app icon guidelines ask: opaque and
full-bleed (the system cuts its own corners), a gradient background and
flat foreground layers with hard edges, no highlights or shadows of its
own, nothing thin enough to vanish at 32px. Each layer is a <g>, ready to
split out for Icon Composer. web/logo-dark.svg is the same layers
recoloured.

favicon.png (128) and apple-touch-icon.png (180) are renders of it. The
pages show it from /logo.svg, served outside the auth layer for the
sign-in page, with an app icon's rounded corners. The 2004 icon stays at
/icon.png for anything outside that links to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:16 +00:00
bdda9b3d2e Block lists: words that hide items and keep them from downloading (#47)
Each person has a list for every feed they read and one per feed. An item whose title or text
holds one of the words, matched as whole words so "ai" does not hide everything that "said"
anything, is hidden from them and, since the scanner now keeps each subscriber's filters
separate, is fetched only if someone else still wants it.

Whole-word matching is not something LIKE can do on both SQLite and Postgres, so the matches are
worked out in Rust into a `hidden` table whenever a list changes, someone subscribes, or a scan
brings in new items, and the queries only look that table up. Both new tables are tables rather
than columns because create_missing adds tables but never columns. Hidden counts as read for
the reaper and for "others still want this file", since whoever it is hidden from is as done
with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:13:03 +00:00
c1187a7926 Verify Cloudflare Access's signed token before trusting the proxy
The proxy sign-in believed Cf-Access-Authenticated-User-Email from any
address in trusted_proxies. On Tower that address is the Docker gateway,
so any container there could name itself anyone (docs/sso.md said as
much, and CLAUDE.md listed it as a known gap).

With [web] access_team and access_aud set, a proxied request must also
carry a Cf-Access-Jwt-Assertion that verifies against Cloudflare's keys
(RS256 only, this application's audience, the team's issuer, not
expired), and the name comes from its email claim. The keys are fetched
at start and again when a token names an unseen key, at most once a
minute, so made-up key ids cannot make every request a request to
Cloudflare. While the keys cannot be had, proxied sign-in is refused;
password and token sign-in are unaffected. Both settings empty, nothing
changes.

jsonwebtoken does the checking, on the aws-lc-rs backend already in the
tree through rustls. Tests sign with throwaway keys in tests/data: a
valid token, another app's audience, expired, a forged signature, HS256,
alg none, the refetch limit, and keys that cannot be fetched. Checked
live on a scratch daemon: the header alone and a forged token got 401,
the admin token still signed in.

vouched_name takes the peer and headers rather than the request: a
&Request held across the new await made the auth middleware's future
unsendable, as a body is not Sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:38:25 +00:00
f1f605e180 Keep a Substack subtitle above the post
Substack puts a post's subtitle in <description> and leaves it out of
content:encoded, and body() took content:encoded alone, so the subtitle
was lost (a known gap in CLAUDE.md). A description is now shown above the
body, as <p><em>, when it is short plain text the body does not already
contain. Podcast feeds that repeat their notes in both, whole or cut short
with an ellipsis, are unchanged; the comparison is by words, since a tag
taken out of the body leaves stray spaces around punctuation.

Checked against Experimental History's feed (subtitles appear) and The
Daily's (notes in both fields, shown once). Entries are inserted with ON
CONFLICT DO NOTHING, so only posts first seen from now on get it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:29:50 +00:00
3625cf48fb Keep the web token out of the startup log
The daemon printed http://<bind>/?token=<token> at every start. The token
signs in as the admin, and in the container that line lands in docker
logs, readable by anyone with Docker access on Tower. It now says where
the token is kept instead; config.toml already has it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:27:06 +00:00
905dfa0b02 A spinner on the feed being checked, not toasts; check only your own feeds
The scan's events reach everyone, so every browser showed "<feed>: N new" and
"Scanning…" toasts, and refreshed, for everyone's feeds. Now a feed's row, and
its folder's, carries a spinner between feed_start and its done, skip or error;
the list refreshes only for the reader's own feeds; the scan toasts are gone, and
"Downloaded" is said only for a file on screen.

"Check every feed" from the web UI sent a scan of every feed on the server.
Command::Fetch takes an optional `feeds` list -- those feeds and the feeds
inside any OPML among them -- and the web fills it with the asker's
subscriptions. The schedule and the CLI send none, meaning every feed.

Closes #37.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:17:40 +00:00
1cafd8d6e3 Keep the feed catalogue and server settings in the database
Phase 3 of #18. Two tables: catalogue (each feed's config::Feed as JSON, so a
new feed setting needs no column) and settings (general: the five server
settings the admin page edits). config.toml keeps what is needed before the
database is reached, or decides who gets in: paths, [torrent], [web].

ipx still runs from one in-memory Config, assembled at start from both
(assemble_config). The eight places that saved config.toml and re-read it now
call Ctx::store_cfg, which writes the database and swaps the copy in memory; the
first-run web token, which is config.toml's, is written there.

The first start on a database with no catalogue imports config.toml's feeds and
settings in one transaction whose first insert is the settings row, so two ipx
starting at once cannot both import; it then trims config.toml, keeping the
original as config.toml.pre-database. After that, feeds written into the file are
ignored with a warning. copy-db skips it, and copies both tables.

Rehearsed on a clone of production's database with production's config: all 130
feeds imported, the file trimmed, and the feed list, settings and directory
identical to the live server's.

Postgres connections now ask for no notices. Every CREATE ... IF NOT EXISTS on an
existing table sends one, eleven per open; sqlx logs them, and
tracing-subscriber 0.3.23's per-layer filters then dropped the next line ipx
logged -- the import's own message went missing that way. Proved by toggling it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:28:49 +00:00
f09bb4a11c Revert pinned items rising to the top of their list
Sorting by the pin column, or the Pinned tab, was enough. order_sql loses its
pinned_first option, pinning no longer reloads the list, and the tests and
changelog line for #35 go. The NULLS FIRST/LAST ordering from the Postgres work
stays.

Closes #36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:08:37 +00:00
bc53e0f730 Postgres: pick the database by URL, copy-db, tests on both
- IPX_DATABASE_URL (postgres://...) picks the database; unset, it is the SQLite
  file as before. Passwords are taken out of anything logged.
- `ipx copy-db <state.db>` copies every table into the empty database the URL
  names, in one transaction, and moves the id counters past the copied ids. A
  copy of production went across in 14s with every count and column
  fingerprint identical.
- With IPX_TEST_DATABASE_URL set, each test gets a Postgres schema of its own;
  all 79 pass on both databases. Fixtures write booleans as true/false.
- Sorts say where an item with no value goes (NULLS FIRST going up, LAST going
  down): SQLite counts NULL as smallest, Postgres as largest, so "largest first"
  on Postgres led with every item that has no file. Tested on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:28:50 +00:00
611716d8b7 SeaORM: feeds and scanning; rusqlite gone
The last nineteen functions move to SeaORM: recording feeds, items and
enclosures, managed OPML feeds, folding WordPress's repeated files, and handing a
Patreon creator's files to its shows. Two SQLite-only forms go: GLOB becomes a
LIKE with the underscore escaped (broader, harmlessly: the fold still keys on
`_=` and digits), and UPDATE OR IGNORE becomes an UPDATE ... WHERE NOT EXISTS.
The two transactions are SeaORM transactions.

With nothing left on it, rusqlite goes, with the SQL schema and migrate(). The
entities are the schema: create_missing makes whatever tables and indexes a
database lacks, from them, with CREATE ... IF NOT EXISTS. Production's schema
already has every column migrate() added and none it dropped.

Not SeaORM's schema sync, used until now: despite its docs it drops a unique
index the entities do not describe, so it dropped users_name_lower on every open.
Every `ipx` command then took a write lock, and against a daemon busy writing,
`ipx status` -- the healthcheck -- failed 7 times in 15 where the old code
failed none. Now 15 in 15, as before. On Postgres it would not have started.

WAL is set only when a file is not already in it: setting it takes a lock that
cannot wait out a busy daemon.

Checked on copies of production: a forced scan of all 162 feeds against the real
feeds with no database errors; the feed list, filters, sorts, search and the
reaper's candidates against the old code on the same data, earlier in the
branch. The column comments from the SQL schema move to the entities.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:10:59 +00:00
a68bfb179b SeaORM: enclosures, downloads and the reaper
Twelve enclosure functions move to SeaORM: recording, the download queue,
marking done or failed, requeueing, and what the reaper may delete. INSERT OR
IGNORE becomes ON CONFLICT DO NOTHING; the reaper's read verdict is true or
false rather than 1 or 0, which Postgres would type as a 32-bit integer and
refuse to read as an i64; `read = 1` and `flagged = 1` test the booleans
themselves. retention::run and its callers (reap, rm, retire_group,
retire_stranded) become async.

The reaper deletes files, so it was checked on a copy of production against the
old SQL on the same file: all 2,195 candidates, identical and in the same order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:38:07 +00:00
6bf1ad6b31 SeaORM: items and read state
The item list, its counts, filters, sorts and search, positions, pins and
mark-all-read move to SeaORM, as SQL written for both databases:

- Parameters are gathered as the SQL is written (Args), so only what a
  statement uses is bound. rusqlite needed every one mentioned, hence the old
  `?1 IS NULL` and `?2 = ''`; Postgres refuses a parameter it cannot type.
- Yes/no columns are tested as booleans (NOT coalesce(s.read, false)) and
  written as true, not 1; SQLite reads true and false as 1 and 0.
- The last tiebreak of the sort is the guid, not SQLite's rowid, which Postgres
  lacks. Only items with the same date change places.
- set_position names entry_state.duration beside excluded.duration.
- The status callback on the control socket returns a future, as the counts
  are now a query.

Checked on a copy of production against the live server: 42 of 48 lists
identical; the other six differ only in how ties fall, or because the test
daemon cleared paths to files this machine does not have. Run on the same file,
every filter's count matches the old SQL exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:32:46 +00:00
d7f8f2df1d SeaORM: subscriptions and pins
Twelve subscription functions move to SeaORM. Lookups use the entity API; the
joins, counts and upserts are SQL written to run on both databases: $n
parameters, ON CONFLICT DO NOTHING in place of INSERT OR IGNORE, and
CASE WHEN on the yes/no column itself rather than comparing it to 1, which
Postgres would refuse for a boolean. INSERT ... SELECT ... ON CONFLICT gets a
WHERE true, which SQLite needs to tell the two apart.

Checked with a daemon on a copy of production: the feed list, read through the
new code, comes back with every feed and its settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:23:37 +00:00
4927677e66 SeaORM: accounts, sessions and themes
The fourteen user and session functions move from rusqlite to SeaORM and become
async; their callers await them (auth, admin_user, user_cmd, the account
handlers). Checked against a copy of production, where the yes/no columns are
still INTEGER: the admin flag reads back right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:17:52 +00:00
484aaa1849 SeaORM beside rusqlite: entities, schema sync, a second connection
The first step of moving to SeaORM (#18, phase 1). Nothing a user sees changes.

- src/entity.rs: the seven tables as SeaORM entities, matching the SQLite schema.
  Strings are Text, as the columns are; yes/no columns are bool, which is BOOLEAN
  on Postgres and stays INTEGER in the existing SQLite file (sync notes the
  difference and leaves it alone).
- Db holds a SeaORM connection to the same SQLite file beside the rusqlite one;
  functions move to it one at a time, and rusqlite goes with the last of them.
- db::sync creates what a database is missing from the entities (SeaORM's
  schema-sync, experimental, so sea-orm is pinned to ~2.0), plus the two indexes
  an entity cannot express. Checked against a copy of production: it added the
  lower(name) index and changed nothing else.
- Test databases are now built from the entities alone, in a temporary file
  (two connections to one ":memory:" are two databases), so every test also
  checks that the entities describe what the queries need. That caught the one
  difference: finding a user by name relied on COLLATE NOCASE, which Postgres
  lacks; it now compares lower() on both sides.
- rusqlite steps back to 0.39: 0.40's libsqlite3-sys is newer than sqlx accepts,
  and only one may link SQLite. It goes away at the end of this phase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:11:28 +00:00
c99e17bd80 A pinned item goes to the top of its list
order_sql takes pinned_first, which puts coalesce(s.flagged, 0) DESC ahead of
the chosen sort, so pins lead every list in whatever order is asked for and on
every page of it. Not when sorting by the pin column itself, where the direction
is the point, and not for Currently Listening. Pinning now asks for the list again
so the row moves at once, instead of redrawing it where it stood.

Closes #35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:51:38 +00:00
aeb686163b A separate admin page: server settings, accounts and the log
/admin, with Server, Accounts and Log sections chosen by the URL's hash. The
server sends the page and /admin.js to admins only (anyone else asking for the
page goes back to the app, and the script is 403), and removes the header's link
to it from everyone else's page rather than hiding it. The API keeps refusing
all of it to non-admins as before.

Settings becomes personal: theme, OPML import and export, and the schedule and
download folder to read. The server fields, the Users dialog and the Log dialog
move out of dialogs.ts into admin.ts.

The CSS moves out of index.html into web/app.css, which both pages load as
/app.css?v=<hash>, served immutable like the scripts. The smoke test checks both
pages.

Closes #19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:28:28 +00:00
2d158a4540 Pin a feed to the top of the feed list
subscriptions.pinned, per person, set by PATCH /api/feeds/{id} {pinned} and
returned as FeedRow.pinned. Kept out of Sub, which the scanner merges into its
policy; set_subscription names its columns, so saving a feed's settings leaves
the pin alone (tested).

Pinned feeds come first in the list, a pin before the name and a rule under the
block: a pinned folder with its feeds under it, a feed from inside one lifted out
of it. The pin button is on both the feed and the folder page.

Closes #33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:14:47 +00:00
9c16408d04 Keep the theme on the account, not in the browser
- users.theme and users.theme_mode, added by migrate(); GET /api/me returns them
  and PATCH /api/me saves them, refusing anything but a plain name and
  light/dark/auto, since index() writes them into the page's <html> tag.
- The page arrives with data-theme and data-choice already on <html> (and
  data-mode unless Auto), so it is drawn in the account's theme from the start.
- A theme a browser kept in localStorage goes up to the account once, the first
  time an account with none loads the page.
- Saves go one at a time, each with the choice as it stands: sent all at once, a
  quick run through the list could land out of order and keep a theme passed on
  the way. The browser test caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:33:59 +00:00
9b2537761f Ask for a post's images without a referrer
jeffgeerling.com answers 403 to an image request whose Referer is another
site, so his posts showed a broken image on iOS and the alt text on desktop.
The sanitiser now gives every <img> referrerpolicy="no-referrer".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:23:48 +00:00
fc09e8a6b7 Favicon, level file icons, and a feed error mark in the triangle's column
- The logo as favicon, squared up (it is 128x121), at /favicon.png and at
  /favicon.ico outside the auth layer, where a browser asking on its own got a
  401; an apple-touch-icon on white (#32).
- An item not yet downloaded had its download bar on a line of its own under the
  file icon, lifting the icon above its row's; the bar now sits under it without
  taking space (#31).
- A feed error is Font Awesome's exclamation, hung in the margin where a folder's
  triangle is, in the same column; a folder holding a failing feed has its
  triangle turn red.

Closes #31, #32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:09:38 +00:00
5483355021 Serve the script as /app.js, cached until a deploy changes it
The page loaded its script inline. It now names /app.js?v=<hash> (login.js for
the sign-in page), the hash of the script's contents: the script is served
immutable for a year and the page no-cache, so a browser fetches the script
again only when a deploy changes it and so its name.

Also fixes a race in the mark-everything-read test: it waited on a badge that
was seldom 0 to begin with, so a mark-unread still in flight could land after
the read-all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:03:25 +00:00
26802d2b23 The page's script is TypeScript in web/src, built and minified with swc
- web/src/*.ts: the script that was inline in index.html and login.html, split along its
  existing sections. Still one scope, concatenated in order, not modules.
- web/build.mjs strips the types, puts the script in the page and minifies it with swc;
  build.rs runs it into OUT_DIR and web.rs include_str!s the result. 137 KB -> 106 KB.
- npx tsc -p . type-checks web/src, loosely; the handful of annotations it needed
  change no behaviour.
- The Docker build installs node and swc (npm ci --omit=dev).
- Two list requests racing no longer let the older one win, and switching tabs clears
  the selection it closes, which made a browser test flaky.

Closes #23, #24.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:41:34 +00:00
d7a4a0b663 Fix the open bugs: read state, Unread tab, feed errors, theme button, log button, relative images
- Opening an item stays read: a list refresh that crossed with the write no longer
  puts the unread dot back (#16).
- On the Unread tab the item you were reading goes when you move to the next (#17).
- Feed errors mark the feed with a red ! instead of a toast per failure (#20).
- The theme is chosen in Settings only (#15).
- The server leaves the Log button out of a non-admin's page, so it no longer flashes (#29).
- Relative images and links in a post resolve against the post's link (#28).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:10:05 +00:00
0a83c716eb Time left and finished go by the length the player measured
A feed can be minutes out: ReThinking's gave 41:23 for a 43:48 file,
which read 0:08 left with 2:33 to play. The player's length is kept in
entry_state beside the position, per listener, where no scan can put
the feed's figure back, and preferred to the feed's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:39:40 +00:00
57a6fb5daa Currently Listening: finished is 90% played, not read
Opening an episode marks it read, so filtering on read hid every
episode anyone had started. The player now also reports the length it
measured, filling in one the feed left out. Fixes #14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:47:57 +00:00
49dafedbbc Inter, one file where WordPress listed two, and a pin heading on line
Inter (#11): the pages are set in Inter's variable font, served from the
binary at /inter.woff2 as the icon is, with its OFL licence beside it in
web/. Classic keeps Lucida Grande, the 2004 app's face.

Double audio (#12): WordPress numbers each audio player on a page by
adding ?_=N to its file's URL, so a post that embeds the file it encloses
listed it twice, and it was downloaded twice. The parser keeps the first
of an item's enclosures that differ only by that number. At startup the
repeats already stored fold into the first; where only the repeat had
been downloaded its file moves to the first rather than being deleted.

Pin heading (#13): the rows' icon buttons kept the browser's side
padding, which pushed their 16px icon 3px right of centre, and the
heading's icon sat at the left of its column. Both are centred now, and
the heading row takes the pixel of border the rows have, so every
heading sits over its column.

Closes #11, closes #12, closes #13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:31:03 +00:00
b83523fc92 Directory: let an admin give a blog its category
Almost no blog names a category the Directory can use, so a feed can
carry one of its own in config.toml, set by an admin in the feed's
settings and used when the feed names none. The feed's own iTunes
category still wins. The field offers the categories the Directory
already shows, so a blog about games joins Games rather than starting a
second chip. Setting it on a feed from an OPML promotes it to config, as
any other shared setting does.

Closes #10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:19:59 +00:00
8ae5c332c3 Pin, not flag
Keeping an item is pinning it now: a thumbtack where the flag was, and
Pin, Pinned and Unpin where Keep, Kept and Stop keeping were, on the
toolbar, the item's own buttons, the filter tab, the table column, the
retention hint and the warning before deleting a shared file. Pinned is
the solid thumbtack and not pinned the same shape outlined, as the flag
had its regular and solid pair. The API and database keep `flagged`.

The icon test compared glyphs by their path alone, which the two pins
share; it compares the whole glyph now.

Closes #9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:14:59 +00:00
4314b11237 Retire davewiner's 922 rows, without taking the ones people still read
davewiner's OPML left config.toml before retire_group existed, so its
derived rows were skipped by every scan but never cleared. At startup the
daemon now retires every group whose parent is gone from config. And
retire_group unmanages a feed that has its own config entry instead of
dropping it: eleven of davewiner's were promoted without being unmanaged,
and dropping them as derived would have deleted their entries. That also
covers removing an OPML or Patreon subscription from the page.

Closes #3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:01:27 +00:00
b8d22904f1 Read a title's HTML entities as the characters they stand for
An Atom title of type="html", and an RSS title in CDATA, reach the parser
with their entities intact, so The Verge's "Meta&#8217;s" showed as typed:
55 stored titles across 17 feeds. Titles are decoded one entity at a time
with quick-xml's HTML5 table, leaving an & that starts none ("Q&A") alone
rather than failing the whole title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:46:47 +00:00
017cfd7e28 Directory: file a show under its iTunes subcategory where it has one
Apple puts every tabletop and gaming show under Leisure, so the top level
alone put most of this server's podcasts behind one chip. Games says what
they are; a show with no subcategory keeps its top-level one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:20:49 +00:00
954173cacf Directory: chips by kind and category over a grid of cover art
Feeds take their channel's first <itunes:category> into a new feeds.category
column; the migration drops ETag and Last-Modified once so every feed re-reads
on its normal schedule and picks one up. /api/popular and /api/directory carry
category and podcast (any audio or video enclosure). Directory becomes a grid of
cover-art tiles under a chip rail: All, Podcasts, Blogs, and a podcast's
categories once Podcasts is picked. Popular and Add a feed keep their rows.

Closes #4, closes #5, closes #6, closes #7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:09:47 +00:00
afbe6367cc Say what a site sent when it is not XML at all
doghouse answers 200 with "Unable to establish a DB connection", and parse()
reported two parser errors about end of input that buried it. A body that does
not start with < now reports its first line, and explain_failure flags it as
the publisher's problem. Malformed XML keeps the parsers' errors.

Fixes #2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZbKERNSt4vQfyGV8rvkqp
2026-09-14 21:33:13 +00:00
a30edff248 Release 0.5.4: remembered view, Auto theme, Currently Listening
- Remember the feed/place and tab across a reload or new visit; an
  unknown or unsubscribed one lands on All Subscriptions instead of the
  first feed alphabetically.
- Add an Auto theme that follows the system's light/dark setting, and
  move Dark/Light/Classic/Auto into Settings as a dropdown alongside the
  header button's toggle.
- Add Currently Listening below Popular: episodes started and not
  finished, across every subscribed feed, one tap to resume. Reuses the
  existing entries/filter machinery (Filter::InProgress) rather than a
  new endpoint.
- Likely fix for the iOS bug where the topbar stopped responding to taps
  until a hard refresh (100vh -> 100dvh); unverified on a real device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
2026-09-14 15:38:03 +00:00