Commit Graph

123 Commits

Author SHA1 Message Date
43acc62259 List feeds in the Directory before anyone subscribes, and clear out dead ones (#107)
The Directory showed a catalogue feed only once someone subscribed, and a feed left the
catalogue with its last subscriber, so nothing could be put there for others to find.

- A feed has a listed flag, set by ipx add --list (with --category for the Directory's chip).
  The web page keeps a listed feed in the catalogue when its last subscriber leaves.
- The Directory lists every catalogue feed; Popular still only what people subscribe to.
  popular() reads titles, artwork and categories through Db::feed_list, not three queries a
  feed. Subscribing from the Directory scans the feed at once.
- A feed nobody subscribes to is checked once a day at most.
- clean_directory, in the sweep before each scan, removes from the catalogue and the database
  a feed nobody subscribes to, with no file on disk and not from an OPML, that has failed for
  30 days or published nothing in a year. Run against production first: it removes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:13:13 +00:00
ef5bdb4244 Say what guards the web UI, and warn only without Cloudflare Access (#106)
Every start logged WARN "web ui is reachable off this machine; the token is all that guards
it". A container has to bind 0.0.0.0 for its port to be published, so it fired on every start
of production, and it was out of date: signing in takes an account's password or the admin
token, and through the tunnel Cloudflare Access. It was the only warning in a healthy log. Now
it names what guards it, at info when Access is configured and a warning otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:46:43 +00:00
c7f13eea2f Send a changed feed's row to the page instead of it reloading the list (#105)
After a feed was checked, failed or downloaded a file, and after every item read, the page
fetched /api/feeds whole, about 60 ms for 160 rows, though one row had changed. The live event
stream now knows who is connected and, after an event that changes a feed, sends that person
its row (feed_row), built by the same code as the list (feed_rows, with Db::feed_list asked for
one feed). Marking an item read answers with the feed's row. The page puts the row in place
and redraws once a frame. A routine skip of a feed not due sends nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:41:43 +00:00
7a134b810f Fetch feeds six ahead in a scan; reload the list only after a scan that checked something (#103, #104)
Fetching was 65-90% of a scan, each feed waiting for the one before: 13 s of fetches in a 20 s
refresh of 32 feeds. The scan now works out which feeds are due, fetches their bodies up to six
ahead in tasks of their own, and handles each in order as before, so database writes,
downloads and OPML syncs stay one at a time. A Patreon creator still fetches in scan_one.

The page reloaded /api/feeds, and /api/settings with it, on every scan_done: the scheduler
scans every minute, so each open page reloaded the list once a minute, 169 times an hour. It
now reloads only when the scan checked a feed, and asks for settings once, on first load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:27:51 +00:00
79bc006a30 Add only what is a feed or links one; refuse the rest (#102)
Adding an address looked for the feed a web page links and, finding none, added the address
as it was: every check then failed, and the sidebar called it a feed that had moved. cnn.com
is one; its page links no feed. find_feed replaces feed_behind_page: the address is added if
it is a feed or an OPML list, the feed its page links if it is a web page that links one (and
that is a feed), and otherwise the add is refused with the reason, from the web page (400, the
dialog stays open) and from `ipx add`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:06:04 +00:00
9084b61bb6 Read an address typed without a scheme as https (#101)
cnn-com was added as 'cnn.com', stored as typed, and every check failed with "relative URL
without a base" before it reached the site to look for its feed. expand_input, which both the
web page and `ipx add` pass the address through, now makes one without a scheme https, and a
protocol-relative //host/path https too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:46:10 +00:00
0cc956b7c6 Forget a failing feed once nobody subscribes to it (#100)
Unsubscribing leaves a feed's row and history, which suits one that worked. One that never did
stayed with its error for good and was never scanned again: cnn-com, added as a bare 'cnn.com'
(#101), sat there failing with no subscriber. The reaper, before each scan, now deletes a feed
that is failing, has no subscriber, is not in the catalogue and has no file on disk, with its
items, file rows, read state and block list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:40:18 +00:00
db87bc0f42 Back off a failing feed exponentially, up to a day (#99)
A feed that failed was tried again on its usual schedule however long it had been failing:
gizmodo's 404, pelgrane's 403, daily-quests' 503 and toddstashwick's redirect loop every hour,
each a request to a site that had said no, a warning and scan time. A failing feed now waits as
long as it has been failing, from error_since to its last check, never less than its usual
interval and never more than a day: 1h, 1h, 2h, 4h, 8h, 16h, then daily on an hourly schedule.
No new column: error_since already marks the run's start and the first success clears it. A
forced refresh skips the due check, so it still tries at once. The feed list's next check
follows the backoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:33:57 +00:00
527777efdf A download limit means a show's newest episodes, not a pace (#97)
pending() took the newest files still pending, up to the limit, so once a show's latest three
were down, each full read of its feed took the three before them, working back through its
whole history. In production 4420 files (about 310 GB) were queued this way across 12 shows,
all on the default limit of 3, which is meant as "the latest three". It now takes only from the
feed's newest `limit` items with a file. 0, unlimited, still takes the whole back catalogue:
that is how the shows kept as an archive are set, along with limits of 100 and 10000.

The settings' wording followed the old behaviour ("The rest wait for the next scan"); the field
is now "Newest episodes to download", and says what 0 does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:50:11 +00:00
4a26c73b82 Clamp an unlimited download queue's LIMIT for Postgres (#98)
With max_new_per_check at 0 and no per-subscription limit, the budget is usize::MAX, and
pending() bound it `as i64`: -1. SQLite reads LIMIT -1 as no limit; Postgres refuses it, so a
feed's downloads failed. The new test fails with "LIMIT must not be negative" on Postgres
without the clamp and passes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:37:39 +00:00
57ab419718 Insert only a feed's new items and files on a scan (#96)
The spans added in 2799704 showed it: in a full scan of 134 feeds (trace da9a419b...,
2026-09-29 17:31, 315 s), storing items took 117 s, fetching 44 s and every other database call
about 2 s together. A scan inserted every item and file the feed listed, stored or not, one
round trip of about 10 ms each; Clarkesworld's 1200 items took 13 s. It now reads the feed's
stored guids and file URLs once (Db::stored_items) and inserts only the rest. A file URL not
among the feed's own may still be another feed's, so that one still goes to the insert, which
finds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:40:52 +00:00
2799704d30 Look at a feed's artwork only when it may have changed, and trace a scan's database work (#95, #96)
A feed read in full checked its own artwork and, without one, asked its website for an icon,
every time; a feed without validators is read in full every scan, so looking-for-group spent
2 s of every scan loading lfg.co's home page. Now the check runs when the feed names different
artwork from what is stored, or the scan was asked for, which keeps #80's point: a refresh
still picks up an icon the site changes or fixes.

Feed spans ran seconds past their fetch with nothing to say where (#96). The artwork lookup,
the loop that stores each item, and the per-feed database calls (feed_summary, record_feed,
subscribers, adopt, skipped_by_filter, rehide, pending) now have spans of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:27:31 +00:00
e8fd3fe9ed Load the feed list in five queries, not six per feed (#94)
GET /api/feeds called feed_summary, http_state, blocklist and unread_count for every feed:
about 950 round trips to Postgres for 160 feeds, 320 ms on every page load. Db::feed_list asks
for the feed rows, entry counts, download counts, the person's unread counts and block lists
once each, and the handler reads from that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:24:09 +00:00
448e557272 Trace ids, failure kinds and one line per event in the JSON log (#91)
From Dash0's structured logging guide, what applies here:

- Each JSON line inside a traced span ends with its trace_id and span_id, so a line in Loki leads
  to its trace in Tempo; the access log is written inside its request's span so it has one too.
  The JSON formatter takes no extra fields, so WithTrace appends them to the object it writes.
- A feed or download failure carries error.type (the HTTP status, or dns, redirect_loop,
  timeout, ...) and http.response.status_code, from failure_kind beside explain_failure, so
  failures group by kind without a regex over msg.
- Each event was logged twice: words under ipx::scan and fields under ipx::io. It is now one
  line under ipx::scan with both; the wire copy is at debug, for the admin page's Daemon I/O tab,
  and out of production's log. The healthcheck's status reply stays under ipx::io.
- The access log's ms is duration_ms. The dashboard and the prod-check skill follow.
- error fields are Display with the anyhow chain everywhere, not a mix of Debug and Display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:25:53 +00:00
36b16c7f5d Fetch artwork offered only over http for the https page (#90)
Through ipodderx.sdf1.net the page is https, the browser upgrades an http:// image to https,
and a host with no https, such as The Secret Cabal's CDN, answers nothing, so no artwork. On an
https page, the page now asks /api/art for those, and ipx fetches them. It only fetches an
address some feed or entry names as its artwork, and only an image, up to 5 MB, so the route
cannot be pointed at anything else on the network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:19:06 +00:00
928cbaf8f4 Show each feed's id labelled in ipx list (#82)
The id led the title's line unlabelled, so antirez.com's, "feed" with no title beside it, read
as a heading; 'ipx fetch antirez' was tried instead and failed. The title now heads the entry and
the id has its own row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:16:25 +00:00
0945f3a9f4 Remove ipx copy-db (#85)
It was the one-off copy from SQLite to Postgres (#18), run once on 2026-09-18. Production has run
on Postgres since; rolling back needs only the old state.db, which is kept, not this command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:15:51 +00:00
d4e00be085 A feed's own artwork has to be there before it is used (#89)
A feed's itunes:image or <image><url> was stored without being asked
for, so a dead one stood in the way of the site's icon. Ken and Robin
Talk About Stuff names http://kenandrobin.wpengine.com/.../kartas_podcast.png,
a 404, while its site's apple-touch-icon works. The feed's artwork now has
to answer as an image, as the site icon already did, when the feed is
read in full; otherwise the site's icon is looked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:11:16 +00:00
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