50 Commits

Author SHA1 Message Date
5bad68670a Release ipx 1.4.0, ipx-directory 1.3.0 and ipx-web 1.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:40:00 +00:00
d87d024096 CLAUDE.md: no test counts to go stale (#297)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:13:01 +00:00
44afc08b9d Search treats % and _ as themselves (#296)
The changelog lines for #292, #293 and #295 ride along in this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:58 +00:00
a21472730e A feed is read up to 50 MB (#295)
resp.bytes() read any size whole: an episode's MP3 given as a feed was
held in memory at every check. Feeds here are a few MB at most.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:55 +00:00
995ee05557 Others' downloads stay off your page (#294)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:55 +00:00
7af6153d18 A clean-up with the downloads folder gone forgets no file (#293)
missing_files took every path that did not exist() as deleted by hand.
With /downloads out of reach (the array stopped, shfs dropped) that is
every file, and each would have been marked reaped at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:52 +00:00
c1d8983ca1 Download addresses lose their query before they go out (#292)
Progress and DownloadDone went to every open page and the log with the
file's whole address; a Patreon file's carries its signature. Seen in
production's log for gcn-news-minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:48 +00:00
4c1b78408f An x in a search box clears it (#291)
Once something is typed: at the box's right end, inside it. One click
handler for every box, which clears it and fires its input event, so each
box's own handler brings its list back whole. The browser's own x, drawn
by some browsers and not others, gives way to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:55:25 +00:00
d850c02501 DeadAir's pause slider without its heading (#290)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:51:33 +00:00
363a5a122d A feed's address stays at the top of its settings (#290)
Asked for on the mockups: the address first, under the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:43:27 +00:00
3d9b45ddd7 Changelog: the redesign under the existing Changed (#290)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:38:22 +00:00
b54b47897b Keep a feed out of Signals, and a feed's settings in named parts (#289, #290)
hide_from_signals on a subscription, on an OPML or creator covering every
show in it, as hide_from_all does for All Subscriptions: the judge does
not put its items to Jev, and each Signal's list and counts leave them
out. shown_in() is the one SQL rule for both.

The settings dialog had grown one captioned setting at a time into a
single column, one part boxed and the rest not. It is in named parts now,
the name in a column beside the controls and above them on a phone, ruled
apart; All Subscriptions and Signals carry the sidebar's icons. The
address, the same for everyone, moves to the foot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:37:49 +00:00
b89c371662 Keep a feed, an OPML or a Patreon creator out of All Subscriptions (#288)
A switch in the feed's settings, In All Subscriptions, on its
subscription as hide_from_all. All Subscriptions' list and count leave
out a feed hidden on its own or through the OPML it is in, and the page's
unread count does the same. Signals read every feed as before, and
Currently Listening, which is All Subscriptions' in-progress filter, keeps
an episode under way whatever the feed's setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:19:54 +00:00
6daab6989e A list's trace says which list it was (#287)
The prod check of 2026-10-19 found /api/entries requests of 0.5-0.8 s
overnight beside others of 80-100 ms on an idle host, the time all in
entries_in, and nothing in the trace said which filter, sort or page
they were. The step now carries them, and whether a search, not its words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:05:50 +00:00
187729a278 ipx-web's spans are named for the request (#286)
Caddy named every span ipx-web, so Tempo's lists and trace view showed the
same name for each request. Caddy 2.11 takes placeholders in the span name:
now the method and path, as ipx names its own. The prod check's span list
shows a span's url.path when it has no route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:04:10 +00:00
3df7c974e8 prod check: slow requests as spans, with their route, laid beside Tower's load
Check 5 listed traces over 5 s, which were all page traces holding minutes of
live updates or audio, so a slow request never showed. query.py's new `spans`
lists the matching spans with their own time; check 5 asks for ipx requests
over a second, steps over half a second, and says to set the hours beside
Tower's load: on 2026-10-18, 120 of 130 slow requests were the host stalling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:59:20 +00:00
59aa00e07c Checks and tests run for the part a change is in
The pre-commit hook ran clippy on the whole workspace for any Rust change;
it now checks the crates touched and those built on them (ipx uses
ipx-directory, both use ipx-feed), and nothing for a change to the page
alone. CLAUDE.md says which tests go with which part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:50:58 +00:00
b4c0048f68 The HTML and Markdown choice drops the Markdown source view (#285)
Rendered Markdown is the one wanted beside the publisher's HTML. A browser
that had chosen the source view shows the HTML.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:45:18 +00:00
d5ffe5d042 Switch an item between its HTML and its Markdown (#285)
A feed that sends <source:markdown> beside its HTML (Scripting News) has its
Markdown kept in entries.markdown, added to existing databases by an ALTER.
A stored item gets it when a scan finds it told differently, the Markdown
counting as a change, the way corrections arrive (#141). The API sends it as
written and made HTML, cleaned by ammonia like the description.

The item's page has a choice between its meta line and its buttons: HTML,
Markdown or Markdown source, remembered in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:39:16 +00:00
d16460267f Changelog: #284 reaches stored items too, on the feed's next whole read
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:33:32 +00:00
21c786f48a Artwork without a scan of entries, and the Directory's artwork let through (#272, #273)
/api/art asked whether a feed or item names the image before anything else,
cache hits included, and entries.image had no index: a sequential scan of
entries, 571 ms on production, 50 at once for a page of images. A kept image
is served first now, since it was only kept once let through, and entries.image
has an index: hash on Postgres, whose btree refuses keys over about 2.7 KB.

The Directory lists podcasts nobody here subscribes to, and their artwork was
refused, so the page drew initials. ipx remembers the images the directory's
listings and pages carry and lets those through too; any other address is
still refused, so /api/art cannot be used to fetch arbitrary URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:31:20 +00:00
3a8d564eb7 Items written in Markdown show formatted (#284)
A description with Markdown and no HTML tag is rendered to HTML with
pulldown-cmark, and an item with no text but RSS's <source:markdown>
(https://source.scripting.com/, as Scripting News sends beside its HTML) uses
that. HTML is left alone, and plain text without Markdown in it stays as it
was, so a line like "1984. ..." is not made a list. ammonia cleans the result
as any feed's HTML. Stored items keep what they have: the scanner does not
rewrite an item it has.

Reference: http://scripting.com/2026/10/06/005559.html

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:05:05 +00:00
f9246129f2 rustfmt on every edit, clippy before every commit touching Rust
A Claude Code hook formats each .rs file as it is edited, and the pre-commit
hook runs clippy as well as the format check. Clippy only checks, so with the
line-tables profile it takes under a minute (57 s after a change) at load 8.
rust-analyzer, which gave the same errors as clippy for 2.8 GB of memory, is
no longer used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:55:33 +00:00
5cf7a28c44 An OPML's page lists its shows' items together, not the shows (#283)
GET /api/feeds/{id}/entries for an OPML or a Patreon creator now lists the
items of the shows in it the person subscribes to, as All Subscriptions does
every feed's. The shows are looked up first and queried by list: an OR with a
subquery, or an IN over one, made a single feed's page a sequential scan of
entries (clarkesworld 10 ms to 26 ms or a second, EXPLAIN on production), so a
single feed keeps its `feed_id = $1`.

The page draws the OPML with its own header and the item list, tabs, search
and sort of any feed, with the feed column showing which show each is from.
A show's scan or change refreshes its OPML's list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:41:38 +00:00
ac3808fe97 Build with line tables only: a test build no longer runs Tower out of memory
Linking ipx's 431 MB test binary took Tower, 31 GB with no swap and 4 GB free
beside its containers, into page-fault thrashing for 54 minutes: load 160 with
the CPU idle and 130 processes blocked. Line tables for our crates and no debug
info for dependencies make it 109 MB, built in 2m14s with load at 20 and 6.5 GB
left. CLAUDE.md says to watch memory beside the load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:41:38 +00:00
341d880e13 cargo-deny and cargo-machete: four advisories fixed, three unused dependencies gone (#281, #282)
cargo deny check found 10 RustSec advisories in Cargo.lock: ammonia
4.1.4 (three, XSS via SVG animation tags among them), rustls 0.23.44
(five), h2 0.4.15 (one) and ringbuf 0.4.8 (one). cargo update took ammonia
to 4.2.4, rustls to 0.23.46 and h2 to 0.4.20. ringbuf's fix is 0.5, which
librqbit 9.0.1 (the newest) cannot take; its double free needs an element
whose Drop panics and librqbit-utp's buffer holds u8, so deny.toml ignores
it with that reason.

deny.toml allows permissive licences only, crates.io only, and no
wildcard versions; ipx and ipx-directory say publish = false, as feed did.
cargo machete found crates/ipx still listing atom_syndication, quick-xml
and rss, which only crates/feed uses since parsing moved there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:29:01 +00:00
cb4407b19d CLAUDE.md: the containers resolve through 1.1.1.1, not NextDNS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:07:56 +00:00
7f61250852 Keep is a button on the show's page, a plus, not a switch in its settings (#280)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:02:55 +00:00
c90a66a24f Clippy clean, and rustfmt and clippy part of the checks
cargo clippy --workspace --all-targets had 24 warnings; its own fixes took
most, the rest by hand: two type aliases for long types, a struct built
whole, a checked division, a doc comment's stray blank line. Its fix for
the stale-feed fold was wrong: `any` stops at the first feed it removes,
where the fold removed every one, so that is a loop instead.

A pre-commit hook in .githooks refuses unformatted Rust; clippy, being a
build, is in CLAUDE.md's checks with -D warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:59:37 +00:00
692a510217 Format the Rust code with rustfmt
The code was formatted by hand, and `cargo fmt --check` differed in 866
places. rustfmt.toml keeps the house's long lines (120) and short
expressions on one line, which halves what changes; this commit is only
what rustfmt does with it, and is named in .git-blame-ignore-revs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:55:41 +00:00
2c4ba7896e Keep a show from an OPML when the OPML drops it (#280)
A show inside a subscribed OPML went when the OPML stopped listing it
(orphaned only if it had downloads), and went for you when you left the
OPML. Keep, in its settings, is a per-person flag on the subscription:
when the OPML drops a kept show, or nobody subscribes to the OPML any more,
it becomes a catalogue entry outside the OPML, taking the OPML's settings
while it is there, and stays on the lists of those who kept it. Whoever had
it only through the OPML loses it as before. Leaving the OPML yourself
leaves what you kept on your list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:37:01 +00:00
36a820f283 Artwork without an image type is known by its first bytes (#279)
Spotify's CloudFront serves some covers as application/octet-stream and
pippa.io sends no Content-Type at all; both are JPEGs, which browsers sniff
and show, but /api/art refused them with a 502 and the podcast showed its
initials. An untyped or octet-stream answer is now read and checked for
JPEG, PNG, GIF or WebP magic; anything else, SVG included, is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:26:01 +00:00
41fc2e3933 Dashboard: per-person Signals panels leave out counts from before #278
They had no person, and drew as " ( key)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:31:02 +00:00
4eba746ba4 Signals' metrics by person and whose key paid (#278)
With the server paying for everyone's Signals, its bill had no names.
ipx_signals_judged_total, ipx_signals_hits_total and ipx_jev_tokens_total
now carry user (the account's name) and key (server or own); a call
shared on the server's key is split by each person's share of its
questions. Icon picks bill the person on the server's key, key checks on
their own. The dashboard has tokens and items by person and a Who used
what table over its range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:26:50 +00:00
ce78599ab4 Signals: calls to TypeSafe paced under its rate limits, a 429 honoured, and the wait traced, counted and drawn (#277)
TypeSafe allows 80 requests and 100,000 input tokens a second. Six calls
in flight was a cap on concurrency, not rate: at 74 ms a call it is 81 a
second. Every call to Jev now takes a turn, a 60th of a second or its
guessed tokens' share of 80,000, so up to ten can be in flight. A 429
holds every call back for Retry-After and ends the pass. Each jev span
records paced_ms, /metrics ipx_jev_paced_seconds_total and a rate_limited
result, and the dashboard draws calls and tokens a second against the
limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:38:20 +00:00
5130f754cc Analyse entries once when entries_recent has no statistics (#274)
Deployed, the judge's query still read every entry: an expression index
has no statistics until its table is analysed, and Postgres, guessing a
third of entries were recent, chose a scan (898 ms) over entries_recent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:17:56 +00:00
2077ea5810 Signals: index the judge's query, six calls at once, one call per item on the server's key, tokens counted, a week's look-back an admin sets, edits that ask again only what was picked out (#274, #275, #276)
The judge's query read all of entries (605 MB, 566 ms) on every wake; an
index on each feed's items by date, as the query asks, and the rechecks
in a half of their own. Calls ran one at a time, about 150 ms each, so a
new Signal's month of about 8,000 items took twenty minutes: six in flight
now. On the server's key everyone's Signals on an item share one call.
TypeSafe's token counts go to /metrics and each jev span.

An item's text is cut at 1,000 characters instead of 2,000: on 55 real
items of eight podcast feeds and six Signals, on the development key,
1,000 changed none of 330 verdicts (nor did the same request twice) and
saved 12% of the input tokens, 1,266 to 1,112 a call; 500 changed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:13:25 +00:00
026fbd1622 Docs and site: who pays for Signals, and Signals in the API reference (#271, #269)
docs/signals.md says what the admin's setting does and that a key is
checked before it is saved; the install guide lists TYPESAFE_KEY; the API
reference has Signals' routes and types and the new fields of Me and
Settings; the home page says an admin may provide the key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:40:09 +00:00
1aef6ba421 Browser suite: the server's TypeSafe key is the shell's, and Signals' tests run only when Signals change (#271)
/src/.envrc's TYPESAFE_KEY is a development key, so the test daemon uses it
and the server paying for Signals is tested end to end. Signals' tests spend
it, so each runs only when the Signals code or the test itself has changed
since it last passed (a hash kept under target/signals-tests);
IPX_SIGNALS_TESTS=1 runs them anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:27:41 +00:00
9a41641eaa Signals: an admin chooses whether the server's TypeSafe key pays for everyone (#271)
With signals_on_server set and TYPESAFE_KEY present, the judge asks Jev
about everyone's Signals on the server's key; otherwise each person turns
Signals on in Settings with a key of their own, which is now checked with
TypeSafe before it is saved. The Signals folder shows only when they are on.

The browser suite's daemon inherited the shell's TYPESAFE_KEY, so fixture
feeds were sent to the real TypeSafe to be categorised; it is blanked now,
and an empty key counts as none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:18:03 +00:00
33eb1f4db2 Signals in /metrics and on the dashboard; failed Jev calls are failed spans (#270)
The judge had spans but nothing in Prometheus: no count of items judged or
of how calls to Jev ended, nor whether the judge was still passing. The jev
span recorded an error field without the span's status, so Tempo's failed
traces left Jev out, and a timeout recorded nothing. Calls now send
traceparent like every other call out of ipx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:55:37 +00:00
3b48f51a56 Home page: the Signals key sentence in the owner's words (#254)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:15:04 +00:00
55b0192df5 Home page: the Signals key sentence reads plainly (#254)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:08:21 +00:00
e07d27d9f2 Home page: no account section, and its screenshot gone (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:06:44 +00:00
3532710b3d Home page: no What it does heading; Signals and DeadAir copy in the radio's voice (#216, #254)
Tune out the noise and All talk, no dead air, each a radio idiom the dial
at the top already sets up, written with /brand-naming-strategies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:04:24 +00:00
b56c97259d Home page: the features zigzag, each picture centred in its column (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:00:24 +00:00
1025416181 Home page: a screenshot for Signals in place of the drawing (#254)
The drawn list's made-up titles read badly. The screenshot is a Signal
called Space on a scratch iPX on eight public feeds, with a stand-in
for Jev matching the Signal's words in titles, in Glass as the other
shots are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:53:49 +00:00
894e5e4711 Home page: the noise bars' widths in the stylesheet (#254)
The site's CSP is style-src 'self', which refuses style attributes, so
the bars would all have drawn at full width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:48:14 +00:00
0e21195e8d Versions after ipx 1.3.0, ipx-web 1.1.0 and ipx-directory 1.2.0: -dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:32 +00:00
3b41abd860 Home page: a section on Signals (#254)
Drawn in CSS like DeadAir's: the noise fades and the three items about
the words listened for stay lit. No screenshot, since one would need a
TypeSafe key on the scratch server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:37:34 +00:00
71 changed files with 4015 additions and 1509 deletions

View File

@@ -5,5 +5,18 @@
"Bash(env)",
"mcp__outline__read_document"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.rs) rustfmt --edition 2024 \"$f\" 2>/dev/null || true;; esac"
}
]
}
]
}
}

View File

@@ -26,6 +26,7 @@ Q=.claude/skills/ipx-prod-check/query.py
$Q logs '<LogQL>' [since] # lines, newest first (500 at most)
$Q metric '<LogQL metric>' [since] # $range becomes `since`
$Q traces '<TraceQL>' [since] # slowest first
$Q spans '<TraceQL>' [since] # the matching spans, each with its own time, slowest first
$Q trace <trace id> # one trace as a tree, with its log lines
$Q prom '<PromQL>' [since] # Prometheus, now; $range becomes `since`
```
@@ -109,6 +110,10 @@ from 2026-10-11 15:15 UTC. All gauges:
| `ipx_feed_retries` | feeds waiting to retry a failed name lookup |
| `ipx_art_cache_bytes`, `ipx_art_cache_limit_bytes` | artwork kept, as of the last sweep, and the most kept: kept sits at the limit by design |
| `ipx_art_evicted_total`, `ipx_art_requests_total{result="hit"\|"miss"}` | counters: images the sweep threw out to stay under the limit, and artwork served from disk or fetched from the publisher; many evicted at every sweep with a falling hit rate means the limit is too small |
| `ipx_signals_judged_total{user,key}`, `ipx_signals_hits_total{user,key}` | counters: items Jev judged for Signals, and Signals they fit, by person and whose key paid (`server` or `own`, #278) (#254) |
| `ipx_jev_requests_total{result="ok"\|"key_refused"\|"malformed"\|"rate_limited"\|"error"}`, `ipx_jev_seconds_total`, `ipx_jev_paced_seconds_total` | counters: calls to TypeSafe's Jev, Signals' and icons', by how they ended; their seconds, the wait for a turn included; and that wait alone. `malformed` is ipx's request at fault (#257); `rate_limited` is a 429 from TypeSafe, whose limits are 80 calls and 100,000 input tokens a second, which ipx paces itself under at 60 and 80,000 (#277) |
| `ipx_jev_tokens_total{kind="input"\|"output",user,key}` | counter: tokens TypeSafe counted, as its answers say, by person and whose key paid: what Signals, icon picks and key checks cost (#275, #278). A call shared on the server's key is split by each person's share of its questions |
| `ipx_signals_last_pass_age_seconds`, `ipx_signals_keys_refused` | since the Signals judge last finished a pass (at most 5 minutes when well; absent until one has); people whose key TypeSafe refused |
| `directory_process_resident_bytes`, `directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`, `directory_uptime_seconds`, `directory_build_info` | the directory's, `job="ipx-directory"`, from its `metrics_bind` (production `192.168.1.130:9188`, LAN only) |
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
@@ -155,18 +160,32 @@ a count says something happened, the lines and traces say why.
`feeds` above 0; none at all for over 10 minutes means the worker is stuck. And `pending` is
the real queue (#113): files a scan will download on its own. Back-catalogue files are
`held`, listed but not counted, so it is usually 0 or a handful.
5. **Slow and failed traces.** All three services' (filter by `resource.service.name` for one).
5. **Slow and failed traces.** Look at ipx's requests as spans, not traces: a trace started by a
tap in the page also holds that page's live updates and any audio it streamed, minutes long,
so a list of traces over a few seconds is nothing but those. `spans` lists each matching span
with its own time and route, slowest first:
```
$Q traces '{resource.deployment.environment.name="production" && duration > 5s}'
R='resource.deployment.environment.name="production" && resource.service.name="ipx"'
$Q spans "{$R && span.http.route != \"\" && span.http.route != \"/api/events\" && span.http.route !~ \"/media/.*\" && duration > 1s} | select(span.http.route)"
$Q spans "{$R && name=\"step\" && duration > 500ms} | select(span.of)"
$Q traces '{resource.deployment.environment.name="production" && status = error}'
$Q trace <id>
```
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
`feed` or `fetch` span holding most of it, or a `download` far slower than its size explains.
A web request over a second is worth a look; the trace shows whether the time is in the
handler or a scan it waited on.
Group the first by hour and by route (`awk '{print substr($3,1,2), $NF}' | sort | uniq -c`),
then lay the hours beside Tower's (the Tower section): load over 100 with processes blocked
and memory under 4 GB is the host stalling, and every request of those minutes is slow with
it, ipx's queries included. On 2026-10-18 that was 120 of 130 requests over a second, up to 43 s.
What is left at quiet hours is ipx's own: open its trace and see which step holds the time. A
request far longer than its steps added up waited outside them, for a connection or a thread.
The second line finds steps over half a second wherever they ran; a step slow only once, first
thing after hours idle, is Postgres reading from disk what it no longer had in memory (All
Subscriptions' `entries_in`, a second where it is 60 ms warm).
Steps are `step` spans named by `of` (#187): an item list's `entries_in`, `sanitize`,
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
`feed` or `fetch` span holding most of it (`$Q spans '{... && name="feed" && duration > 10s}'`),
or a `download` far slower than its size explains.
Steps are `step` spans named by `of` (#187): an item list's `entries_in` (with the list asked for: `filter`, `sort`, `dir`, `offset`, `limit`, `search`), `sanitize`,
`deadair`, `count_in`; the feed list's `catalogue`, `feed_list` and its queries; a `reap`'s
`missing_files`, `prune_abandoned_failures`, `reap_candidates`, `clean_directory` (once a day
since #193, so a reap is about a second), `trim_artwork`, with what it removed on the `reap`
@@ -194,6 +213,8 @@ a count says something happened, the lines and traces say why.
$Q prom 'max(max_over_time(ipx_http_requests_in_flight{job="ipx"}[$range]))' 24h
$Q prom 'sum(increase(ipx_events_missed_total{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_deadair_busy_seconds{job="ipx"}[$range]))' 24h
$Q prom 'sum by (result) (increase(ipx_jev_requests_total{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_signals_last_pass_age_seconds{job="ipx"}[$range]))' 24h
```
`up` 0 means Prometheus cannot reach the port: the container is down or `metrics_bind` is
unset. The daemon keeps all 20 connections open on Postgres (#241), so `idle` is the pool's 20 less
@@ -210,6 +231,9 @@ a count says something happened, the lines and traces say why.
where the worker's time went (reap was most of it until #193). Web requests in flight near
the pool's max mean the pool is what they wait on. Live updates missed mean a page showed stale
counts until reloaded. DeadAir over a minute on one file is a file ffmpeg chokes on.
Any `rate_limited` Jev call means the pacing is not holding ipx under TypeSafe's limits (#277); each
`jev` span has `paced_ms`, its wait for a turn. Any `malformed` Jev call is ipx asking wrongly (#257); `key_refused` is someone's key; the Signals
judge over 10 minutes since a pass is stuck on Jev: its `signals` trace shows which `jev` call.
7. **What the page saw.** The browser's own errors and timings, which no server line shows.
```
@@ -253,7 +277,7 @@ to see where the time went, Caddy's span with ipx's inside it. A slow page with
Caddy or the network; a long one is ipx.
```sh
$Q traces '{resource.service.name="ipx-web" && duration > 2s}'
$Q spans '{resource.service.name="ipx-web" && span.url.path != "/api/events" && span.url.path !~ "/media/.*" && duration > 2s} | select(span.url.path)'
$Q traces '{resource.service.name="ipx-web" && status = error}'
```

View File

@@ -4,6 +4,7 @@
query.py logs '<LogQL log query>' [since] lines, newest first
query.py metric '<LogQL metric query>' [since] one value per series; $range is `since`
query.py traces '<TraceQL query>' [since] matching traces, slowest first
query.py spans '<TraceQL query>' [since] the matching spans, each with its own time
query.py trace <trace id> one trace's spans, as a tree
query.py prom '<PromQL query>' [since] one value per series, now; $range is `since`
@@ -58,6 +59,21 @@ def main():
name = t.get("rootTraceName") or "(from the page)"
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {name}")
print(f"-- {len(traces)} trace(s)", file=sys.stderr)
elif kind == "spans":
# The spans that matched, each with its own time: a trace's length says nothing about a
# slow request in it when the trace also holds a page's live updates or an audio stream.
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=200&spss=20")
rows = []
for t in r.get("traces", []):
for ss in t.get("spanSets") or [t.get("spanSet") or {}]:
for sp in ss.get("spans", []):
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])}
what = attrs.get("http.route") or attrs.get("of") or attrs.get("url.path") or sp.get("name") or ""
rows.append((int(sp.get("durationNanos", 0)) / 1e6, int(sp["startTimeUnixNano"]), t["traceID"], what))
for ms, ts, tid, what in sorted(rows, reverse=True):
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(ts / 1e9))
print(f"{ms:>8.0f}ms {when}Z {tid} {what}")
print(f"-- {len(rows)} span(s)", file=sys.stderr)
elif kind == "prom":
# ipx's /metrics (#194), scraped as job ipx; through the network like the others.
r = fetch(f"http://prometheus:9090/api/v1/query?query={enc(q.replace('$range', since))}&time={now}")

3
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1,3 @@
# Formatting only: `git config blame.ignoreRevsFile .git-blame-ignore-revs` makes blame skip these.
# rustfmt adopted
692a5102179b958b5d15cecc4f5e6f16b2955b76

26
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/sh
# Refuses a commit whose Rust is not as rustfmt writes it, or that clippy warns about. Clippy only
# checks and never links, so it is light beside a test build (CLAUDE.md, Tests). It checks the
# crates the commit touches and those built on them, not the workspace: ipx uses ipx-directory,
# and both use ipx-feed. A commit to the page alone runs nothing here.
staged=$(git diff --cached --name-only --diff-filter=ACMD)
echo "$staged" | grep -q -e '\.rs$' -e 'Cargo\.toml$' -e '^Cargo\.lock$' || exit 0
if ! cargo fmt --all --check >/dev/null 2>&1; then
echo "pre-commit: the Rust is not formatted; run cargo fmt --all" >&2
exit 1
fi
if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then
pkgs="ipx-feed ipx-directory ipx"
elif echo "$staged" | grep -q '^crates/directory/'; then
pkgs="ipx-directory ipx"
elif echo "$staged" | grep -q '^crates/ipx/'; then
pkgs="ipx"
else
exit 0
fi
args=$(for p in $pkgs; do printf ' -p %s' "$p"; done)
# shellcheck disable=SC2086 # one word a flag
if ! cargo clippy $args --all-targets -q -- -D warnings; then
echo "pre-commit: clippy has warnings in $pkgs; fix them, or allow one where it is wrong, with a comment saying why" >&2
exit 1
fi

View File

@@ -86,6 +86,11 @@ Testing sign-in from Tower itself proves nothing about the tunnel: Tower's own r
published port as `192.168.1.130`, and Caddy rightly strips their `Cf-Access-*`. Send them from a
container on another network, as cloudflared's are (#209).
The three containers resolve names through Cloudflare (`dns: [1.1.1.1, 1.0.0.1]` in the compose
file), not the router: the LAN's DNS is NextDNS, which answers for a domain it filters with its own
block page, and ipx then logged a feed as failing with `invalid peer certificate: UnknownIssuer`
(tomrenner.com, 2026-10-17). Docker's own DNS still answers for the services' names first.
**Name the service.** A bare `up -d` recreates every container in the project, which once the
split lands means the frontend and directory too. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
@@ -171,13 +176,17 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
## Tests
```sh
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
cargo fmt --all --check # rustfmt, as rustfmt.toml says; the pre-commit hook refuses a commit without it
cargo clippy --workspace --all-targets -- -D warnings # no warnings, ever
cargo deny check # known vulnerabilities, licences, sources (deny.toml); reads Cargo.lock, builds nothing
cargo machete # dependencies a crate lists and does not use; now and then, it can be wrong
cargo test # parsing, filters, retention, schedules, SQL, per-user state
npx tsc -p . # type-checks web/src
node tests/page-smoke.js
node --test tests/page-unit.js # the page's own functions, called directly, in a second
node tests/native-bridge.js # the page hands playback to a native shell
node tests/contrast.js # every theme's palette against WCAG AA
npx playwright test # 66 browser tests against a real daemon on fixture feeds
npx playwright test # the browser tests, against a real daemon on fixture feeds
node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds
```
@@ -189,10 +198,34 @@ session was stopped. So:
run in the background beside another, and never two crates' builds at once. Do not tune `-j`.
* Look at the load before and after each (`cat /proc/loadavg`). Above about 24, Tower's twelve
threads twice over, wait for it to come down before starting another.
* Build only the crate the change is in (`cargo build -p ipx-directory`), and run only its tests
until the change is done; the whole workspace's once, at the end.
* Test only what the change is in. ipx uses ipx-directory, and both use ipx-feed, so:
| changed | run |
|---|---|
| `web/` alone | the three page checks; `npx playwright test -g '<what changed>'`; no cargo |
| `crates/ipx/` | `cargo test -p ipx`; the browser tests too if the API changed |
| `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx` |
| `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, then `-p ipx` |
| `Cargo.toml`, `Cargo.lock` | all three, one at a time |
The pre-commit hook's clippy follows the same table. The whole browser suite once, at the end
of a change to the page or the API; never `cargo test` for the workspace in one command.
* Builds and test binaries live under `target/`, a link to `/mnt/fast`; linking through
`/mnt/user` (FUSE) once took the load to 248 on its own.
* **Memory is the tighter limit.** Tower has 31 GB, no swap, and the containers leave about 4-7
GB. A build past that stalls everything on page faults: load 160 with the CPU idle and the
disks quiet, many processes blocked (`node_procs_blocked`, `node_vmstat_pgmajfault`). On
2026-10-18 linking a 431 MB test binary did it for 54 minutes. So `[profile.dev]` keeps only
line tables and dependencies no debug info; keep it that way, and look at `MemAvailable` beside
the load.
The Rust is formatted by rustfmt (`rustfmt.toml`) and kept free of clippy's warnings. Claude Code
formats each `.rs` file it edits (a hook in `.claude/settings.json`), and the pre-commit hook refuses
a commit touching Rust that is unformatted or has a clippy warning. A clone turns on the hook once: `git config core.hooksPath .githooks`; and `git config blame.ignoreRevsFile
.git-blame-ignore-revs` keeps blame past the commit that first formatted everything. A clippy
suggestion is not always the same code: on adopting it, `fold(false, |any, id| remove(id) || any)`
was offered as `any(...)`, which stops at the first and would have removed one feed of many. A lint
that is wrong for a line gets `#[allow(clippy::...)]` there with a comment saying why.
Things about the browser suite that have cost time:

79
Cargo.lock generated
View File

@@ -40,9 +40,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.4"
version = "4.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
checksum = "1f45dfa685e1ec33b1bf1a3b93b8047cbdbef41b6040deebbe6bea7720a45574"
dependencies = [
"cssparser",
"html5ever",
@@ -789,9 +789,9 @@ dependencies = [
[[package]]
name = "cssparser"
version = "0.37.0"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
dependencies = [
"dtoa-short",
"itoa",
@@ -1437,9 +1437,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
dependencies = [
"atomic-waker",
"bytes",
@@ -1555,12 +1555,13 @@ dependencies = [
[[package]]
name = "html5ever"
version = "0.39.0"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
dependencies = [
"log",
"markup5ever",
"memchr",
]
[[package]]
@@ -1859,12 +1860,11 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "ipx"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"ammonia",
"anyhow",
"argon2",
"atom_syndication",
"axum",
"chrono",
"clap",
@@ -1877,9 +1877,7 @@ dependencies = [
"opentelemetry",
"opml",
"percent-encoding",
"quick-xml 0.42.0",
"reqwest",
"rss",
"sea-orm",
"serde",
"serde_json",
@@ -1896,7 +1894,7 @@ dependencies = [
[[package]]
name = "ipx-directory"
version = "1.2.0"
version = "1.3.0"
dependencies = [
"ammonia",
"anyhow",
@@ -1933,6 +1931,7 @@ dependencies = [
"atom_syndication",
"chrono",
"opml",
"pulldown-cmark",
"quick-xml 0.42.0",
"reqwest",
"rss",
@@ -2526,9 +2525,9 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.39.0"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
dependencies = [
"log",
"tendril",
@@ -2985,9 +2984,9 @@ dependencies = [
[[package]]
name = "phf"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_shared",
"serde",
@@ -2995,9 +2994,9 @@ dependencies = [
[[package]]
name = "phf_codegen"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
dependencies = [
"phf_generator",
"phf_shared",
@@ -3005,9 +3004,9 @@ dependencies = [
[[package]]
name = "phf_generator"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared",
@@ -3015,9 +3014,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
dependencies = [
"siphasher",
]
@@ -3130,6 +3129,24 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags 2.13.1",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quanta"
version = "0.12.6"
@@ -3544,9 +3561,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.44"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"once_cell",
@@ -4279,9 +4296,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.9.0"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
dependencies = [
"new_debug_unreachable",
"parking_lot",
@@ -4291,9 +4308,9 @@ dependencies = [
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
dependencies = [
"phf_generator",
"phf_shared",
@@ -5070,9 +5087,9 @@ dependencies = [
[[package]]
name = "web_atoms"
version = "0.2.6"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
dependencies = [
"phf",
"phf_codegen",

View File

@@ -25,6 +25,7 @@ opentelemetry = { version = "0.33", default-features = false, features = ["trace
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client", "gzip-http"] }
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
opml = "1.1.6"
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
mimalloc = "0.1.52"
percent-encoding = "2.3.2"
quick-xml = { version = "0.42.0", features = ["escape-html"] }
@@ -42,3 +43,12 @@ tracing = "0.1.44"
tracing-opentelemetry = { version = "0.34", default-features = false }
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
url = "2.5.8"
# Backtraces keep file and line; the full debug info made a 431 MB test binary whose link ran
# Tower, which has no swap, out of memory and stalled it for most of an hour (2026-10-18).
[profile.dev]
debug = "line-tables-only"
# Nobody steps through dependencies.
[profile.dev.package."*"]
debug = false

View File

@@ -10,6 +10,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.3.0] - 2026-10-19
### Added
- A feed's items written in Markdown show formatted on its page, from a Markdown description or RSS's `<source:markdown>` (#284).
### Fixed
- A listed feed whose address serves something larger than 50 MB is refused as too large, instead of read whole into memory at every check (#295).
### Security
- ammonia, which cleans publishers' HTML, updated to 4.2.4 for three advisories, among them XSS through SVG animation tags; rustls to 0.23.46 for five and h2 to 0.4.20 for one (#281).
## [1.2.0] - 2026-10-16
### Added
@@ -58,7 +72,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.2.0...main
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.3.0...main
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.2.0...directory-v1.3.0
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.1...directory-v1.2.0
[1.1.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0

View File

@@ -1,7 +1,8 @@
[package]
name = "ipx-directory"
version = "1.2.0"
version = "1.3.0"
edition.workspace = true
publish = false
[[bin]]
name = "ipx-directory"

View File

@@ -96,8 +96,11 @@ async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<(
}
let result = dir.store.snapshot(INSTANCE, &entries).await?;
tracing::info!(
shows = ids.len(), accepted = result.accepted, refused_private = result.refused_private,
refused_blocked = result.refused_blocked, refused_invalid = result.refused_invalid,
shows = ids.len(),
accepted = result.accepted,
refused_private = result.refused_private,
refused_blocked = result.refused_blocked,
refused_invalid = result.refused_invalid,
"Apple Podcasts synced"
);
// Fetched now, not when the refresher next looks.
@@ -128,7 +131,11 @@ struct Genre {
fn categories(g: &Genres) -> BTreeMap<String, Vec<String>> {
g.0.get("26")
.map(|podcasts| {
podcasts.subgenres.values().map(|c| (c.name.clone(), c.subgenres.values().map(|s| s.name.clone()).collect())).collect()
podcasts
.subgenres
.values()
.map(|c| (c.name.clone(), c.subgenres.values().map(|s| s.name.clone()).collect()))
.collect()
})
.unwrap_or_default()
}
@@ -253,7 +260,8 @@ mod tests {
)
.unwrap();
assert_eq!(chart_ids(&chart), ["1200361736", "1028908750"]);
let one: Chart = serde_json::from_str(r#"{"feed": {"entry": {"id": {"attributes": {"im:id": "7"}}}}}"#).unwrap();
let one: Chart =
serde_json::from_str(r#"{"feed": {"entry": {"id": {"attributes": {"im:id": "7"}}}}}"#).unwrap();
assert_eq!(chart_ids(&one), ["7"], "a chart of one");
let empty: Chart = serde_json::from_str(r#"{"feed": {}}"#).unwrap();
assert!(chart_ids(&empty).is_empty());

View File

@@ -162,7 +162,10 @@ mod tests {
let cfg = Config::parse(GOOD).unwrap();
assert_eq!(cfg.bind, "0.0.0.0:8100");
let t = cfg.tokens().unwrap();
assert_eq!((t[0].name.as_str(), t[0].access, t[0].sha256.as_str()), ("tower", Access::Rwa, "a".repeat(64).as_str()));
assert_eq!(
(t[0].name.as_str(), t[0].access, t[0].sha256.as_str()),
("tower", Access::Rwa, "a".repeat(64).as_str())
);
assert_eq!(t[1].access, Access::Ro);
assert!(Access::Ro < Access::Rw && Access::Rw < Access::Rwa);
}
@@ -171,7 +174,9 @@ mod tests {
fn a_missing_or_misspelled_access_a_reused_name_or_a_bad_hash_is_refused() {
let h = "a".repeat(64);
assert!(Config::parse(&format!("[[tokens]]\nname = \"a\"\ntoken_sha256 = \"{h}\"")).is_err());
assert!(Config::parse(&format!("[[tokens]]\nname = \"a\"\naccess = \"admin\"\ntoken_sha256 = \"{h}\"")).is_err());
assert!(
Config::parse(&format!("[[tokens]]\nname = \"a\"\naccess = \"admin\"\ntoken_sha256 = \"{h}\"")).is_err()
);
assert!(Config::parse(&GOOD.replace("\"friend\"", "\"tower\"")).is_err());
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), "xyz")).unwrap().tokens().is_err());
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), &h)).unwrap().tokens().is_err());

View File

@@ -153,16 +153,16 @@ impl Directory {
fn caller(&self, presented: &str) -> Option<Me> {
let h = config::hash(presented);
// Every token compared, whether or not one matched, so timing says nothing either way.
self.tokens
.read()
.unwrap()
.iter()
.fold(None, |found, t| if config::same_secret(&t.sha256, &h) { Some(Me { name: t.name.clone(), access: t.access }) } else { found })
self.tokens.read().unwrap().iter().fold(None, |found, t| {
if config::same_secret(&t.sha256, &h) { Some(Me { name: t.name.clone(), access: t.access }) } else { found }
})
}
/// Fetches one feed and stores what it found.
pub async fn refresh(&self, due: &store::Due) -> anyhow::Result<()> {
let got = match ipx_feed::fetch(&self.client, &due.url, None, due.etag.as_deref(), due.last_modified.as_deref()).await {
let got = match ipx_feed::fetch(&self.client, &due.url, None, due.etag.as_deref(), due.last_modified.as_deref())
.await
{
Ok((ipx_feed::Fetched::NotModified, _)) => Refreshed::NotModified,
Ok((ipx_feed::Fetched::Body { bytes, etag, last_modified }, _)) => {
if ipx_feed::is_opml(&bytes) {
@@ -242,10 +242,14 @@ pub async fn reloader(dir: Shared, path: std::path::PathBuf) {
let n = tokens.len();
match dir.set_tokens(tokens).await {
Ok(dropped) => tracing::info!(tokens = n, dropped, "config read again"),
Err(e) => tracing::warn!(error = %format!("{e:#}"), "config read again, but old snapshots could not be dropped"),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "config read again, but old snapshots could not be dropped")
}
}
Err(e) => tracing::warn!(error = %format!("{e:#}"), "config changed but does not load; keeping the tokens as they were"),
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "config changed but does not load; keeping the tokens as they were")
}
}
}
}
@@ -342,7 +346,8 @@ async fn authenticate(State(dir): State<Shared>, mut req: Request, next: Next) -
.map(str::trim)
.filter(|t| !t.is_empty());
let Some(me) = presented.and_then(|t| dir.caller(t)) else {
return ApiError(StatusCode::UNAUTHORIZED, "a valid token is needed: Authorization: Bearer <token>".into()).into_response();
return ApiError(StatusCode::UNAUTHORIZED, "a valid token is needed: Authorization: Bearer <token>".into())
.into_response();
};
let who = Who(me.name.clone());
req.extensions_mut().insert(me);
@@ -387,7 +392,11 @@ async fn popular(State(dir): State<Shared>, Query(q): Query<Limit>) -> Result<Js
}
async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Result<Json<FeedPage>, ApiError> {
dir.store.feed(&id).await?.map(Json).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))
dir.store
.feed(&id)
.await?
.map(Json)
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))
}
async fn snapshot(
@@ -408,7 +417,11 @@ async fn snapshot(
Ok(Json(r))
}
async fn remove(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
async fn remove(
State(dir): State<Shared>,
Extension(me): Extension<Me>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
need(&me, Access::Rwa)?;
dir.store.block(&id).await?;
tracing::info!(feed = %id, by = %me.name, "feed removed and blocked");
@@ -420,7 +433,11 @@ async fn blocked(State(dir): State<Shared>, Extension(me): Extension<Me>) -> Res
Ok(Json(dir.store.blocked().await?))
}
async fn unblock(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
async fn unblock(
State(dir): State<Shared>,
Extension(me): Extension<Me>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
need(&me, Access::Rwa)?;
if !dir.store.unblock(&id).await? {
return Err(ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not blocked")));
@@ -434,16 +451,28 @@ async fn instances(State(dir): State<Shared>, Extension(me): Extension<Me>) -> R
Ok(Json(dir.store.instances().await?))
}
async fn drop_instance(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(name): Path<String>) -> Result<StatusCode, ApiError> {
async fn drop_instance(
State(dir): State<Shared>,
Extension(me): Extension<Me>,
Path(name): Path<String>,
) -> Result<StatusCode, ApiError> {
need(&me, Access::Rwa)?;
let n = dir.store.drop_instance(&name).await?;
tracing::info!(instance = %name, feeds = n, by = %me.name, "snapshot dropped");
Ok(StatusCode::NO_CONTENT)
}
async fn refresh(State(dir): State<Shared>, Extension(me): Extension<Me>, Path(id): Path<String>) -> Result<Json<FeedPage>, ApiError> {
async fn refresh(
State(dir): State<Shared>,
Extension(me): Extension<Me>,
Path(id): Path<String>,
) -> Result<Json<FeedPage>, ApiError> {
need(&me, Access::Rwa)?;
let due = dir.store.due_one(&id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))?;
let due = dir
.store
.due_one(&id)
.await?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))?;
dir.refresh(&due).await?;
feed(State(dir), Path(id)).await
}
@@ -467,7 +496,13 @@ mod tests {
(router(dir.clone()), dir)
}
async fn call(app: &Router, method: &str, path: &str, token: Option<&str>, body: Option<serde_json::Value>) -> (u16, serde_json::Value) {
async fn call(
app: &Router,
method: &str,
path: &str,
token: Option<&str>,
body: Option<serde_json::Value>,
) -> (u16, serde_json::Value) {
let mut req = axum::http::Request::builder().method(method).uri(path);
if let Some(t) = token {
req = req.header("authorization", format!("Bearer secret-{t}"));
@@ -501,7 +536,8 @@ mod tests {
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), 200);
let got: serde_json::Value = serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap();
let got: serde_json::Value =
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap();
assert_eq!(got["accepted"], 1);
}
@@ -510,11 +546,8 @@ mod tests {
let (app, _) = app().await;
for (asked, sent) in [("br", Some("br")), ("zstd", Some("zstd")), ("gzip", Some("gzip")), ("identity", None)] {
// A page, not an empty feed list: under 32 bytes is sent as it is.
let req = axum::http::Request::builder()
.uri("/")
.header("accept-encoding", asked)
.body(Body::empty())
.unwrap();
let req =
axum::http::Request::builder().uri("/").header("accept-encoding", asked).body(Body::empty()).unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), 200);
let got = resp.headers().get("content-encoding").map(|v| v.to_str().unwrap().to_owned());
@@ -583,7 +616,11 @@ mod tests {
assert_eq!(call(&app, "GET", "/v1/feeds/nope", Some("ro"), None).await.0, 404);
assert_eq!(call(&app, "GET", "/v1/popular?limit=5", Some("ro"), None).await.1[0]["id"], id.as_str());
for (m, p) in [("DELETE", format!("/v1/feeds/{id}")), ("GET", "/v1/blocked".into()), ("DELETE", "/v1/instances/rw-token".into())] {
for (m, p) in [
("DELETE", format!("/v1/feeds/{id}")),
("GET", "/v1/blocked".into()),
("DELETE", "/v1/instances/rw-token".into()),
] {
assert_eq!(call(&app, m, &p, Some("rw"), None).await.0, 403, "{m} {p}");
}
assert_eq!(call(&app, "DELETE", &format!("/v1/feeds/{id}"), Some("rwa"), None).await.0, 204);

View File

@@ -74,15 +74,24 @@ async fn main() -> Result<()> {
async fn serve(path: &std::path::Path) -> Result<()> {
// One JSON object a line for Loki when DIRECTORY_LOG_FORMAT=json, as ipx does; text otherwise.
let json = std::env::var("DIRECTORY_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
let filter = || tracing_subscriber::EnvFilter::try_from_env("DIRECTORY_LOG").unwrap_or_else(|_| "ipx_directory=info".into());
let filter =
|| tracing_subscriber::EnvFilter::try_from_env("DIRECTORY_LOG").unwrap_or_else(|_| "ipx_directory=info".into());
// Traces to Tempo when OTEL_EXPORTER_OTLP_ENDPOINT is set, joining ipx's (#213).
let otel = ipx_directory::otel::provider("ipx-directory")?;
tracing_subscriber::registry()
.with((!json).then(|| tracing_subscriber::fmt::layer().with_writer(std::io::stderr).with_filter(filter())))
.with(json.then(|| tracing_subscriber::fmt::layer().json().flatten_event(true).with_writer(std::io::stderr).with_filter(filter())))
.with(json.then(|| {
tracing_subscriber::fmt::layer()
.json()
.flatten_event(true)
.with_writer(std::io::stderr)
.with_filter(filter())
}))
.with(otel.as_ref().map(|p| {
use opentelemetry::trace::TracerProvider;
tracing_opentelemetry::layer().with_tracer(p.tracer("ipx-directory")).with_filter(tracing_subscriber::EnvFilter::new("ipx_directory=info,ipx_feed=info"))
tracing_opentelemetry::layer()
.with_tracer(p.tracer("ipx-directory"))
.with_filter(tracing_subscriber::EnvFilter::new("ipx_directory=info,ipx_feed=info"))
}))
.init();
@@ -96,10 +105,13 @@ async fn serve(path: &std::path::Path) -> Result<()> {
let dir = Directory::new(store, vec![])?;
let dropped = dir.set_tokens(tokens).await?;
let listener = tokio::net::TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
let listener =
tokio::net::TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
tracing::info!(version = env!("CARGO_PKG_VERSION"), bind = %cfg.bind, tokens = cfg.tokens.len(), dropped, "directory started");
if !cfg.metrics_bind.is_empty() {
let metrics = tokio::net::TcpListener::bind(&cfg.metrics_bind).await.with_context(|| format!("listening for metrics on {}", cfg.metrics_bind))?;
let metrics = tokio::net::TcpListener::bind(&cfg.metrics_bind)
.await
.with_context(|| format!("listening for metrics on {}", cfg.metrics_bind))?;
tracing::info!(bind = %cfg.metrics_bind, "serving metrics for Prometheus at /metrics");
tokio::spawn(ipx_directory::metrics::serve(metrics));
}
@@ -112,7 +124,8 @@ async fn serve(path: &std::path::Path) -> Result<()> {
tokio::spawn(ipx_directory::reloader(dir.clone(), path.to_owned()));
axum::serve(listener, ipx_directory::router(dir))
.with_graceful_shutdown(async {
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("SIGTERM handler");
let mut term =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("SIGTERM handler");
tokio::select! {
_ = term.recv() => {}
_ = tokio::signal::ctrl_c() => {}

View File

@@ -14,7 +14,8 @@ static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
pub fn process() -> (Option<f64>, Option<f64>) {
let rss = std::fs::read_to_string("/proc/self/status").ok().and_then(|s| {
let kb = s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
let kb =
s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
Some(kb * 1024.0)
});
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
@@ -35,10 +36,34 @@ fn render(started: std::time::Instant) -> String {
if let Some(v) = fds {
gauge("directory_process_open_fds", "gauge", "Files and sockets the process has open.", "", v);
}
gauge("directory_feed_fetches_total", "counter", "Feeds fetched, answered 304 included.", "", ipx_feed::FETCHES.load(Relaxed) as f64);
gauge("directory_feed_bytes_total", "counter", "Bytes of feed bodies fetched.", "", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64);
gauge("directory_uptime_seconds", "gauge", "Seconds since the directory started.", "", started.elapsed().as_secs() as f64);
gauge("directory_build_info", "gauge", "The version running, as a label.", &format!("{{version=\"{}\"}}", env!("CARGO_PKG_VERSION")), 1.0);
gauge(
"directory_feed_fetches_total",
"counter",
"Feeds fetched, answered 304 included.",
"",
ipx_feed::FETCHES.load(Relaxed) as f64,
);
gauge(
"directory_feed_bytes_total",
"counter",
"Bytes of feed bodies fetched.",
"",
ipx_feed::FETCHED_BYTES.load(Relaxed) as f64,
);
gauge(
"directory_uptime_seconds",
"gauge",
"Seconds since the directory started.",
"",
started.elapsed().as_secs() as f64,
);
gauge(
"directory_build_info",
"gauge",
"The version running, as a label.",
&format!("{{version=\"{}\"}}", env!("CARGO_PKG_VERSION")),
1.0,
);
out
}
@@ -46,7 +71,8 @@ fn render(started: std::time::Instant) -> String {
/// Access in front, and these are for the LAN.
pub async fn serve(listener: tokio::net::TcpListener) {
let started = std::time::Instant::now();
let app = axum::Router::new().route(
let app = axum::Router::new()
.route(
"/metrics",
axum::routing::get(move || async move {
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
@@ -64,6 +90,9 @@ mod tests {
#[test]
fn metrics_say_memory_and_feeds() {
let text = super::render(std::time::Instant::now());
assert!(text.contains("directory_process_resident_bytes ") && text.contains("directory_feed_bytes_total "), "{text}");
assert!(
text.contains("directory_process_resident_bytes ") && text.contains("directory_feed_bytes_total "),
"{text}"
);
}
}

View File

@@ -70,13 +70,21 @@ mod tests {
assert_eq!(cx.span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
assert!(cx.span().span_context().is_remote());
// Anything else from the browser starts a trace of its own.
for bad in ["", "00-xyz-00f067aa0ba902b7-01", "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"00-00000000000000000000000000000000-00f067aa0ba902b7-01", "00-4bf92f3577b34da6a3ce929d0e0e4736"] {
for bad in [
"",
"00-xyz-00f067aa0ba902b7-01",
"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"00-00000000000000000000000000000000-00f067aa0ba902b7-01",
"00-4bf92f3577b34da6a3ce929d0e0e4736",
] {
assert!(with(bad).is_none(), "{bad}");
}
assert!(remote_parent(&axum::http::HeaderMap::new(), None).is_none());
// From the query, where a beacon or Safari's player cannot send a header (#192).
let q = remote_parent(&axum::http::HeaderMap::new(), Some("x=1&traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"));
let q = remote_parent(
&axum::http::HeaderMap::new(),
Some("x=1&traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
);
assert_eq!(q.unwrap().span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
assert!(remote_parent(&axum::http::HeaderMap::new(), Some("traceparent=nonsense")).is_none());
}

View File

@@ -9,11 +9,11 @@
use std::collections::BTreeMap;
use std::fmt::Write;
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use crate::{Listing, Shared};
@@ -63,7 +63,9 @@ async fn home(State(dir): State<Shared>) -> Response {
Err(e) => return failed(e),
};
let mut top = all.clone();
top.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| name(a).to_lowercase().cmp(&name(b).to_lowercase())));
top.sort_by(|a, b| {
b.subscribers.cmp(&a.subscribers).then_with(|| name(a).to_lowercase().cmp(&name(b).to_lowercase()))
});
top.truncate(10);
let mut cats: BTreeMap<&str, Vec<&Listing>> = BTreeMap::new();
for l in &all {
@@ -75,12 +77,25 @@ async fn home(State(dir): State<Shared>) -> Response {
} else {
b.push_str("<h2>Most subscribed</h2><ol class=chart>");
for (i, l) in top.iter().enumerate() {
let _ = write!(b, "<li><a href=\"/feed/{}\"><span class=no>{}</span>{}<span class=txt><b>{}</b><small>{}</small></span></a></li>",
esc(&l.id), i + 1, art(l, "art sm"), esc(name(l)), subscribers(l.subscribers));
let _ = write!(
b,
"<li><a href=\"/feed/{}\"><span class=no>{}</span>{}<span class=txt><b>{}</b><small>{}</small></span></a></li>",
esc(&l.id),
i + 1,
art(l, "art sm"),
esc(name(l)),
subscribers(l.subscribers)
);
}
b.push_str("</ol><h2>Categories</h2><div class=cats>");
for (c, ls) in &cats {
let _ = write!(b, "<a class=cat href=\"/category/{}\"><b>{}</b><small>{}</small></a>", seg(c), esc(c), ls.len());
let _ = write!(
b,
"<a class=cat href=\"/category/{}\"><b>{}</b><small>{}</small></a>",
seg(c),
esc(c),
ls.len()
);
}
let _ = write!(b, "</div><p><a class=more href=\"/all\">All {} feeds, A to Z</a></p>", all.len());
}
@@ -115,7 +130,8 @@ async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn
if ls.is_empty() && here.starts_with("/category/") {
return not_found("That category has nothing in it.");
}
let subs: BTreeMap<String, usize> = ls.iter().filter_map(|l| l.subcategory.clone()).fold(BTreeMap::new(), |mut m, s| {
let subs: BTreeMap<String, usize> =
ls.iter().filter_map(|l| l.subcategory.clone()).fold(BTreeMap::new(), |mut m, s| {
*m.entry(s).or_default() += 1;
m
});
@@ -138,18 +154,38 @@ async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn
if q.is_empty() { here.to_owned() } else { format!("{here}?{q}") }
};
let mut b = String::new();
let _ = write!(b, "<p class=back><a href=\"/\">Directory</a></p><h2>{}</h2><nav class=tabs aria-label=Kind>", esc(title));
let _ = write!(
b,
"<p class=back><a href=\"/\">Directory</a></p><h2>{}</h2><nav class=tabs aria-label=Kind>",
esc(title)
);
for (k, label) in [(None, "All"), (Some("podcasts"), "Podcasts"), (Some("blogs"), "Blogs")] {
let on = f.kind.as_deref() == k;
let _ = write!(b, "<a href=\"{}\"{}>{label}</a>", esc(&link(k, f.sub.as_deref())), if on { " aria-current=page" } else { "" });
let _ = write!(
b,
"<a href=\"{}\"{}>{label}</a>",
esc(&link(k, f.sub.as_deref())),
if on { " aria-current=page" } else { "" }
);
}
b.push_str("</nav>");
if !subs.is_empty() {
b.push_str("<nav class=pills aria-label=Subcategory>");
let _ = write!(b, "<a href=\"{}\"{}>All</a>", esc(&link(f.kind.as_deref(), None)), if f.sub.is_none() { " aria-current=page" } else { "" });
let _ = write!(
b,
"<a href=\"{}\"{}>All</a>",
esc(&link(f.kind.as_deref(), None)),
if f.sub.is_none() { " aria-current=page" } else { "" }
);
for (s, n) in &subs {
let on = f.sub.as_deref() == Some(s.as_str());
let _ = write!(b, "<a href=\"{}\"{}>{} <small>{n}</small></a>", esc(&link(f.kind.as_deref(), Some(s))), if on { " aria-current=page" } else { "" }, esc(s));
let _ = write!(
b,
"<a href=\"{}\"{}>{} <small>{n}</small></a>",
esc(&link(f.kind.as_deref(), Some(s))),
if on { " aria-current=page" } else { "" },
esc(s)
);
}
b.push_str("</nav>");
}
@@ -158,7 +194,14 @@ async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn
} else {
let _ = write!(b, "<p class=count>{}</p><div class=tiles>", plural(ls.len(), "feed"));
for l in &ls {
let _ = write!(b, "<a class=tile href=\"/feed/{}\">{}<b>{}</b><small>{}</small></a>", esc(&l.id), art(l, "art"), esc(name(l)), subscribers(l.subscribers));
let _ = write!(
b,
"<a class=tile href=\"/feed/{}\">{}<b>{}</b><small>{}</small></a>",
esc(&l.id),
art(l, "art"),
esc(name(l)),
subscribers(l.subscribers)
);
}
b.push_str("</div>");
}
@@ -177,11 +220,21 @@ async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Response {
if let Some(c) = &l.category {
let _ = write!(b, " <span aria-hidden=true>/</span> <a href=\"/category/{}\">{}</a>", seg(c), esc(c));
}
let _ = write!(b, "</p><div class=show>{}<div class=meta><h2>{}</h2><p class=sub>{}{}</p>", art(l, "art lg"), esc(name(l)),
subscribers(l.subscribers), l.subcategory.as_deref().map(|s| format!(" &middot; {}", esc(s))).unwrap_or_default());
let _ = write!(b, "<div class=addr><label for=addr>Feed address</label><span class=row><input id=addr readonly value=\"{}\" spellcheck=false>\
let _ = write!(
b,
"</p><div class=show>{}<div class=meta><h2>{}</h2><p class=sub>{}{}</p>",
art(l, "art lg"),
esc(name(l)),
subscribers(l.subscribers),
l.subcategory.as_deref().map(|s| format!(" &middot; {}", esc(s))).unwrap_or_default()
);
let _ = write!(
b,
"<div class=addr><label for=addr>Feed address</label><span class=row><input id=addr readonly value=\"{}\" spellcheck=false>\
<button type=button data-copy=\"{}\">Copy</button></span><small>Paste it into any podcast app or feed reader to subscribe.</small></div></div></div>",
esc(&l.url), esc(&l.url));
esc(&l.url),
esc(&l.url)
);
if let Some(d) = p.description.as_deref().map(text).filter(|d| !d.is_empty() && d != name(l)) {
let _ = write!(b, "<p class=about>{}</p>", esc(&d));
}
@@ -199,17 +252,26 @@ async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Response {
None => esc(&title),
};
let blurb: String = e.description.as_deref().map(text).unwrap_or_default().chars().take(400).collect();
let when = [e.published.map(date), e.duration.filter(|d| *d > 0).map(clock)].into_iter().flatten().collect::<Vec<_>>().join(" &middot; ");
let _ = write!(b, "<article><b>{head}</b>{}{}</article>",
let when = [e.published.map(date), e.duration.filter(|d| *d > 0).map(clock)]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" &middot; ");
let _ = write!(
b,
"<article><b>{head}</b>{}{}</article>",
if blurb.is_empty() { String::new() } else { format!("<p>{}</p>", esc(&blurb)) },
if when.is_empty() { String::new() } else { format!("<small>{when}</small>") });
if when.is_empty() { String::new() } else { format!("<small>{when}</small>") }
);
}
page(name(l), Some(&text(p.description.as_deref().unwrap_or(""))), &b)
}
fn page(title: &str, description: Option<&str>, body: &str) -> Response {
let full = if title == "iPX Directory" { title.to_owned() } else { format!("{title} | iPX Directory") };
let desc = description.filter(|d| !d.is_empty()).map(|d| d.chars().take(200).collect::<String>())
let desc = description
.filter(|d| !d.is_empty())
.map(|d| d.chars().take(200).collect::<String>())
.unwrap_or_else(|| "Public podcasts and blogs that iPX servers share.".into());
let html = format!(
"<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\">\
@@ -234,14 +296,22 @@ fn page(title: &str, description: Option<&str>, body: &str) -> Response {
}
fn not_found(why: &str) -> Response {
let mut r = page("Not found", None, &format!("<p class=back><a href=\"/\">Directory</a></p><h2>Not found</h2><p class=hint>{}</p>", esc(why)));
let mut r = page(
"Not found",
None,
&format!("<p class=back><a href=\"/\">Directory</a></p><h2>Not found</h2><p class=hint>{}</p>", esc(why)),
);
*r.status_mut() = StatusCode::NOT_FOUND;
r
}
fn failed(e: anyhow::Error) -> Response {
tracing::error!(error = %format!("{e:#}"), "a directory page failed");
let mut r = page("Unavailable", None, "<h2>Unavailable</h2><p class=hint>The directory could not read its list just now. Try again in a minute.</p>");
let mut r = page(
"Unavailable",
None,
"<h2>Unavailable</h2><p class=hint>The directory could not read its list just now. Try again in a minute.</p>",
);
*r.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
r
}
@@ -253,8 +323,12 @@ fn redirect(to: &'static str) -> Response {
/// A cover from its publisher, over the feed's initials, which show where it has none or it fails.
fn art(l: &Listing, class: &str) -> String {
// The first letter of each of the first two words that have one: `<antirez>` is A, not nothing.
let initials: String = name(l).split_whitespace().filter_map(|w| w.chars().find(|c| c.is_alphanumeric())).take(2).collect();
let img = l.image.as_deref().filter(|u| u.starts_with("https://") || u.starts_with("http://"))
let initials: String =
name(l).split_whitespace().filter_map(|w| w.chars().find(|c| c.is_alphanumeric())).take(2).collect();
let img = l
.image
.as_deref()
.filter(|u| u.starts_with("https://") || u.starts_with("http://"))
.map(|u| format!("<img src=\"{}\" alt=\"\" loading=lazy referrerpolicy=no-referrer>", esc(u)))
.unwrap_or_default();
format!("<span class=\"{class}\" aria-hidden=true><i>{}</i>{img}</span>", esc(&initials.to_uppercase()))
@@ -301,7 +375,13 @@ fn clock(secs: i64) -> String {
fn text(html: &str) -> String {
// A space where each tag was, or two paragraphs run together: "drives.For over".
let stripped = ammonia::Builder::empty().clean(&html.replace('<', " <")).to_string();
let decoded = stripped.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#39;", "'").replace("&nbsp;", " ").replace("&amp;", "&");
let decoded = stripped
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&nbsp;", " ")
.replace("&amp;", "&");
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
}

View File

@@ -28,11 +28,7 @@ pub struct Due {
/// What a fetch found, to be stored.
pub enum Refreshed {
NotModified,
Feed {
parsed: ipx_feed::ParsedFeed,
etag: Option<String>,
last_modified: Option<String>,
},
Feed { parsed: ipx_feed::ParsedFeed, etag: Option<String>, last_modified: Option<String> },
Failed(String),
}
@@ -122,7 +118,12 @@ impl Store {
let schema = format!("ipxd_{pid}_{n}");
admin.db.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await.unwrap();
let sep = if url.contains('?') { '&' } else { '?' };
Self::open(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"), false).await.unwrap()
Self::open(
&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"),
false,
)
.await
.unwrap()
}
/// `keep_open` for the server, which keeps its whole pool connected.
@@ -195,8 +196,12 @@ impl Store {
/// blocked feed is left out and counted; the rest are added to the feeds, new ones to be
/// fetched by the refresher. Feeds no instance sends any more go.
pub async fn snapshot(&self, instance: &str, entries: &[SnapshotEntry]) -> Result<SnapshotResult> {
let blocked: std::collections::HashSet<String> =
self.rows("SELECT id FROM blocked", vec![]).await?.iter().map(|r| r.try_get("", "id")).collect::<Result<_, _>>()?;
let blocked: std::collections::HashSet<String> = self
.rows("SELECT id FROM blocked", vec![])
.await?
.iter()
.map(|r| r.try_get("", "id"))
.collect::<Result<_, _>>()?;
let mut result = SnapshotResult::default();
let mut keep = std::collections::BTreeMap::new();
for e in entries {
@@ -372,7 +377,13 @@ impl Store {
)
.await?
.iter()
.map(|r| Ok(Instance { name: r.try_get("", "instance")?, feeds: r.try_get("", "feeds")?, subscribers: r.try_get("", "subscribers")? }))
.map(|r| {
Ok(Instance {
name: r.try_get("", "instance")?,
feeds: r.try_get("", "feeds")?,
subscribers: r.try_get("", "subscribers")?,
})
})
.collect()
}
@@ -380,7 +391,14 @@ impl Store {
self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![])
.await?
.iter()
.map(|r| Ok(Blocked { id: r.try_get("", "id")?, url: r.try_get("", "url")?, title: r.try_get("", "title")?, at: r.try_get("", "at")? }))
.map(|r| {
Ok(Blocked {
id: r.try_get("", "id")?,
url: r.try_get("", "url")?,
title: r.try_get("", "title")?,
at: r.try_get("", "at")?,
})
})
.collect()
}
@@ -406,8 +424,7 @@ impl Store {
}
pub async fn due_one(&self, id: &str) -> Result<Option<Due>> {
Ok(self
.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
self.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
.await?
.first()
.map(|r| -> Result<Due> {
@@ -418,14 +435,18 @@ impl Store {
last_modified: r.try_get("", "last_modified")?,
})
})
.transpose()?)
.transpose()
}
pub async fn save(&self, id: &str, got: Refreshed) -> Result<()> {
let t = now();
match got {
Refreshed::NotModified => {
self.exec("UPDATE feeds SET checked_at = $1, last_error = NULL, error_since = NULL WHERE id = $2", vec![t.into(), id.into()]).await?;
self.exec(
"UPDATE feeds SET checked_at = $1, last_error = NULL, error_since = NULL WHERE id = $2",
vec![t.into(), id.into()],
)
.await?;
}
Refreshed::Failed(err) => {
self.exec(
@@ -436,16 +457,25 @@ impl Store {
}
Refreshed::Feed { parsed, etag, last_modified } => {
let path = parsed.category.as_deref().map(ipx_feed::category_path);
let podcast = parsed.entries.iter().flat_map(|e| &e.enclosures).any(|e| {
e.mime.as_deref().is_some_and(|m| m.starts_with("audio/") || m.starts_with("video/"))
});
let podcast = parsed
.entries
.iter()
.flat_map(|e| &e.enclosures)
.any(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("audio/") || m.starts_with("video/")));
let mut entries = parsed.entries;
entries.sort_by_key(|e| std::cmp::Reverse(e.published));
// What an item is, never its files: their addresses are where a key would be.
let items: Vec<Item> = entries
.into_iter()
.take(ITEMS)
.map(|e| Item { guid: e.guid, title: e.title, link: e.link, published: e.published, duration: e.duration, description: e.description })
.map(|e| Item {
guid: e.guid,
title: e.title,
link: e.link,
published: e.published,
duration: e.duration,
description: e.description,
})
.collect();
self.exec(
"UPDATE feeds SET title = $1, image = $2, category = $3, subcategory = $4, description = $5,
@@ -476,8 +506,8 @@ impl Store {
/// A feed's last error as its page says it: explained once it has been failing a day, or else
/// only that the check failed. Never the error itself, which is for the log.
fn failure_words(err: &str, failing_for: i64) -> String {
Some(err)
.filter(|_| failing_for >= FLAG_AFTER_SECS)
(failing_for >= FLAG_AFTER_SECS)
.then_some(err)
.and_then(ipx_feed::explain_failure)
.map_or_else(|| "Its last check failed.".to_owned(), |f| f.reason.to_owned())
}
@@ -541,7 +571,10 @@ pub(crate) mod tests {
assert_eq!(all[0].subscribers, 3);
let i = s.instances().await.unwrap();
assert_eq!(i.iter().map(|i| (i.name.as_str(), i.feeds, i.subscribers)).collect::<Vec<_>>(), [("a", 1, 1), ("b", 1, 2)]);
assert_eq!(
i.iter().map(|i| (i.name.as_str(), i.feeds, i.subscribers)).collect::<Vec<_>>(),
[("a", 1, 1), ("b", 1, 2)]
);
// A token taken out of the config file takes its counts.
s.keep_instances(&["b".into()]).await.unwrap();
@@ -583,7 +616,11 @@ pub(crate) mod tests {
#[tokio::test]
async fn a_feed_naming_no_category_takes_the_one_a_server_gave_it() {
let s = Store::scratch().await;
let entry = |c: Option<&str>| SnapshotEntry { url: "https://x.test/blog.xml".into(), subscribers: 1, category: c.map(Into::into) };
let entry = |c: Option<&str>| SnapshotEntry {
url: "https://x.test/blog.xml".into(),
subscribers: 1,
category: c.map(Into::into),
};
s.snapshot("a", &[entry(Some("Visual Arts"))]).await.unwrap();
let d = s.due(0, 10).await.unwrap().remove(0);
let mut parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();

View File

@@ -9,6 +9,7 @@ anyhow.workspace = true
atom_syndication.workspace = true
chrono.workspace = true
opml.workspace = true
pulldown-cmark.workspace = true
quick-xml.workspace = true
reqwest.workspace = true
rss.workspace = true

View File

@@ -27,6 +27,9 @@ pub struct Entry {
pub link: Option<String>,
pub published: Option<i64>,
pub description: Option<String>,
/// The item's Markdown, when the feed sends it beside HTML of its own (`<source:markdown>`):
/// the page can show either (#285). With no HTML it is the description instead (#284).
pub markdown: Option<String>,
pub categories: Vec<String>,
pub explicit: bool,
/// Episode artwork; falls back to the feed's in the UI.
@@ -111,6 +114,23 @@ pub fn redact_text(text: &str) -> String {
out
}
/// The most of a feed or page read. The largest feeds here are a few MB; without a limit an
/// episode's MP3 given as a feed was read whole into memory, at every check (#295).
const MAX_BODY: usize = 50 << 20;
/// A response's body, up to `MAX_BODY`: past it, an error, and the rest is never read.
async fn read_capped(mut resp: reqwest::Response) -> Result<Vec<u8>> {
if resp.content_length().is_some_and(|n| n > MAX_BODY as u64) {
return Err(anyhow!("larger than {} MB, too large to be a feed", MAX_BODY >> 20));
}
let mut out = Vec::new();
while let Some(c) = resp.chunk().await? {
out.extend_from_slice(&c);
anyhow::ensure!(out.len() <= MAX_BODY, "larger than {} MB, too large to be a feed", MAX_BODY >> 20);
}
Ok(out)
}
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
pub async fn fetch(
client: &reqwest::Client,
@@ -158,12 +178,11 @@ pub async fn fetch(
// The original surfaced 401/407 specially; the code is enough for a UI to switch on.
return Err(anyhow!("HTTP {status}"));
}
let header = |h: reqwest::header::HeaderName| {
resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned)
};
let header =
|h: reqwest::header::HeaderName| resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned);
let etag = header(ETAG);
let last_modified = header(LAST_MODIFIED);
let bytes = resp.bytes().await.context("reading body")?.to_vec();
let bytes = read_capped(resp).await.context("reading body")?;
tracing::Span::current().record("http.response.body.size", bytes.len());
FETCHED_BYTES.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
@@ -188,7 +207,10 @@ pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
if let Some(c) = code.filter(|c| (100..600).contains(c)) {
return (c.to_string(), Some(c));
}
let kind = if low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated") {
let kind = if low.contains("dns error")
|| low.contains("failed to lookup address")
|| low.contains("no address associated")
{
"dns"
} else if low.contains("too many redirects") {
"redirect_loop"
@@ -222,14 +244,14 @@ pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
/// nothing extra, so a miss costs a clearer message, not a wrong one.
pub fn explain_failure(msg: &str) -> Option<Failure> {
if let Some(rest) = msg.strip_prefix("got a web page, not a feed") {
let new_url = rest
.strip_prefix("; it links ")
.and_then(|r| r.strip_suffix(" as its feed"))
.map(str::to_owned);
let new_url = rest.strip_prefix("; it links ").and_then(|r| r.strip_suffix(" as its feed")).map(str::to_owned);
return Some(Failure { reason: "The feed moved; this address now shows a web page.", new_url });
}
if msg.contains("the site sent ") {
return Some(Failure { reason: "The site sent a message instead of the feed; the publisher has to fix it.", new_url: None });
return Some(Failure {
reason: "The site sent a message instead of the feed; the publisher has to fix it.",
new_url: None,
});
}
let low = msg.to_ascii_lowercase();
if low.contains("http 404") {
@@ -247,7 +269,9 @@ pub fn explain_failure(msg: &str) -> Option<Failure> {
// Only a name that does not exist. A lookup that failed for the moment says nothing about the
// site, and NPR's and Megaphone's feeds were labelled gone after a DNS outage (#143).
if !is_temporary_lookup_failure(msg)
&& (low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated"))
&& (low.contains("dns error")
|| low.contains("failed to lookup address")
|| low.contains("no address associated"))
{
return Some(Failure { reason: "This address no longer resolves; the site may be gone.", new_url: None });
}
@@ -276,8 +300,7 @@ pub fn is_opml(bytes: &[u8]) -> bool {
/// The feeds listed in an OPML document, as (title, xml_url), walking nested folders.
pub fn parse_opml(bytes: &[u8]) -> Result<Vec<(String, String)>> {
let text = String::from_utf8_lossy(bytes);
let doc = opml::OPML::from_str(&text)
.map_err(|e| anyhow!("that does not parse as OPML: {e}"))?;
let doc = opml::OPML::from_str(&text).map_err(|e| anyhow!("that does not parse as OPML: {e}"))?;
let mut out = vec![];
collect_outlines(&doc.body.outlines, &mut out);
Ok(out)
@@ -298,10 +321,7 @@ pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, Strin
pub fn opml_title(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
let doc = opml::OPML::from_str(&text).ok()?;
doc.head
.and_then(|h| h.title)
.map(|t| t.trim().to_owned())
.filter(|t| !t.is_empty())
doc.head.and_then(|h| h.title).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
}
/// The token and show of a Patreon feed link, or None for any other URL.
@@ -351,10 +371,7 @@ pub fn same_feed(a: &str, b: &str) -> bool {
/// ponytail: Patreon's own web API, undocumented, asked without signing in. If it changes,
/// finding shows stops and the show feeds already found keep working. The documented API
/// needs an OAuth client per install and does not list shows.
pub async fn patreon_shows(
client: &reqwest::Client,
url: &str,
) -> Result<(Option<String>, Vec<(String, String)>)> {
pub async fn patreon_shows(client: &reqwest::Client, url: &str) -> Result<(Option<String>, Vec<(String, String)>)> {
// The creator feed names its campaign by number in its self link, a few hundred bytes in.
// The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up.
let mut resp = client.get(url).timeout(FEED_TIMEOUT).send().await.context("connecting")?;
@@ -366,8 +383,7 @@ pub async fn patreon_shows(
let Some(chunk) = resp.chunk().await.context("reading the feed")? else { break };
head.extend_from_slice(&chunk);
}
let campaign = patreon_campaign(&head)
.ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?;
let campaign = patreon_campaign(&head).ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?;
let api = format!(
"https://www.patreon.com/api/campaigns/{campaign}\
@@ -390,8 +406,11 @@ fn patreon_campaign(head: &[u8]) -> Option<String> {
})
}
/// A campaign's name, and its shows as (id, title).
type PatreonShows = (Option<String>, Vec<(String, String)>);
/// A campaign's name and its shows as (id, title), from Patreon's JSON:API answer.
fn parse_patreon_shows(json: &[u8]) -> Result<(Option<String>, Vec<(String, String)>)> {
fn parse_patreon_shows(json: &[u8]) -> Result<PatreonShows> {
let v: serde_json::Value = serde_json::from_slice(json).context("Patreon's answer is not JSON")?;
// Missing is not the same as none. Read as no shows, the creator feed would be scanned as
// a plain feed, claim every show's files, and leave the shows empty once the list returned.
@@ -490,7 +509,8 @@ fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
let tag_lower = &lower[start..end];
let is_alternate = tag_lower.contains("rel=\"alternate\"") || tag_lower.contains("rel='alternate'");
let is_feed_type = tag_lower.contains("rss+xml") || tag_lower.contains("atom+xml");
if is_alternate && is_feed_type
if is_alternate
&& is_feed_type
&& let Some(href) = tag_attr(tag, "href")
{
return Some(href);
@@ -505,15 +525,10 @@ fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
/// feed that moved (#102); cnn.com is one.
pub async fn find_feed(client: &reqwest::Client, url: &str) -> Result<String> {
let read = |u: String| async move {
let resp = client
.get(&u)
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.context("connecting")?;
let resp = client.get(&u).timeout(std::time::Duration::from_secs(30)).send().await.context("connecting")?;
anyhow::ensure!(resp.status().is_success(), "HTTP {}", resp.status());
let base = resp.url().clone();
anyhow::Ok((base, resp.bytes().await.context("reading")?))
anyhow::Ok((base, read_capped(resp).await.context("reading")?))
};
let is_feed = |b: &[u8]| is_opml(b) || parse(b).is_ok();
let (base, body) = read(url.to_owned()).await.with_context(|| format!("could not read {url}"))?;
@@ -527,7 +542,8 @@ pub async fn find_feed(client: &reqwest::Client, url: &str) -> Result<String> {
let Some(href) = alternate_feed_link(&body) else {
anyhow::bail!("{url} is a web page that links no feed, so there is nothing to subscribe to");
};
let linked = base.join(&href).with_context(|| format!("{url} links {href} as its feed, which is not an address"))?;
let linked =
base.join(&href).with_context(|| format!("{url} links {href} as its feed, which is not an address"))?;
let (_, feed) = read(linked.to_string()).await.with_context(|| format!("{url} links {linked} as its feed, but"))?;
anyhow::ensure!(is_feed(&feed), "{url} links {linked} as its feed, but that is not a feed either");
Ok(linked.into())
@@ -545,7 +561,7 @@ pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
// The icon a page names can be gone: antirez.com names /images/favicon.png, a 404, while its
// /favicon.ico is there. Stored unchecked, it was a broken image that was never looked up again.
if resp.status().is_success()
&& let Ok(page) = resp.bytes().await
&& let Ok(page) = read_capped(resp).await
&& let Some(href) = page_icon(&page)
&& let Ok(url) = base.join(&href)
&& is_image(client, url.as_str()).await
@@ -682,9 +698,7 @@ fn enclosures_by_item(bytes: &[u8]) -> Vec<Vec<Enclosure>> {
let (mut url, mut mime, mut length) = (String::new(), None, None);
for attr in e.attributes().flatten() {
// Values arrive escaped: a feed URL's "&" is "&amp;" in the document.
let val = quick_xml::escape::unescape(&attr.value)
.map(|v| v.trim().to_string())
.unwrap_or_default();
let val = quick_xml::escape::unescape(&attr.value).map(|v| v.trim().to_string()).unwrap_or_default();
match attr.key.local_name().as_ref() {
"url" => url = val,
"type" => mime = Some(val).filter(|v| !v.is_empty()),
@@ -736,10 +750,7 @@ fn enclosures_by_item(bytes: &[u8]) -> Vec<Vec<Enclosure>> {
fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
let per_item = enclosures_by_item(bytes);
let explicit = ch
.itunes_ext()
.and_then(|it| it.explicit())
.is_some_and(is_yes);
let explicit = ch.itunes_ext().and_then(|it| it.explicit()).is_some_and(is_yes);
let entries = ch
.items()
@@ -768,10 +779,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
item.title(),
)?;
let entry_explicit = item
.itunes_ext()
.and_then(|it| it.explicit())
.is_some_and(is_yes);
let entry_explicit = item.itunes_ext().and_then(|it| it.explicit()).is_some_and(is_yes);
let it = item.itunes_ext();
Some(Entry {
@@ -779,8 +787,11 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
title: title_text(item.title()),
link: non_empty(item.link()),
published: item.pub_date().and_then(parse_date),
// Content wins over description, as __getEntries preferred entry.content.
description: body(item.content(), item.description()),
// Content wins over description, as __getEntries preferred entry.content. The
// source namespace's Markdown copy only when there is neither (#284).
description: notes(item.content(), item.description(), source_markdown(item)),
markdown: non_empty(source_markdown(item))
.filter(|_| non_empty(item.content()).or_else(|| non_empty(item.description())).is_some()),
categories: item
.categories()
.iter()
@@ -839,25 +850,18 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
.collect();
drop_player_repeats(&mut enclosures);
let alt = e
.links()
.iter()
.find(|l| l.rel() == "alternate" || l.rel().is_empty())
.map(|l| l.href());
let alt = e.links().iter().find(|l| l.rel() == "alternate" || l.rel().is_empty()).map(|l| l.href());
let guid = pick_guid(
Some(e.id()),
alt,
enclosures.first().map(|x| x.url.as_str()),
Some(e.title().as_str()),
)?;
let guid =
pick_guid(Some(e.id()), alt, enclosures.first().map(|x| x.url.as_str()), Some(e.title().as_str()))?;
Some(Entry {
guid,
title: title_text(Some(e.title().as_str())),
link: alt.map(str::to_owned),
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
description: notes(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str()), None),
markdown: None,
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
explicit: false,
image: None,
@@ -888,18 +892,8 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
/// The original fell back to hashing the title or description. A guid, permalink or
/// enclosure URL is a stable identifier already, so no hashing is needed; an entry with
/// none of them has nothing to download and is dropped.
fn pick_guid(
guid: Option<&str>,
link: Option<&str>,
enclosure: Option<&str>,
title: Option<&str>,
) -> Option<String> {
[guid, link, enclosure, title]
.into_iter()
.flatten()
.map(str::trim)
.find(|s| !s.is_empty())
.map(str::to_owned)
fn pick_guid(guid: Option<&str>, link: Option<&str>, enclosure: Option<&str>, title: Option<&str>) -> Option<String> {
[guid, link, enclosure, title].into_iter().flatten().map(str::trim).find(|s| !s.is_empty()).map(str::to_owned)
}
fn is_yes(s: &str) -> bool {
@@ -946,9 +940,7 @@ fn title_text(s: Option<&str>) -> Option<String> {
while let Some(at) = rest.find('&') {
out.push_str(&rest[..at]);
rest = &rest[at..];
let len = 1 + rest[1..]
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '#'))
.unwrap_or(rest.len() - 1);
let len = 1 + rest[1..].find(|c: char| !(c.is_ascii_alphanumeric() || c == '#')).unwrap_or(rest.len() - 1);
let decoded = rest[len..]
.starts_with(';')
.then(|| quick_xml::escape::unescape_with(&rest[..=len], quick_xml::escape::resolve_html5_entity).ok())
@@ -968,6 +960,43 @@ fn title_text(s: Option<&str>) -> Option<String> {
non_empty(Some(&out))
}
/// An item's notes as HTML: `body`, with Markdown made HTML, whether the feed wrote its text in
/// Markdown or sent only a Markdown copy (#284). The page cleans it as it does any feed's HTML.
fn notes(content: Option<&str>, description: Option<&str>, markdown: Option<&str>) -> Option<String> {
match body(content, description) {
Some(b) if is_markdown(&b) => Some(markdown_html(&b)),
Some(b) => Some(b),
None => non_empty(markdown).map(|m| markdown_html(&m)),
}
}
/// `<source:markdown>`, from https://source.scripting.com/: the item's text in Markdown.
///
/// shortcut: found by the `source` prefix the namespace's own docs and feeds use; a feed binding
/// it to another prefix is not read, until one turns up.
fn source_markdown(item: &rss::Item) -> Option<&str> {
item.extensions().get("source")?.get("markdown")?.first()?.value()
}
/// Text with no HTML tag and some Markdown: a link, a heading, a list, a quote, code or bold.
/// Plain text without any is left as it was, since rendering it would change nothing but
/// could still read a line starting "1984." as a list.
fn is_markdown(s: &str) -> bool {
let tag = s
.match_indices('<')
.any(|(i, _)| s[i + 1..].starts_with(|c: char| c.is_ascii_alphabetic() || c == '/' || c == '!'));
let block = |l: &str| ["# ", "## ", "### ", "- ", "* ", "> ", "```"].iter().any(|m| l.trim_start().starts_with(m));
!tag && (s.contains("](") || s.contains("**") || s.lines().any(block))
}
/// Markdown made HTML, uncleaned: whoever shows it cleans it as any feed's HTML.
pub fn markdown_html(md: &str) -> String {
use pulldown_cmark::{Options, Parser, html};
let mut out = String::new();
html::push_html(&mut out, Parser::new_ext(md, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH));
out
}
/// An item's show notes: its full body when that is whole, else its description.
///
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
@@ -1014,7 +1043,10 @@ fn text_of(html: &str) -> String {
for c in html.chars() {
match c {
'<' => in_tag = true,
'>' if in_tag => { in_tag = false; out.push(' '); }
'>' if in_tag => {
in_tag = false;
out.push(' ');
}
_ if !in_tag => out.push(c),
_ => {}
}
@@ -1051,13 +1083,8 @@ fn item_image(item: &rss::Item, enclosures: &[Enclosure]) -> Option<String> {
non_empty(e.attrs.get("url").map(String::as_str))
})
};
attr("thumbnail", false)
.or_else(|| attr("content", true))
.or_else(|| {
enclosures
.iter()
.find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("image/")))
.map(|e| e.url.clone())
attr("thumbnail", false).or_else(|| attr("content", true)).or_else(|| {
enclosures.iter().find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("image/"))).map(|e| e.url.clone())
})
}
@@ -1099,13 +1126,62 @@ const CATEGORIES: &[(&str, &[&str])] = &[
("History", &[]),
("Health & Fitness", &["Alternative Health", "Fitness", "Medicine", "Mental Health", "Nutrition", "Sexuality"]),
("Kids & Family", &["Education for Kids", "Parenting", "Pets & Animals", "Stories for Kids"]),
("Leisure", &["Animation & Manga", "Automotive", "Aviation", "Crafts", "Games", "Hobbies", "Home & Garden", "Video Games"]),
(
"Leisure",
&["Animation & Manga", "Automotive", "Aviation", "Crafts", "Games", "Hobbies", "Home & Garden", "Video Games"],
),
("Music", &["Music Commentary", "Music History", "Music Interviews"]),
("News", &["Business News", "Daily News", "Entertainment News", "News Commentary", "Politics", "Sports News", "Tech News"]),
("Religion & Spirituality", &["Buddhism", "Christianity", "Hinduism", "Islam", "Judaism", "Religion", "Spirituality"]),
("Science", &["Astronomy", "Chemistry", "Earth Sciences", "Life Sciences", "Mathematics", "Natural Sciences", "Nature", "Physics", "Social Sciences"]),
(
"News",
&[
"Business News",
"Daily News",
"Entertainment News",
"News Commentary",
"Politics",
"Sports News",
"Tech News",
],
),
(
"Religion & Spirituality",
&["Buddhism", "Christianity", "Hinduism", "Islam", "Judaism", "Religion", "Spirituality"],
),
(
"Science",
&[
"Astronomy",
"Chemistry",
"Earth Sciences",
"Life Sciences",
"Mathematics",
"Natural Sciences",
"Nature",
"Physics",
"Social Sciences",
],
),
("Society & Culture", &["Documentary", "Personal Journals", "Philosophy", "Places & Travel", "Relationships"]),
("Sports", &["Baseball", "Basketball", "Cricket", "Fantasy Sports", "Football", "Golf", "Hockey", "Rugby", "Running", "Soccer", "Swimming", "Tennis", "Volleyball", "Wilderness", "Wrestling"]),
(
"Sports",
&[
"Baseball",
"Basketball",
"Cricket",
"Fantasy Sports",
"Football",
"Golf",
"Hockey",
"Rugby",
"Running",
"Soccer",
"Swimming",
"Tennis",
"Volleyball",
"Wilderness",
"Wrestling",
],
),
("Technology", &[]),
("True Crime", &[]),
("TV & Film", &["After Shows", "Film History", "Film Interviews", "Film Reviews", "TV Reviews"]),
@@ -1207,15 +1283,12 @@ pub fn url_looks_private(feed_url: &str) -> bool {
|| u.password().is_some()
|| u.query_pairs().any(|(k, _)| {
let k = k.to_ascii_lowercase();
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"]
.iter()
.any(|w| k.contains(w))
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"].iter().any(|w| k.contains(w))
})
}
/// Services whose feeds are always one subscriber's own.
const PAID_HOSTS: &[&str] =
&["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
const PAID_HOSTS: &[&str] = &["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
#[cfg(test)]
mod tests {
@@ -1227,7 +1300,9 @@ mod tests {
assert_eq!(super::redact("not a url?token=SECRET"), "not a url");
// As reqwest words a failure, and with two addresses and none.
assert_eq!(
super::redact_text("connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out"),
super::redact_text(
"connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out"
),
"connecting: error sending request for url (https://www.patreon.com/rss/x): operation timed out"
);
assert_eq!(super::redact_text("from http://a/f?k=1 to https://u:p@b/g"), "from http://a/f to https://b/g");
@@ -1266,10 +1341,18 @@ mod tests {
let path = req.split_whitespace().nth(1).unwrap_or("/").to_owned();
let body = "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title></channel></rss>";
let resp = match path.as_str() {
"/old" => "HTTP/1.1 301 Moved Permanently\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned(),
"/old" => {
"HTTP/1.1 301 Moved Permanently\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned()
}
"/tmp" => "HTTP/1.1 302 Found\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned(),
"/chain" => "HTTP/1.1 308 Permanent Redirect\r\nLocation: /tmp\r\nContent-Length: 0\r\n\r\n".to_owned(),
"/loop" => "HTTP/1.1 301 Moved Permanently\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n".to_owned(),
"/chain" => {
"HTTP/1.1 308 Permanent Redirect\r\nLocation: /tmp\r\nContent-Length: 0\r\n\r\n".to_owned()
}
// An episode's file given as a feed: its size said, its body never sent.
"/huge" => "HTTP/1.1 200 OK\r\nContent-Length: 600000000\r\n\r\n".to_owned(),
"/loop" => {
"HTTP/1.1 301 Moved Permanently\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n".to_owned()
}
_ => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()),
};
let _ = sock.write_all(resp.as_bytes()).await;
@@ -1292,6 +1375,8 @@ mod tests {
assert!(matches!(got, super::Fetched::Body { .. }));
assert_eq!(moved, Some(format!("{base}/new")));
assert_eq!(get("/tmp").await.unwrap().1, None); // 302: for now
let huge = get("/huge").await.err().map(|e| format!("{e:#}")).unwrap_or_default();
assert!(huge.contains("too large to be a feed"), "{huge}");
assert_eq!(get("/chain").await.unwrap().1, None); // 308 then 302: not for good
assert_eq!(get("/new").await.unwrap().1, None); // never moved
let looped = get("/loop").await.err().unwrap().to_string();
@@ -1327,10 +1412,7 @@ mod tests {
);
assert!(feed.entries[1].explicit, "entry-level itunes:explicit=yes");
assert_eq!(
feed.entries[2].enclosures[0].mime.as_deref(),
Some("application/x-bittorrent")
);
assert_eq!(feed.entries[2].enclosures[0].mime.as_deref(), Some("application/x-bittorrent"));
}
#[test]
@@ -1344,6 +1426,40 @@ mod tests {
assert_eq!(parse(atom).unwrap().description.as_deref(), Some("Or here."));
}
#[test]
fn markdown_notes_are_made_html_and_html_is_left_alone() {
let feed = |items: &str| {
let xml = format!(
r#"<?xml version="1.0"?><rss version="2.0" xmlns:source="https://source.scripting.com/"><channel><title>R</title>{items}</channel></rss>"#
);
parse(xml.as_bytes()).unwrap().entries.into_iter().map(|e| e.description).collect::<Vec<_>>()
};
let got = feed(
r#"<item><guid>a</guid><description># Hi
Some **bold** and [a link](https://x.example/).</description></item>
<item><guid>b</guid><source:markdown>Only *Markdown* here.</source:markdown></item>
<item><guid>c</guid><description>&lt;p&gt;HTML **as sent**&lt;/p&gt;</description><source:markdown>ignored</source:markdown></item>
<item><guid>d</guid><description>1984. Plain text, left as it is.</description></item>"#,
);
assert_eq!(
got[0].as_deref(),
Some("<h1>Hi</h1>\n<p>Some <strong>bold</strong> and <a href=\"https://x.example/\">a link</a>.</p>\n")
);
assert_eq!(got[1].as_deref(), Some("<p>Only <em>Markdown</em> here.</p>\n"), "the Markdown copy, alone");
assert_eq!(got[2].as_deref(), Some("<p>HTML **as sent**</p>"), "HTML wins, untouched");
assert_eq!(got[3].as_deref(), Some("1984. Plain text, left as it is."));
}
#[test]
fn markdown_sent_beside_html_is_kept_for_the_page_to_switch_to() {
let xml = br#"<?xml version="1.0"?><rss version="2.0" xmlns:source="https://source.scripting.com/"><channel><title>R</title>
<item><guid>both</guid><description>&lt;p&gt;Hi&lt;/p&gt;</description><source:markdown>*Hi*</source:markdown></item>
<item><guid>alone</guid><source:markdown>*Hi*</source:markdown></item></channel></rss>"#;
let got: Vec<_> = parse(xml).unwrap().entries.into_iter().map(|e| e.markdown).collect();
assert_eq!(got, [Some("*Hi*".to_owned()), None], "only beside HTML; alone, it is the description (#284)");
}
#[test]
fn a_file_wordpress_lists_twice_is_one_enclosure() {
let xml = br#"<?xml version="1.0"?><rss version="2.0"><channel><title>R</title>
@@ -1352,9 +1468,12 @@ mod tests {
<enclosure url="https://x/ep.mp3?_=2" length="1" type="audio/mpeg"/>
<enclosure url="https://x/other.mp3?_=3&amp;key=k" length="1" type="audio/mpeg"/>
</item></channel></rss>"#;
let urls: Vec<String> =
parse(xml).unwrap().entries[0].enclosures.iter().map(|e| e.url.clone()).collect();
assert_eq!(urls, ["https://x/ep.mp3", "https://x/other.mp3?_=3&key=k"], "the repeat goes, a different file stays");
let urls: Vec<String> = parse(xml).unwrap().entries[0].enclosures.iter().map(|e| e.url.clone()).collect();
assert_eq!(
urls,
["https://x/ep.mp3", "https://x/other.mp3?_=3&key=k"],
"the repeat goes, a different file stays"
);
assert_eq!(same_file_key("https://x/a.mp3?key=k&_=2"), same_file_key("https://x/a.mp3?key=k"));
assert_ne!(same_file_key("https://x/a.mp3?_=x"), same_file_key("https://x/a.mp3"), "only a number");
}
@@ -1381,8 +1500,15 @@ mod tests {
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
assert_eq!(body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
assert_eq!(
body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(),
Some("<p>Notes</p>"),
"a whole body wins"
);
assert_eq!(
body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(),
Some("Plain notes, no tags.")
);
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
}
@@ -1401,14 +1527,19 @@ mod tests {
);
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
for d in ["This week we talk about tape, drums and a very long list.",
for d in [
"This week we talk about tape, drums and a very long list.",
"This week we talk about tape, drums…",
"This week we talk about\ntape [...]"] {
"This week we talk about\ntape [...]",
] {
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
}
let long = "word ".repeat(80);
assert_eq!(body(Some("<p>The post.</p>"), Some(&long)).as_deref(), Some("<p>The post.</p>"),
"a long description is notes, not a subtitle");
assert_eq!(
body(Some("<p>The post.</p>"), Some(&long)).as_deref(),
Some("<p>The post.</p>"),
"a long description is notes, not a subtitle"
);
}
#[test]
@@ -1421,10 +1552,7 @@ mod tests {
<enclosure url="https://x/a.mp3" length="1" type="audio/mpeg"/></item>
</channel></rss>"#;
let feed = parse(xml).unwrap();
assert!(
feed.entries[0].explicit,
"the entry says nothing; the feed-level flag must still mark it explicit"
);
assert!(feed.entries[0].explicit, "the entry says nothing; the feed-level flag must still mark it explicit");
}
#[test]
@@ -1454,13 +1582,17 @@ mod tests {
explain_failure("the feed's address answered with nothing at all").unwrap().reason,
"This address answers with nothing; the site may be gone."
);
let dns = explain_failure("connecting: dns error: failed to lookup address information: Name or service not known").unwrap();
let dns =
explain_failure("connecting: dns error: failed to lookup address information: Name or service not known")
.unwrap();
assert_eq!(dns.reason, "This address no longer resolves; the site may be gone.");
// A lookup that failed for the moment is not a site gone (#143).
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
assert!(is_temporary_lookup_failure(blip));
assert!(explain_failure(blip).is_none());
assert!(!is_temporary_lookup_failure("connecting: dns error: failed to lookup address information: Name or service not known"));
assert!(!is_temporary_lookup_failure(
"connecting: dns error: failed to lookup address information: Name or service not known"
));
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
@@ -1671,10 +1803,7 @@ mod tests {
let two = &f.entries[0].enclosures;
assert_eq!(two.len(), 2, "both enclosures survive");
assert_eq!(
two[0].url, "https://x/a.mp3?v=1&t=2",
"document order, and the escaped ampersand is decoded"
);
assert_eq!(two[0].url, "https://x/a.mp3?v=1&t=2", "document order, and the escaped ampersand is decoded");
assert_eq!(two[0].length, Some(111));
assert_eq!(two[1].url, "https://x/b.mp4");
assert_eq!(two[1].mime.as_deref(), Some("video/mp4"));
@@ -1777,4 +1906,3 @@ mod tests {
assert_eq!(category_path("Software How-To"), ("Technology".into(), None));
}
}

View File

@@ -12,6 +12,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.4.0] - 2026-10-19
### Added
- An item whose feed sends Markdown beside its HTML, as Scripting News does with `<source:markdown>`, keeps it: `markdown` and `markdown_html` in the API's items (#285).
- An item written in Markdown shows formatted, not as Markdown's raw characters: a description in Markdown, or, with no other text, RSS's `<source:markdown>`. HTML a feed sends is left as it is (#284).
- A show from an OPML subscription or a Patreon creator can be kept: `kept` on `PATCH /api/feeds/{id}` and in the feed list. When the OPML drops a kept show, or you leave the OPML, it stays on your list as a feed of its own (#280).
- `/metrics` counts Signals: items judged and fitting, calls to Jev by how they ended, the time they took and the tokens TypeSafe counted, by person and by whose key paid so the server's bill can be split, time since the judge's last pass, and keys refused; a failed call to Jev shows as a failed trace (#270, #275, #278).
- An admin setting for how many days back a new Signal reads (#276).
- A list's trace says which filter, sort and page it was asked for, and whether a search, so a slow list can be told from the others (#287).
- A feed, an OPML subscription or a Patreon creator can be left out of All Subscriptions: `hide_from_all` on `PATCH /api/feeds/{id}` and in the feed list. Set on an OPML or creator it covers every show in it; Signals and Currently Listening still read them (#288).
- A feed, an OPML subscription or a Patreon creator can be kept out of Signals: `hide_from_signals` on `PATCH /api/feeds/{id}` and in the feed list. Jev is not asked about its items, so they cost nothing, and they are left out of each Signal's list and counts (#289).
- An admin setting, Signals for everyone on the server's TypeSafe key, so the server pays for Jev instead of each person (#271).
### Changed
- `GET /api/feeds/{id}/entries` for an OPML subscription or a Patreon creator lists the items of the shows in it that you subscribe to, together, where it listed nothing (#283).
- A TypeSafe key is checked with TypeSafe before it is saved, and one it refuses is not saved (#271).
- A new Signal reads the last week instead of the last 30 days, unless an admin sets otherwise (#276).
- Editing a Signal's name, words or description asks again only about what it had picked out, and about new items as they come, instead of the whole window again (#276).
- Signals fill faster and cost less: up to ten calls to TypeSafe at once instead of one, paced under TypeSafe's limits of 80 calls and 100,000 input tokens a second, with every call held back while TypeSafe answers 429 (#277), everyone's Signals on an item in one call on the server's key, and an item's text cut at 1,000 characters instead of 2,000 (#275).
### Fixed
- Artwork comes quickly: an image already kept is served without asking the database again, and the database finds which feed names an image by an index instead of reading every item, which took over half a second an image (#272).
- The Directory shows the artwork of podcasts nobody on this server subscribes to, where it drew their initials (#273).
- Artwork a publisher sends without an image type, or as `application/octet-stream`, is shown when it is a JPEG, PNG, GIF or WebP, instead of initials (#279).
- Looking for items to read for Signals read every item on the server on each scan; it now reads only recent ones (#274).
- With the downloads folder out of reach during a clean-up, as when the array stops, every downloaded episode was taken as deleted by hand and forgotten; the clean-up now leaves them alone until the folder is back (#293).
- A feed address that serves something larger than 50 MB, such as an episode's file given as a feed, is refused as too large, instead of read whole into memory at every check (#295).
- Searching items for `%` or `_` finds those characters, instead of treating them as wildcards that matched nearly everything (#296).
### Security
- ammonia, which cleans publishers' HTML, updated to 4.2.4 for three advisories, among them XSS through SVG animation tags; rustls to 0.23.46 for five and h2 to 0.4.20 for one, in every connection ipx makes (#281).
- The address of a file being downloaded, which for Patreon carries its signature, is sent to open pages and written to the log without its query, as other addresses already were (#292).
## [1.3.0] - 2026-10-16
### Added
@@ -848,7 +885,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.3.0...main
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.4.0...main
[1.4.0]: https://git.sdf1.net/rays/ipx/compare/v1.3.0...v1.4.0
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/v1.2.0...v1.3.0
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0

View File

@@ -1,7 +1,8 @@
[package]
name = "ipx"
version = "1.3.0"
version = "1.4.0"
edition.workspace = true
publish = false
[dependencies]
ipx-feed.workspace = true
@@ -10,7 +11,6 @@ ammonia.workspace = true
anyhow.workspace = true
argon2.workspace = true
flate2.workspace = true
atom_syndication.workspace = true
axum.workspace = true
chrono.workspace = true
clap.workspace = true
@@ -20,9 +20,7 @@ librqbit.workspace = true
opentelemetry.workspace = true
opml.workspace = true
percent-encoding.workspace = true
quick-xml.workspace = true
reqwest.workspace = true
rss.workspace = true
sea-orm.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -103,11 +103,8 @@ impl Keys {
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
fn store(&self, set: JwkSet) {
let keys = set
.keys
.iter()
.filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?)))
.collect();
let keys =
set.keys.iter().filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?))).collect();
let mut c = self.cache.lock().unwrap();
c.keys = keys;
c.fetched = Some(Instant::now());
@@ -181,8 +178,7 @@ mod tests {
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
assert_eq!(check(&keys, &hs).await, None, "HS256");
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ",
ok.split('.').nth(1).unwrap());
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ", ok.split('.').nth(1).unwrap());
assert_eq!(check(&keys, &none).await, None, "alg none");
}
@@ -191,16 +187,25 @@ mod tests {
let keys = Keys::default();
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
let fetches = std::sync::atomic::AtomicUsize::new(0);
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"),
"a key not cached yet is fetched");
let count = || {
fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async { Ok(jwks()) }
};
assert_eq!(
keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(),
Some("rays@sdf1.net"),
"a key not cached yet is fetched"
);
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
// A made-up key id straight after: not fetched again.
let mut h = Header::new(Algorithm::RS256);
h.kid = Some("nobody".into());
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
let count = || {
fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async { Ok(jwks()) }
};
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
}

View File

@@ -1,8 +1,8 @@
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
//! of each publisher's server for every visit, and still there when a publisher is not.
use std::path::{Path, PathBuf};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};

View File

@@ -25,9 +25,7 @@ pub fn verify_password(password: &str, stored: &str) -> bool {
tracing::warn!("stored password hash is unreadable; refusing the sign-in");
return false;
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
}
/// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong

View File

@@ -50,6 +50,12 @@ pub struct General {
pub directory_token: String,
/// Send the directory the public feeds people here subscribe to, and how many of them do.
pub directory_share: bool,
/// Signals are judged on the server's TypeSafe key (`TYPESAFE_KEY`), for everyone, rather than
/// on each person's own (#271). Without that key it changes nothing.
pub signals_on_server: bool,
/// How far back a new Signal looks, in days (#276): each item is a call to TypeSafe, and a
/// month of production's feeds was about 8,000.
pub signal_days: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
@@ -207,19 +213,15 @@ impl Default for General {
directory_url: String::new(),
directory_token: String::new(),
directory_share: false,
signals_on_server: false,
signal_days: 7,
}
}
}
impl Default for Torrent {
fn default() -> Self {
Self {
enabled: true,
seed_ratio: 1.0,
seed_time_mins: 60,
port_range: "6881-6889".into(),
stall_mins: 30,
}
Self { enabled: true, seed_ratio: 1.0, seed_time_mins: 60, port_range: "6881-6889".into(), stall_mins: 30 }
}
}
@@ -301,14 +303,11 @@ impl Config {
// ponytail: a missing config is an empty one, so `list`/`add` work on a fresh install.
return Ok(Self::default());
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading {}", path.display()))?;
let mut cfg: Self =
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let mut cfg: Self = toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
cfg.general.download_dir = expand_tilde(&cfg.general.download_dir);
Ok(cfg)
}
}
/// What the database keeps of the configuration (issue #18): the server settings the admin page
@@ -331,6 +330,14 @@ pub struct Stored {
pub directory_token: String,
#[serde(default)]
pub directory_share: bool,
#[serde(default)]
pub signals_on_server: bool,
#[serde(default = "signal_days")]
pub signal_days: u64,
}
fn signal_days() -> u64 {
General::default().signal_days
}
fn art_cache_mb() -> u64 {
@@ -338,7 +345,7 @@ fn art_cache_mb() -> u64 {
}
/// `[general]` keys that live in the database once it holds the configuration.
const STORED_KEYS: [&str; 9] = [
const STORED_KEYS: [&str; 11] = [
"schedule",
"max_total_gb",
"max_age_days",
@@ -348,6 +355,8 @@ const STORED_KEYS: [&str; 9] = [
"directory_url",
"directory_token",
"directory_share",
"signals_on_server",
"signal_days",
];
impl Stored {
@@ -363,6 +372,8 @@ impl Stored {
directory_url: g.directory_url.clone(),
directory_token: g.directory_token.clone(),
directory_share: g.directory_share,
signals_on_server: g.signals_on_server,
signal_days: g.signal_days,
}
}
@@ -377,6 +388,8 @@ impl Stored {
g.directory_url = self.directory_url;
g.directory_token = self.directory_token;
g.directory_share = self.directory_share;
g.signals_on_server = self.signals_on_server;
g.signal_days = self.signal_days;
}
}
@@ -493,10 +506,7 @@ pub fn unique_slug(text: &str, taken: &BTreeMap<String, Feed>) -> String {
/// `$var`, or `~/fallback` when it is unset or empty, as the XDG base directory spec says.
fn xdg(var: &str, fallback: &str) -> PathBuf {
std::env::var_os(var)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home().join(fallback))
std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from).unwrap_or_else(|| home().join(fallback))
}
fn home() -> PathBuf {
@@ -575,10 +585,20 @@ mod tests {
#[test]
fn intervals_parse_from_the_forms_people_actually_type() {
for (input, want) in [
("every 30m", 30), ("30m", 30), ("30", 30), ("every 30 minutes", 30),
("every 4h", 240), ("4h", 240), ("4 hours", 240), ("EVERY 4H", 240),
("1d", 1440), ("every 2 days", 2880), (" every 90m ", 90),
("1w", 10080), ("every 2 weeks", 20160), ("2 w", 20160),
("every 30m", 30),
("30m", 30),
("30", 30),
("every 30 minutes", 30),
("every 4h", 240),
("4h", 240),
("4 hours", 240),
("EVERY 4H", 240),
("1d", 1440),
("every 2 days", 2880),
(" every 90m ", 90),
("1w", 10080),
("every 2 weeks", 20160),
("2 w", 20160),
] {
assert_eq!(parse_interval(input), Some(want), "{input:?}");
}
@@ -650,11 +670,26 @@ mod tests {
assert!(slug(&"x".repeat(100)).len() <= 40);
let mut taken = BTreeMap::new();
taken.insert("the-daily".to_string(), Feed {
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None, category: None, listed: false, private: false,
});
taken.insert(
"the-daily".to_string(),
Feed {
url: "u".into(),
folder: None,
group: None,
media_types: None,
schedule: None,
keywords: vec![],
allow_explicit: false,
auto_download: true,
max_new_per_check: None,
username: None,
password: None,
password_env: None,
category: None,
listed: false,
private: false,
},
);
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
}

File diff suppressed because it is too large Load Diff

View File

@@ -164,8 +164,7 @@ pub fn pieces(frames: &[(u64, u64)], drop: &[(u64, u64)], frame: f64) -> Vec<(u6
}
let dropped = drops.peek().is_some_and(|&&(a, _)| a as usize <= n);
// Something between two frames (an ID3 tag partway through) ends a piece too.
let joins = !dropped
&& run.is_some_and(|(f, c)| c < most && frames[f + c - 1].0 + frames[f + c - 1].1 == pos);
let joins = !dropped && run.is_some_and(|(f, c)| c < most && frames[f + c - 1].0 + frames[f + c - 1].1 == pos);
if !joins && let Some(r) = run.take() {
push(&mut out, r);
}
@@ -261,9 +260,7 @@ impl Wanting {
}
pub fn wants(&self, user: i64, feed: &str) -> bool {
self.people
.get(&user)
.is_some_and(|p| wants(p, feed, self.groups.get(feed).map(String::as_str)))
self.people.get(&user).is_some_and(|p| wants(p, feed, self.groups.get(feed).map(String::as_str)))
}
/// The pause this person has DeadAir leave in a feed: the feed's, else its group's.
@@ -415,14 +412,15 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<
let todo = ctx.db.deadair_todo().await?;
// For /metrics (#195): files of feeds someone has DeadAir on, from memory, without the
// per-file subscription and finished checks below.
let wanted = |t: &crate::db::DeadAirTodo| !failed.contains(&t.id) && wanting.people.keys().any(|&u| wanting.wants(u, &t.feed_id));
let wanted = |t: &crate::db::DeadAirTodo| {
!failed.contains(&t.id) && wanting.people.keys().any(|&u| wanting.wants(u, &t.feed_id))
};
st.deadair_backlog.store(todo.iter().filter(|t| wanted(t)).count() as i64, Relaxed);
for t in todo {
if failed.contains(&t.id) {
continue;
}
let users: Vec<i64> =
wanting.people.keys().copied().filter(|&u| wanting.wants(u, &t.feed_id)).collect();
let users: Vec<i64> = wanting.people.keys().copied().filter(|&u| wanting.wants(u, &t.feed_id)).collect();
if users.is_empty() || !ctx.db.subscribed_any(&users, &t.feed_id).await? {
continue;
}
@@ -446,11 +444,18 @@ async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<
return Ok(true);
}
};
ctx.db.store_plans(t.id, &serde_json::to_string(&plans)?).instrument(span.in_scope(|| step("store_plans"))).await?;
ctx.db
.store_plans(t.id, &serde_json::to_string(&plans)?)
.instrument(span.in_scope(|| step("store_plans")))
.await?;
let usual = plans.get(&DEFAULT_PAUSE);
span.record("cuts", usual.map_or(0, |p| p.cuts.len()));
let saved_ms = usual.map_or(0, Plan::saved_ms);
ctx.out.emit(crate::ipc::Event::DeadairDone { feed: t.feed_id.clone(), enclosure: t.id, saved_s: saved_ms / 1000 });
ctx.out.emit(crate::ipc::Event::DeadairDone {
feed: t.feed_id.clone(),
enclosure: t.id,
saved_s: saved_ms / 1000,
});
// The event says it too (ev="deadair_done"); this line has how long it took and how many cuts.
tracing::info!(
feed = t.feed_id,
@@ -472,8 +477,17 @@ pub async fn analyse(src: &Path) -> Result<Option<BTreeMap<i64, Plan>>> {
let Some((first, frame)) = probe(src).instrument(step("probe")).await? else { return Ok(None) };
let path = src.display().to_string();
let log = ffmpeg(&[
"-hide_banner", "-nostats", "-i", &path, "-map", "0:a:0",
"-af", &format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"), "-f", "null", "-",
"-hide_banner",
"-nostats",
"-i",
&path,
"-map",
"0:a:0",
"-af",
&format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"),
"-f",
"null",
"-",
])
.instrument(step("silencedetect"))
.await
@@ -492,16 +506,15 @@ pub async fn analyse(src: &Path) -> Result<Option<BTreeMap<i64, Plan>>> {
/// Where each audio frame is in the file, as (byte offset, bytes), in order.
async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
let out = run_out("ffprobe", &[
"-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pos,size", "-of", "compact=p=0", path,
])
let out = run_out(
"ffprobe",
&["-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pos,size", "-of", "compact=p=0", path],
)
.await?;
// ffprobe writes the fields in its own order, not the order asked for: by name, then.
out.lines()
.map(|l| {
let field = |k: &str| {
l.split('|').find_map(|f| f.strip_prefix(k)?.strip_prefix('=')?.parse::<u64>().ok())
};
let field = |k: &str| l.split('|').find_map(|f| f.strip_prefix(k)?.strip_prefix('=')?.parse::<u64>().ok());
field("pos").zip(field("size")).with_context(|| format!("reading ffprobe's frame: {l}"))
})
.collect()
@@ -511,26 +524,44 @@ async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
/// can shorten; None for one it cannot. A cover picture in an MP3 is not video.
async fn probe(src: &Path) -> Result<Option<(f64, f64)>> {
let path = src.display().to_string();
let out = run_out("ffprobe", &[
"-v", "error", "-show_entries",
"format=format_name:stream=codec_type,codec_name:stream_disposition=attached_pic", "-of", "json", &path,
])
let out = run_out(
"ffprobe",
&[
"-v",
"error",
"-show_entries",
"format=format_name:stream=codec_type,codec_name:stream_disposition=attached_pic",
"-of",
"json",
&path,
],
)
.await?;
let v: serde_json::Value = serde_json::from_str(&out).context("reading ffprobe")?;
let streams = v["streams"].as_array().cloned().unwrap_or_default();
let video = streams
.iter()
.any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
let video = streams.iter().any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
let audio = streams.iter().find(|s| s["codec_type"] == "audio");
let format = v["format"]["format_name"].as_str().unwrap_or("");
let plain = |codec: &str| format == codec && audio.is_some_and(|a| a["codec_name"] == codec);
if video || !(plain("mp3") || plain("aac")) {
return Ok(None);
}
let out = run_out("ffprobe", &[
"-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pts_time,duration_time",
"-read_intervals", "%+#1", "-of", "csv=p=0", &path,
])
let out = run_out(
"ffprobe",
&[
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"packet=pts_time,duration_time",
"-read_intervals",
"%+#1",
"-of",
"csv=p=0",
&path,
],
)
.await?;
let mut f = out.trim().split(',').map(|x| x.trim().parse::<f64>());
match (f.next(), f.next()) {
@@ -682,9 +713,16 @@ mod tests {
for (ext, codec) in [("mp3", "libmp3lame"), ("aac", "aac"), ("m4a", "aac")] {
let src = dir.join(format!("t.{ext}"));
ffmpeg(&[
"-v", "error", "-y", "-f", "lavfi", "-i",
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
"aevalsrc='if(lt(mod(t,3),1),0.5*sin(2*PI*440*t),0)':d=12:s=44100",
"-c:a", codec, &src.display().to_string(),
"-c:a",
codec,
&src.display().to_string(),
])
.await
.unwrap();
@@ -707,7 +745,20 @@ mod tests {
let len = |p: &Path| {
let p = p.display().to_string();
async move {
run_out("ffprobe", &["-v", "error", "-select_streams", "a:0", "-show_entries", "packet=duration_time", "-of", "csv=p=0", &p])
run_out(
"ffprobe",
&[
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"packet=duration_time",
"-of",
"csv=p=0",
&p,
],
)
.await
.unwrap()
.lines()
@@ -718,7 +769,12 @@ mod tests {
let short = len(&src).await - len(&list).await;
assert!((short * 1000.0 - cut as f64).abs() < 100.0, "{ext}: {short} s shorter, {cut} ms cut");
// Decodes without a complaint.
assert!(ffmpeg(&["-v", "error", "-i", &list.display().to_string(), "-f", "null", "-"]).await.unwrap().is_empty());
assert!(
ffmpeg(&["-v", "error", "-i", &list.display().to_string(), "-f", "null", "-"])
.await
.unwrap()
.is_empty()
);
// Saved as a file of its own (#186): as long as the playlist, and it plays.
let (n, body) = kept(&src, p).await.unwrap();
let parts: Vec<_> = futures_util::TryStreamExt::try_collect(body).await.unwrap();
@@ -730,7 +786,12 @@ mod tests {
let saved = dir.join(format!("saved.{ext}"));
std::fs::write(&saved, &bytes).unwrap();
assert!((len(&saved).await - len(&list).await).abs() < 0.05, "{ext}");
assert!(ffmpeg(&["-v", "error", "-i", &saved.display().to_string(), "-f", "null", "-"]).await.unwrap().is_empty());
assert!(
ffmpeg(&["-v", "error", "-i", &saved.display().to_string(), "-f", "null", "-"])
.await
.unwrap()
.is_empty()
);
}
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -750,6 +811,11 @@ async fn real_file() {
let name = src.file_name().unwrap().to_string_lossy().into_owned();
for (pause, p) in &plans {
std::fs::write(src.with_file_name(format!("{name}.{pause}.m3u8")), playlist(p, &name)).unwrap();
eprintln!("{pause} ms: {} cuts, {:.1} min saved, {} pieces", p.cuts.len(), p.saved_ms() as f64 / 60_000.0, p.pieces.len());
eprintln!(
"{pause} ms: {} cuts, {:.1} min saved, {} pieces",
p.cuts.len(),
p.saved_ms() as f64 / 60_000.0,
p.pieces.len()
);
}
}

View File

@@ -26,8 +26,12 @@ pub enum DirError {
impl std::fmt::Display for DirError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DirError::NotConfigured => write!(f, "No directory is set up on this server. An admin sets one in Admin, Directory."),
DirError::Refused(401, _) => write!(f, "The directory does not accept this server's token. It may have been revoked."),
DirError::NotConfigured => {
write!(f, "No directory is set up on this server. An admin sets one in Admin, Directory.")
}
DirError::Refused(401, _) => {
write!(f, "The directory does not accept this server's token. It may have been revoked.")
}
DirError::Refused(403, msg) => write!(f, "The directory refused: {msg}."),
DirError::Refused(404, _) => write!(f, "That is not in the directory."),
DirError::Refused(code, msg) => write!(f, "The directory answered {code}: {msg}"),
@@ -58,9 +62,18 @@ pub fn configured(cfg: &config::Config) -> bool {
/// A span the whole call, its answer read and parsed included: the directory's own span ends when it
/// has answered, so a slow read of a large answer showed under no span (#250).
#[tracing::instrument(name = "directory_call", skip_all, fields(http.request.method = %method, url.path = %path.split('?').next().unwrap_or(path), http.response.body.size))]
async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &str, body: Option<&[SnapshotEntry]>) -> Result<T, DirError> {
async fn call<T: DeserializeOwned>(
ctx: &Ctx,
method: reqwest::Method,
path: &str,
body: Option<&[SnapshotEntry]>,
) -> Result<T, DirError> {
let (base, token) = target(&ctx.cfg()).ok_or(DirError::NotConfigured)?;
let mut req = ctx.client.request(method, format!("{base}{path}")).bearer_auth(token).timeout(std::time::Duration::from_secs(20));
let mut req = ctx
.client
.request(method, format!("{base}{path}"))
.bearer_auth(token)
.timeout(std::time::Duration::from_secs(20));
// The directory's spans join this request's trace (#213).
if let Some(tp) = ipx_directory::otel::traceparent() {
req = req.header("traceparent", tp);
@@ -80,13 +93,19 @@ async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &st
let resp = req.send().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
let status = resp.status();
if !status.is_success() {
let msg = resp.json::<serde_json::Value>().await.ok().and_then(|v| v["error"].as_str().map(str::to_owned)).unwrap_or_default();
let msg = resp
.json::<serde_json::Value>()
.await
.ok()
.and_then(|v| v["error"].as_str().map(str::to_owned))
.unwrap_or_default();
return Err(DirError::Refused(status.as_u16(), msg));
}
// A removal or an unblock answers 204, with nothing to read: that reads as null, which is ().
let body = resp.bytes().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
tracing::Span::current().record("http.response.body.size", body.len());
serde_json::from_slice(if body.is_empty() { b"null" } else { &body }).map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}")))
serde_json::from_slice(if body.is_empty() { b"null" } else { &body })
.map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}")))
}
fn seg(s: &str) -> percent_encoding::PercentEncode<'_> {
@@ -98,15 +117,37 @@ pub async fn me(ctx: &Ctx) -> Result<Me, DirError> {
}
pub async fn feeds(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
call(ctx, reqwest::Method::GET, "/v1/feeds", None).await
let got: Vec<Listing> = call(ctx, reqwest::Method::GET, "/v1/feeds", None).await?;
note_art(got.iter());
Ok(got)
}
pub async fn popular(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await
let got: Vec<Listing> = call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await?;
note_art(got.iter());
Ok(got)
}
pub async fn page(ctx: &Ctx, id: &str) -> Result<FeedPage, DirError> {
call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await
let got: FeedPage = call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await?;
note_art(std::iter::once(&got.listing));
Ok(got)
}
/// Artwork the directory has listed, which `/api/art` serves though no feed here names it: the
/// Directory shows podcasts nobody here subscribes to, and drew their initials instead (#273).
///
/// shortcut: in memory, one address a listed feed, forgotten on a restart until the Directory is
/// next read, which the page does when it loads; a table if that ever shows as initials.
static LISTED_ART: std::sync::LazyLock<std::sync::Mutex<HashSet<String>>> = std::sync::LazyLock::new(Default::default);
fn note_art<'a>(listings: impl Iterator<Item = &'a Listing>) {
let mut art = LISTED_ART.lock().unwrap_or_else(|e| e.into_inner());
art.extend(listings.filter_map(|l| l.image.clone()));
}
pub fn listed_art(url: &str) -> bool {
LISTED_ART.lock().unwrap_or_else(|e| e.into_inner()).contains(url)
}
// Administering the directory (#206): each needs an rwa token, which the directory checks.
@@ -211,7 +252,8 @@ pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receive
let key = serde_json::to_vec(&(target(&cfg), &entries)).unwrap_or_default();
let forced = ctx.directory_resend.swap(false, std::sync::atomic::Ordering::Relaxed);
if forced || resend(last.as_ref(), &key) {
let span = tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len());
let span =
tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len());
let got = tracing::Instrument::instrument(share(&ctx, &entries), span).await;
if got.is_ok() {
last = Some((key, std::time::Instant::now()));
@@ -225,7 +267,12 @@ pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receive
};
if let Some(got) = got {
match &got {
Ok(r) => tracing::info!(accepted = r.accepted, refused_private = r.refused_private, refused_blocked = r.refused_blocked, "shared with the directory"),
Ok(r) => tracing::info!(
accepted = r.accepted,
refused_private = r.refused_private,
refused_blocked = r.refused_blocked,
"shared with the directory"
),
Err(e) => tracing::warn!(error = %e, "could not share with the directory"),
}
let (result, error) = match got {
@@ -254,6 +301,23 @@ fn resend(last: Option<&(Vec<u8>, std::time::Instant)>, key: &[u8]) -> bool {
mod tests {
use super::*;
#[test]
fn artwork_the_directory_listed_is_let_through_and_nothing_else() {
let listing = |image: Option<&str>| Listing {
id: "x".into(),
url: "https://x.example/feed".into(),
title: None,
image: image.map(str::to_owned),
category: None,
subcategory: None,
podcast: true,
subscribers: 1,
};
note_art([listing(Some("https://img.example/cover.jpg")), listing(None)].iter());
assert!(listed_art("https://img.example/cover.jpg"));
assert!(!listed_art("http://192.168.1.1/admin"));
}
fn sub(id: &str, url: &str, edit: impl FnOnce(&mut config::Feed)) -> Sub {
let mut cfg: config::Feed = serde_json::from_value(serde_json::json!({ "url": url })).unwrap();
edit(&mut cfg);

View File

@@ -85,16 +85,8 @@ pub fn filename_for(url: &str, content_disposition: Option<&str>) -> String {
}
let from_url = url::Url::parse(url)
.ok()
.and_then(|u| {
u.path_segments()
.and_then(|s| s.filter(|p| !p.is_empty()).next_back())
.map(str::to_owned)
})
.map(|seg| {
percent_encoding::percent_decode_str(&seg)
.decode_utf8_lossy()
.into_owned()
})
.and_then(|u| u.path_segments().and_then(|mut s| s.rfind(|p| !p.is_empty())).map(str::to_owned))
.map(|seg| percent_encoding::percent_decode_str(&seg).decode_utf8_lossy().into_owned())
.unwrap_or_default();
sanitize(&from_url)
}
@@ -105,11 +97,7 @@ fn disposition_filename(header: &str) -> Option<String> {
// RFC 5987 form wins when both are present.
if let Some(v) = part.strip_prefix("filename*=") {
let raw = v.rsplit('\'').next().unwrap_or(v);
return Some(
percent_encoding::percent_decode_str(raw)
.decode_utf8_lossy()
.into_owned(),
);
return Some(percent_encoding::percent_decode_str(raw).decode_utf8_lossy().into_owned());
}
}
for part in header.split(';') {
@@ -155,21 +143,15 @@ pub async fn download(
let name = filename_for(
resp.url().as_str(),
resp.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok()),
resp.headers().get(reqwest::header::CONTENT_DISPOSITION).and_then(|v| v.to_str().ok()),
);
let total = resp.content_length();
let tmp_dir = cfg.general.download_dir.join(".ipx-incomplete");
tokio::fs::create_dir_all(&tmp_dir)
.await
.with_context(|| format!("creating {}", tmp_dir.display()))?;
tokio::fs::create_dir_all(&tmp_dir).await.with_context(|| format!("creating {}", tmp_dir.display()))?;
let tmp = tmp_dir.join(&name);
let mut file = tokio::fs::File::create(&tmp)
.await
.with_context(|| format!("creating {}", tmp.display()))?;
let mut file = tokio::fs::File::create(&tmp).await.with_context(|| format!("creating {}", tmp.display()))?;
let mut done: u64 = 0;
let mut stream = resp.bytes_stream();
let _active = crate::metrics::busy(&crate::metrics::STATS.downloads_active);
@@ -199,13 +181,9 @@ pub async fn download(
/// Moves a completed download into its folder. Same filesystem as the incomplete dir, so
/// this is a rename rather than the original's copy-then-unlink.
pub async fn place(d: &Downloaded, dir: &Path) -> Result<PathBuf> {
tokio::fs::create_dir_all(dir)
.await
.with_context(|| format!("creating {}", dir.display()))?;
tokio::fs::create_dir_all(dir).await.with_context(|| format!("creating {}", dir.display()))?;
let dest = unique_path(dir, &d.name);
tokio::fs::rename(&d.tmp, &dest)
.await
.with_context(|| format!("moving into {}", dest.display()))?;
tokio::fs::rename(&d.tmp, &dest).await.with_context(|| format!("moving into {}", dest.display()))?;
Ok(dest)
}
@@ -281,15 +259,8 @@ fn unique_path(dir: &Path, name: &str) -> PathBuf {
if !candidate.exists() {
return candidate;
}
let stem = Path::new(name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(name);
let ext = Path::new(name)
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{e}"))
.unwrap_or_default();
let stem = Path::new(name).file_stem().and_then(|s| s.to_str()).unwrap_or(name);
let ext = Path::new(name).extension().and_then(|e| e.to_str()).map(|e| format!(".{e}")).unwrap_or_default();
for n in 2..1000 {
let candidate = dir.join(format!("{stem}-{n}{ext}"));
if !candidate.exists() {
@@ -308,12 +279,7 @@ pub fn folder_for(cfg: &Config, id: &str, feed_cfg: &FeedCfg, title: Option<&str
match cfg.general.organize {
Organize::Date => chrono::Local::now().format("%m-%d-%Y").to_string(),
Organize::Feed => {
let raw = feed_cfg
.folder
.as_deref()
.or(title)
.filter(|s| !s.trim().is_empty())
.unwrap_or(id);
let raw = feed_cfg.folder.as_deref().or(title).filter(|s| !s.trim().is_empty()).unwrap_or(id);
raw.split('/')
.map(str::trim)
.filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
@@ -410,19 +376,13 @@ mod tests {
"ep1.mp3",
"query string is not part of the name"
);
assert_eq!(
filename_for("https://x.com/media/My%20Show%20Ep%201.mp3", None),
"My Show Ep 1.mp3"
);
assert_eq!(filename_for("https://x.com/media/My%20Show%20Ep%201.mp3", None), "My Show Ep 1.mp3");
assert_eq!(
filename_for("https://x.com/dl?id=9", Some("attachment; filename=\"real name.mp3\"")),
"real name.mp3",
"Content-Disposition overrides the URL"
);
assert_eq!(
filename_for("https://x.com/dl", Some("attachment; filename*=UTF-8''caf%C3%A9.mp3")),
"café.mp3"
);
assert_eq!(filename_for("https://x.com/dl", Some("attachment; filename*=UTF-8''caf%C3%A9.mp3")), "café.mp3");
assert_eq!(filename_for("https://x.com/", None), "download");
assert_eq!(
filename_for("https://x.com/a/../../etc/passwd", None),
@@ -436,9 +396,21 @@ mod tests {
let mut cfg = Config::default();
cfg.general.download_dir = "/tmp".into();
let mut f = crate::config::Feed {
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
max_new_per_check: None, username: None, password: None, password_env: None, category: None, listed: false, private: false,
url: "u".into(),
folder: Some("Subscriptions/Some | Show".into()),
group: None,
media_types: None,
schedule: None,
keywords: vec![],
allow_explicit: false,
auto_download: true,
max_new_per_check: None,
username: None,
password: None,
password_env: None,
category: None,
listed: false,
private: false,
};
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");

View File

@@ -78,6 +78,9 @@ pub mod entries {
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub first_seen: i64,
/// The item's Markdown, when its feed sends it beside the HTML (#285).
#[sea_orm(column_type = "Text", nullable)]
pub markdown: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub image: Option<String>,
pub duration: Option<i64>,
@@ -216,6 +219,17 @@ pub mod subscriptions {
/// Pinned to the top of this person's feed list, a feed inside a folder included.
#[sea_orm(default_value = false)]
pub pinned: bool,
/// A feed from an OPML or Patreon list this person keeps when the list drops it, or when
/// they leave the list: it becomes a feed of its own instead of going (#280).
#[sea_orm(default_value = false)]
pub kept: bool,
/// Left out of All Subscriptions: this feed, or on an OPML or Patreon creator every show
/// in it (#288).
#[sea_orm(default_value = false)]
pub hide_from_all: bool,
/// Left out of every Signal, and never put to Jev for them, as `hide_from_all` (#289).
#[sea_orm(default_value = false)]
pub hide_from_signals: bool,
}
owned_by_user!();
@@ -333,6 +347,10 @@ pub mod signal_verdicts {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
pub hit: bool,
/// A picked-out item to ask about again, since its Signal changed (#276). It stays in the
/// Signal's list until Jev has answered the new question.
#[sea_orm(default_value = false)]
pub recheck: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -11,13 +11,31 @@ use tokio::sync::{broadcast, mpsc};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "ev", rename_all = "snake_case")]
pub enum Event {
FeedStart { feed: String },
FeedSkip { feed: String, reason: String },
FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize },
FeedError { feed: String, msg: String },
FeedStart {
feed: String,
},
FeedSkip {
feed: String,
reason: String,
},
FeedDone {
feed: String,
new: usize,
downloaded: usize,
failed: usize,
torrents: usize,
},
FeedError {
feed: String,
msg: String,
},
/// The feed answered from a new address after redirects that all said it moved for good,
/// and the catalogue now has that address (#115).
FeedMoved { feed: String, from: String, to: String },
FeedMoved {
feed: String,
from: String,
to: String,
},
Progress {
feed: String,
/// Which enclosure this is about. Without it a UI cannot tell one download's
@@ -29,14 +47,38 @@ pub enum Event {
#[serde(skip_serializing_if = "Option::is_none")]
total: Option<u64>,
},
DownloadDone { feed: String, enclosure: i64, url: String, path: String, bytes: u64 },
DownloadError { feed: String, enclosure: i64, url: String, msg: String },
TorrentDeferred { feed: String, url: String },
DownloadDone {
feed: String,
enclosure: i64,
url: String,
path: String,
bytes: u64,
},
DownloadError {
feed: String,
enclosure: i64,
url: String,
msg: String,
},
TorrentDeferred {
feed: String,
url: String,
},
/// DeadAir finished a file's copy (#171), so a page showing it as in progress can stop.
DeadairDone { feed: String, enclosure: i64, saved_s: i64 },
DeadairDone {
feed: String,
enclosure: i64,
saved_s: i64,
},
/// Jev found items for someone's Signals (#258), so their page fetches the counts again.
SignalHits { user: i64, hits: usize },
Reaped { path: String, bytes: u64 },
SignalHits {
user: i64,
hits: usize,
},
Reaped {
path: String,
bytes: u64,
},
/// Terminal: a client that asked for work stops reading here.
ScanDone {
feeds: usize,
@@ -47,9 +89,18 @@ pub enum Event {
#[serde(default)]
forced: bool,
},
ReapDone { files: usize, bytes: u64 },
Status { feeds: usize, pending: i64, downloaded: i64 },
Error { msg: String },
ReapDone {
files: usize,
bytes: u64,
},
Status {
feeds: usize,
pending: i64,
downloaded: i64,
},
Error {
msg: String,
},
}
impl Event {
@@ -80,10 +131,9 @@ impl Event {
Event::Reaped { path, bytes } => {
format!("deleted {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
}
Event::ReapDone { files, bytes } => format!(
"deleted {files} old file(s), {:.1} MB",
*bytes as f64 / 1_048_576.0
),
Event::ReapDone { files, bytes } => {
format!("deleted {files} old file(s), {:.1} MB", *bytes as f64 / 1_048_576.0)
}
Event::Status { feeds, pending, downloaded } => {
format!("{feeds} feeds, {pending} queued to download, {downloaded} downloaded")
}
@@ -91,7 +141,9 @@ impl Event {
// Noise in a terminal; a UI still gets them on the socket.
Event::FeedStart { feed } => format!("{feed}: checking"),
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
Event::DeadairDone { feed, saved_s, .. } => {
format!("{feed}: DeadAir found the silences, {saved_s} s shorter")
}
Event::SignalHits { hits, .. } => format!("{hits} item(s) picked out for Signals"),
Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
})
@@ -165,7 +217,9 @@ impl Emitter {
// An error here only means nobody is listening yet.
let _ = tx.send(e.clone());
}
if self.print && let Some(line) = e.human() {
if self.print
&& let Some(line) = e.human()
{
println!("{line}");
}
}
@@ -182,7 +236,10 @@ impl Event {
*url = redact(url);
*msg = redact_text(msg);
}
Event::TorrentDeferred { url, .. } => *url = redact(url),
// Progress and DownloadDone too: a Patreon file's address carries its signature (#292).
Event::TorrentDeferred { url, .. } | Event::Progress { url, .. } | Event::DownloadDone { url, .. } => {
*url = redact(url)
}
Event::FeedMoved { from, to, .. } => {
*from = redact(from);
*to = redact(to);
@@ -262,9 +319,8 @@ pub async fn daemon_is_live(path: &Path) -> bool {
/// the meantime. The answer goes to the client that asked and no one else: broadcast, it ended
/// any `ipx fetch` that was watching a scan, since `status` is a terminal event.
/// A future, since reading the counts is a database query.
pub type StatusFn = std::sync::Arc<
dyn Fn(Command) -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync,
>;
pub type StatusFn =
std::sync::Arc<dyn Fn(Command) -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync>;
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
pub async fn serve(
@@ -276,14 +332,12 @@ pub async fn serve(
// A socket file left by a crashed daemon would block the bind; a live one was already
// rejected by the caller's daemon_is_live() check.
if path.exists() {
std::fs::remove_file(&path)
.with_context(|| format!("removing stale socket {}", path.display()))?;
std::fs::remove_file(&path).with_context(|| format!("removing stale socket {}", path.display()))?;
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let listener = UnixListener::bind(&path)
.with_context(|| format!("binding {}", path.display()))?;
let listener = UnixListener::bind(&path).with_context(|| format!("binding {}", path.display()))?;
tracing::info!(socket = %path.display(), "listening");
loop {
@@ -370,7 +424,9 @@ pub async fn reload(path: &Path) -> Result<()> {
}
anyhow::bail!("the daemon closed the connection without answering")
};
tokio::time::timeout(std::time::Duration::from_secs(10), answer).await.context("the daemon did not answer in 10s")?
tokio::time::timeout(std::time::Duration::from_secs(10), answer)
.await
.context("the daemon did not answer in 10s")?
}
/// Sends one command to a running daemon and prints the events it produces.
@@ -428,9 +484,28 @@ mod tests {
url: format!("https://cdn/ep.mp3?{key}"),
msg: format!("connecting: error sending request for url (https://cdn/ep.mp3?{key})"),
});
out.emit(Event::FeedMoved { feed: "f".into(), from: format!("https://x/rss?{key}"), to: format!("https://y/rss?{key}") });
// What every open page is sent, whoever's feed it is (#238).
for _ in 0..3 {
out.emit(Event::FeedMoved {
feed: "f".into(),
from: format!("https://x/rss?{key}"),
to: format!("https://y/rss?{key}"),
});
out.emit(Event::Progress {
feed: "f".into(),
enclosure: 1,
url: format!("https://cdn/ep.mp3?{key}"),
file: "ep.mp3".into(),
done: 1,
total: None,
});
out.emit(Event::DownloadDone {
feed: "f".into(),
enclosure: 1,
url: format!("https://cdn/ep.mp3?{key}"),
path: "/downloads/ep.mp3".into(),
bytes: 1,
});
// What every open page is sent, whoever's feed it is (#238, #292).
for _ in 0..5 {
let sent = serde_json::to_string(&rx.try_recv().unwrap()).unwrap();
assert!(!sent.contains("SECRET"), "{sent}");
assert!(sent.contains("https://"), "the address is still there, without its key: {sent}");
@@ -441,7 +516,10 @@ mod tests {
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
let wire = serde_json::to_value(&e).unwrap();
assert_eq!((wire["ev"].as_str(), wire["from"].as_str(), wire["to"].as_str()), (Some("feed_moved"), Some("http://a/f"), Some("https://a/f")));
assert_eq!(
(wire["ev"].as_str(), wire["from"].as_str(), wire["to"].as_str()),
(Some("feed_moved"), Some("http://a/f"), Some("https://a/f"))
);
assert_eq!(e.human().unwrap(), "x: moved for good from http://a/f to https://a/f; following it");
}
@@ -480,14 +558,8 @@ mod tests {
assert!(json.contains(r#""enclosure":42"#), "a UI needs this to target one row");
// total is omitted rather than null when the server sent no length.
let ev = Event::Progress {
feed: "a".into(),
enclosure: 1,
url: "u".into(),
file: "f".into(),
done: 1,
total: None,
};
let ev =
Event::Progress { feed: "a".into(), enclosure: 1, url: "u".into(), file: "f".into(), done: 1, total: None };
assert!(!serde_json::to_string(&ev).unwrap().contains("total"));
}
@@ -495,14 +567,7 @@ mod tests {
fn only_completion_events_end_a_client_session() {
assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal());
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
assert!(!Event::FeedDone {
feed: "a".into(),
new: 0,
downloaded: 0,
failed: 0,
torrents: 0
}
.is_terminal());
assert!(!Event::FeedDone { feed: "a".into(), new: 0, downloaded: 0, failed: 0, torrents: 0 }.is_terminal());
}
#[tokio::test]

View File

@@ -27,9 +27,8 @@ struct Ring {
next_seq: u64,
}
static BUF: LazyLock<Mutex<Ring>> = LazyLock::new(|| {
Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 })
});
static BUF: LazyLock<Mutex<Ring>> =
LazyLock::new(|| Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 }));
pub fn push(level: &str, target: &str, msg: String) {
let mut ring = match BUF.lock() {
@@ -57,12 +56,7 @@ pub fn since(after: u64, limit: usize) -> (Vec<LogLine>, u64) {
Err(p) => p.into_inner(),
};
let latest = ring.next_seq.saturating_sub(1);
let mut out: Vec<LogLine> = ring
.lines
.iter()
.filter(|l| l.seq > after)
.cloned()
.collect();
let mut out: Vec<LogLine> = ring.lines.iter().filter(|l| l.seq > after).cloned().collect();
// On a first load (after = 0) the tail is what matters, not the head.
if out.len() > limit {
out.drain(..out.len() - limit);
@@ -130,10 +124,7 @@ mod tests {
let (all, latest) = since(0, CAPACITY * 2);
assert_eq!(all.len(), CAPACITY, "bounded");
assert!(latest >= (CAPACITY + 50) as u64);
assert!(
all.first().unwrap().seq < all.last().unwrap().seq,
"oldest first"
);
assert!(all.first().unwrap().seq < all.last().unwrap().seq, "oldest first");
// "everything after the last one I saw" must return nothing new.
let (none, _) = since(latest, 100);

View File

@@ -5,8 +5,8 @@ mod config;
mod db;
mod deadair;
mod directory;
mod entity;
mod download;
mod entity;
// The crate root's import, so `crate::feed` reaches it from every module as before.
use ipx_feed as feed;
mod ipc;
@@ -20,9 +20,9 @@ mod web;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use ipc::{Command as Cmd, Emitter, Event};
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use std::future::Future;
use tokio::sync::{broadcast, mpsc, watch};
#[derive(Parser)]
@@ -200,7 +200,14 @@ const RETRY_LOOKUP_SECS: i64 = 600;
impl Ctx {
/// When a feed is next due, taking a lookup retry into account: not before it.
fn due(&self, cfg: &config::Config, feed: &config::Feed, id: &str, state: &db::HttpState, subscribed: bool) -> Option<i64> {
fn due(
&self,
cfg: &config::Config,
feed: &config::Feed,
id: &str,
state: &db::HttpState,
subscribed: bool,
) -> Option<i64> {
let at = due_at(cfg, feed, state, subscribed);
match self.retry.lock().unwrap().get(id) {
Some(&retry) => Some(at.unwrap_or(0).max(retry)),
@@ -250,9 +257,7 @@ impl Ctx {
async fn torrents(&self) -> Result<&torrent::Torrents> {
let cfg = self.cfg();
self.torrents
.get_or_try_init(|| torrent::Torrents::new(&cfg))
.await
self.torrents.get_or_try_init(|| torrent::Torrents::new(&cfg)).await
}
}
@@ -267,20 +272,19 @@ async fn main() -> Result<()> {
};
// Everything goes to stderr as before, and is mirrored into a ring the UI can read.
{
use tracing_subscriber::Layer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Layer;
// Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer
// keeps debug as well, so the log view can show protocol traffic and routine
// skips that would be noise on a terminal. IPX_UI_LOG overrides it.
let stderr_filter = || {
tracing_subscriber::EnvFilter::try_from_env("IPX_LOG").unwrap_or_else(|_| "ipx=info".into())
};
let stderr_filter =
|| tracing_subscriber::EnvFilter::try_from_env("IPX_LOG").unwrap_or_else(|_| "ipx=info".into());
// One JSON object a line for Loki (#91), with each event's fields as its own; text
// otherwise, for someone reading a terminal.
let json = std::env::var("IPX_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
let ui_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_UI_LOG")
.unwrap_or_else(|_| "ipx=debug".into());
let ui_filter =
tracing_subscriber::EnvFilter::try_from_env("IPX_UI_LOG").unwrap_or_else(|_| "ipx=debug".into());
tracing_subscriber::registry()
.with((!json).then(|| {
tracing_subscriber::fmt::layer()
@@ -294,7 +298,11 @@ async fn main() -> Result<()> {
tracing_subscriber::fmt::layer()
.fmt_fields(tracing_subscriber::fmt::format::JsonFields::new())
.event_format(WithTrace(
tracing_subscriber::fmt::format().json().flatten_event(true).with_current_span(true).with_span_list(false),
tracing_subscriber::fmt::format()
.json()
.flatten_event(true)
.with_current_span(true)
.with_span_list(false),
))
.with_writer(std::io::stderr)
.with_filter(stderr_filter())
@@ -315,9 +323,7 @@ async fn main() -> Result<()> {
// A daemon owns the state; don't have two processes downloading the same thing.
let wire_cmd = match &cli.command {
Command::Fetch { feed, force } => {
Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] })
}
Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }),
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
Command::Status => Some(Cmd::Status),
Command::List
@@ -391,11 +397,13 @@ async fn main() -> Result<()> {
Command::Export { file } => export(&ctx, &file).await,
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
};
if result.is_ok() && changes_catalogue && ipc::daemon_is_live(&socket).await {
if let Err(e) = ipc::reload(&socket).await {
if result.is_ok()
&& changes_catalogue
&& ipc::daemon_is_live(&socket).await
&& let Err(e) = ipc::reload(&socket).await
{
eprintln!("the running daemon did not take the change ({e:#}); restart it, or it may undo it");
}
}
// The batch exporter holds the last few seconds of spans; without this they are lost.
if let Some(p) = otel {
let _ = p.shutdown();
@@ -459,11 +467,7 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
if ctx.db.user_by_name(&name).await?.is_some() {
anyhow::bail!("{name} already exists");
}
let hash = if no_password {
None
} else {
Some(crate::auth::hash_password(&read_password()?)?)
};
let hash = if no_password { None } else { Some(crate::auth::hash_password(&read_password()?)?) };
// The first account runs the place; there is nobody else to grant it.
let first = ctx.db.users().await?.is_empty();
ctx.db.create_user(&name, hash.as_deref(), admin || first).await?;
@@ -496,10 +500,7 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
}
UserCmd::Passwd { name } => {
let name = name.trim().to_ascii_lowercase();
let user = ctx
.db
.user_by_name(&name).await?
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
ctx.db.set_password(user.id, &crate::auth::hash_password(&read_password()?)?).await?;
println!("password changed for {name}");
Ok(())
@@ -509,10 +510,7 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
// The same rules as a name the proxy vouches for, or the proxy would never find it.
let new_name = crate::auth::name_from_header(&new_name)
.ok_or_else(|| anyhow::anyhow!("not a usable name: no commas, semicolons or line breaks"))?;
let user = ctx
.db
.user_by_name(&name).await?
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
if ctx.db.user_by_name(&new_name).await?.is_some() {
anyhow::bail!("{new_name} already exists");
}
@@ -522,10 +520,7 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
}
UserCmd::Rm { name } => {
let name = name.trim().to_ascii_lowercase();
let user = ctx
.db
.user_by_name(&name).await?
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
let user = ctx.db.user_by_name(&name).await?.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
remove_user(ctx, user.id).await?;
println!("removed {name}");
Ok(())
@@ -580,8 +575,8 @@ pub(crate) async fn status(ctx: &Ctx) -> Event {
use std::sync::atomic::Ordering::Relaxed;
let st = &metrics::STATS;
st.feeds.store(feeds as i64, Relaxed);
st.pending.store(pending as i64, Relaxed);
st.downloaded.store(downloaded as i64, Relaxed);
st.pending.store(pending, Relaxed);
st.downloaded.store(downloaded, Relaxed);
Event::Status { feeds, pending, downloaded }
}
Err(e) => Event::Error { msg: format!("{e:#}") },
@@ -654,10 +649,7 @@ async fn daemon(
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd, answer));
// One command at a time: the queue is what keeps two scans from overlapping.
tracing::info!(
feeds = subscriptions(&ctx).await.map(|s| s.len()).unwrap_or(0),
"daemon started"
);
tracing::info!(feeds = subscriptions(&ctx).await.map(|s| s.len()).unwrap_or(0), "daemon started");
// A signal has to be able to interrupt work in progress, not just the wait between
// jobs. Racing `shutdown()` in the outer select only cancels branch selection: once
@@ -689,11 +681,7 @@ async fn daemon(
}
// Runs one job, abandoning it if a signal arrives. Returns false to end the loop.
async fn until_stopped(
ctx: &Ctx,
rx: &watch::Receiver<bool>,
job: impl Future<Output = Result<()>>,
) -> bool {
async fn until_stopped(ctx: &Ctx, rx: &watch::Receiver<bool>, job: impl Future<Output = Result<()>>) -> bool {
let mut stop = rx.clone();
tokio::select! {
_ = stop.changed() => {
@@ -783,10 +771,7 @@ async fn start_web(
config_path.display()
);
} else {
tracing::info!(
"web ui at http://{bind}/ (the sign-in token is [web] token in {})",
config_path.display()
);
tracing::info!("web ui at http://{bind}/ (the sign-in token is [web] token in {})", config_path.display());
}
// Bound beyond localhost, as a container has to be for its port to be published. Worth a
@@ -825,13 +810,7 @@ async fn start_web(
let at = metrics::spawn(&cfg.web.metrics_bind, sources)?;
tracing::info!(%at, "serving metrics for Prometheus at /metrics");
}
let state = web::WebState {
ctx: ctx.clone(),
cmds: cmds.clone(),
events: events.clone(),
access,
playing,
};
let state = web::WebState { ctx: ctx.clone(), cmds: cmds.clone(), events: events.clone(), access, playing };
Ok(Some(tokio::spawn(async move {
if let Err(e) = web::serve(state, &bind).await {
tracing::error!(error = %format!("{e:#}"), "web ui stopped");
@@ -938,10 +917,9 @@ async fn add_one(
Ok(feed::Fetched::Body { bytes, .. }) if feed::is_opml(&bytes) => {
feed::opml_title(&bytes).unwrap_or_else(|| url_stem(url))
}
Ok(feed::Fetched::Body { bytes, .. }) => feed::parse(&bytes)
.ok()
.and_then(|f| f.title)
.unwrap_or_else(|| url_stem(url)),
Ok(feed::Fetched::Body { bytes, .. }) => {
feed::parse(&bytes).ok().and_then(|f| f.title).unwrap_or_else(|| url_stem(url))
}
_ => {
tracing::warn!(url, "could not read the feed; naming it from its URL");
url_stem(url)
@@ -950,10 +928,8 @@ async fn add_one(
// Slugs must be unique across derived feeds too, or a new feed can collide with one
// an OPML already introduced.
let mut taken: std::collections::BTreeMap<String, config::Feed> = subscriptions(ctx).await?
.into_iter()
.map(|s| (s.id, s.cfg))
.collect();
let mut taken: std::collections::BTreeMap<String, config::Feed> =
subscriptions(ctx).await?.into_iter().map(|s| (s.id, s.cfg)).collect();
// A removed feed keeps its rows, so its id is only free again for the same feed: re-adding
// it gets its history back, and a different feed does not inherit someone else's.
for (id, other) in ctx.db.feed_urls().await? {
@@ -968,10 +944,7 @@ async fn add_one(
/// Host plus last path segment, for naming a feed we could not read.
fn url_stem(url: &str) -> String {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(str::to_owned))
.unwrap_or_else(|| url.to_owned())
url::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_owned)).unwrap_or_else(|| url.to_owned())
}
async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
@@ -991,17 +964,16 @@ async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
}
async fn import(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
let text = std::fs::read_to_string(file)
.with_context(|| format!("reading {}", file.display()))?;
let text = std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
// The CLI speaks for the operator, as the shared web token does.
let admin = ctx
.db
.users().await?
.users()
.await?
.into_iter()
.find(|u| u.is_admin)
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
let doc = opml::OPML::from_str(&text)
.map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
let doc = opml::OPML::from_str(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
let (added, had) = subscribe_opml(ctx, &doc, admin.id).await?;
println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
Ok(())
@@ -1017,11 +989,7 @@ async fn import(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
///
/// The caller parses the document, so each refuses a file that is not OPML in its own terms,
/// before anything is touched: a 400 from the web, a message from the CLI.
pub async fn subscribe_opml(
ctx: &Ctx,
doc: &opml::OPML,
user_id: i64,
) -> Result<(usize, usize)> {
pub async fn subscribe_opml(ctx: &Ctx, doc: &opml::OPML, user_id: i64) -> Result<(usize, usize)> {
let mut found = vec![];
feed::collect_outlines(&doc.body.outlines, &mut found);
@@ -1108,7 +1076,10 @@ async fn assemble_config(db: &db::Db, mut cfg: config::Config, path: &std::path:
}
cfg.save_bootstrap(path)?;
}
tracing::info!(feeds = cfg.feeds.len(), "moved the feeds and server settings from config.toml into the database");
tracing::info!(
feeds = cfg.feeds.len(),
"moved the feeds and server settings from config.toml into the database"
);
return Ok(cfg);
}
// Another ipx imported between our look and our insert; the next pass reads its copy.
@@ -1117,18 +1088,12 @@ async fn assemble_config(db: &db::Db, mut cfg: config::Config, path: &std::path:
}
async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
let mut doc = opml::OPML::default();
doc.head = Some(opml::Head {
title: Some("ipx subscriptions".into()),
let mut doc = opml::OPML {
head: Some(opml::Head { title: Some("ipx subscriptions".into()), ..Default::default() }),
..Default::default()
});
};
for (id, feed) in &ctx.cfg().feeds {
let title = ctx
.db
.feed_summary(id).await
.ok()
.and_then(|s| s.title)
.unwrap_or_else(|| id.clone());
let title = ctx.db.feed_summary(id).await.ok().and_then(|s| s.title).unwrap_or_else(|| id.clone());
doc.add_feed(&title, &feed.url);
}
let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?;
@@ -1178,13 +1143,21 @@ async fn clean_directory(ctx: &Ctx) -> Result<()> {
return Ok(());
}
let mut cfg = ctx.edit_cfg().await;
if stale.iter().fold(false, |any, id| cfg.feeds.remove(id).is_some() || any) {
// Every one removed, so not `any`, which would stop at the first.
let mut removed = false;
for id in &stale {
removed |= cfg.feeds.remove(id).is_some();
}
if removed {
ctx.store_cfg(cfg).await?;
}
for id in &stale {
retire_group(ctx, id).await?;
ctx.db.forget_feed(id).await?;
tracing::info!(feed = id, "removed a feed nobody subscribes to that is dead or has published nothing in a year");
tracing::info!(
feed = id,
"removed a feed nobody subscribes to that is dead or has published nothing in a year"
);
}
Ok(())
}
@@ -1201,22 +1174,17 @@ async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
span.record("entries", r.entries_pruned);
if !dry_run {
tracing::Instrument::instrument(clean_directory(ctx), db::step("clean_directory")).await?;
let gone = db::step("trim_artwork").in_scope(|| art::trim(&art::dir(), ctx.cfg().general.art_cache_mb * 1_048_576));
let gone =
db::step("trim_artwork").in_scope(|| art::trim(&art::dir(), ctx.cfg().general.art_cache_mb * 1_048_576));
if gone > 0 {
tracing::debug!(gone, "trimmed the artwork kept on disk");
}
}
for c in r.aged_out.iter().chain(r.over_quota.iter()) {
ctx.out.emit(Event::Reaped {
path: c.path.clone(),
bytes: c.bytes.max(0) as u64,
});
ctx.out.emit(Event::Reaped { path: c.path.clone(), bytes: c.bytes.max(0) as u64 });
}
if standalone {
ctx.out.emit(Event::ReapDone {
files: r.aged_out.len() + r.over_quota.len(),
bytes: r.bytes_freed,
});
ctx.out.emit(Event::ReapDone { files: r.aged_out.len() + r.over_quota.len(), bytes: r.bytes_freed });
}
Ok(())
}
@@ -1235,9 +1203,8 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
let subscribed = ctx.db.subscriber_counts().await?;
let mut scanned = 0;
let mut fresh: Vec<String> = vec![];
let in_scope = |s: &Sub| {
scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g))
};
let in_scope =
|s: &Sub| scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g));
let mut states = ctx.db.http_states().await?;
let mut due = vec![];
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id) && in_scope(s)) {
@@ -1250,15 +1217,16 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
state.last_modified = None;
}
if !force && let Some(at) = ctx.due(&cfg, &sub.cfg, id, &state, subscribed.contains_key(id)) {
if at > db::now() {
if !force
&& let Some(at) = ctx.due(&cfg, &sub.cfg, id, &state, subscribed.contains_key(id))
&& at > db::now()
{
ctx.out.emit(Event::FeedSkip {
feed: id.clone(),
reason: format!("not due for {}", duration((at - db::now()) as u64)),
});
continue;
}
}
due.push((sub, state));
}
@@ -1281,7 +1249,9 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
return None;
}
let _fetching = metrics::busy(&metrics::STATS.feeds_fetching);
let got = feed::fetch(&client, &feed_cfg.url, feed_cfg.auth(), etag.as_deref(), modified.as_deref()).await;
let got =
feed::fetch(&client, &feed_cfg.url, feed_cfg.auth(), etag.as_deref(), modified.as_deref())
.await;
// A forced scan looks for the site's icon of each feed naming no artwork: here,
// a few feeds at a time, not each in its turn, 22 s of a 62 s scan (#228).
let icon = match &got {
@@ -1314,14 +1284,10 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
failed: s.failed,
torrents: s.torrents,
}),
Ok(Outcome::NotModified) => ctx.out.emit(Event::FeedSkip {
feed: id.clone(),
reason: "not modified".into(),
}),
Ok(Outcome::Empty) => ctx.out.emit(Event::FeedSkip {
feed: id.clone(),
reason: "nothing yet".into(),
}),
Ok(Outcome::NotModified) => {
ctx.out.emit(Event::FeedSkip { feed: id.clone(), reason: "not modified".into() })
}
Ok(Outcome::Empty) => ctx.out.emit(Event::FeedSkip { feed: id.clone(), reason: "nothing yet".into() }),
Ok(Outcome::Opml { added, removed, kept, total }) => {
ctx.out.emit(Event::FeedSkip {
feed: id.clone(),
@@ -1390,11 +1356,8 @@ pub struct Sub {
/// entry -- there is nothing to store but its URL and where it came from.
pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
let cfg = ctx.cfg();
let mut out: Vec<Sub> = cfg
.feeds
.iter()
.map(|(id, f)| Sub { id: id.clone(), cfg: f.clone(), managed: false })
.collect();
let mut out: Vec<Sub> =
cfg.feeds.iter().map(|(id, f)| Sub { id: id.clone(), cfg: f.clone(), managed: false }).collect();
// A group's title, once a group: asked for each feed in it, three queries a feed, it was about
// 96 for The Glass Cannon Network's 32 shows on every feed list, live update and scan (#159).
@@ -1451,7 +1414,11 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
/// lists are left alone.
pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> {
ctx.db.unsubscribe(user_id, id).await?;
for child in subscriptions(ctx).await?.iter().filter(|s| s.cfg.group.as_deref() == Some(id)) {
// What they kept stays on their list, a feed of its own once nobody reads the list (#280).
let kept = ctx.db.kept_feeds(user_id).await?;
for child in
subscriptions(ctx).await?.iter().filter(|s| s.cfg.group.as_deref() == Some(id) && !kept.contains(&s.id))
{
ctx.db.unsubscribe(user_id, &child.id).await?;
}
if ctx.db.subscriber_counts().await?.contains_key(id) {
@@ -1504,6 +1471,8 @@ pub async fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
// Scanned from its config entry and still read. Dropped as derived, its stored
// entries would go with it: davewiner's 11 were promoted without being unmanaged.
ctx.db.unmanage(&m.id).await?;
} else if promote_kept(ctx, &m).await? {
tracing::info!(feed = %m.id, "its list is gone but someone kept it; a feed of its own now");
} else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
ctx.db.set_orphaned(&m.id, true).await?;
} else {
@@ -1513,6 +1482,46 @@ pub async fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
Ok(())
}
/// Makes a feed from an OPML or Patreon list a feed of its own, for those who kept it (#280): a
/// catalogue entry in no group, which the list no longer adds or takes away. Whoever had it only
/// through the list, without keeping it, loses it, as they would have when the list dropped it.
/// False, changing nothing, when nobody kept it.
async fn promote_kept(ctx: &Ctx, m: &db::Managed) -> Result<bool> {
let subs = ctx.db.subscribers_of(&m.id).await?;
if !subs.iter().any(|(_, kept)| *kept) {
return Ok(false);
}
let mut cfg = ctx.edit_cfg().await;
// The list's own settings carry over while it is there; its folder and group do not.
let base = cfg.feeds.get(&m.group_id).cloned();
cfg.feeds.entry(m.id.clone()).or_insert_with(|| config::Feed {
url: m.url.clone(),
folder: None,
group: None,
media_types: base.as_ref().and_then(|p| p.media_types.clone()),
schedule: base.as_ref().and_then(|p| p.schedule.clone()),
keywords: base.as_ref().map(|p| p.keywords.clone()).unwrap_or_default(),
allow_explicit: base.as_ref().is_some_and(|p| p.allow_explicit),
auto_download: base.as_ref().is_none_or(|p| p.auto_download),
max_new_per_check: base.as_ref().and_then(|p| p.max_new_per_check),
username: base.as_ref().and_then(|p| p.username.clone()),
password: base.as_ref().and_then(|p| p.password.clone()),
password_env: base.as_ref().and_then(|p| p.password_env.clone()),
category: None,
listed: false,
private: base.as_ref().is_some_and(|p| p.private),
});
ctx.store_cfg(cfg).await?;
ctx.db.unmanage(&m.id).await?;
ctx.db.set_orphaned(&m.id, false).await?;
for (user, kept) in subs {
if !kept {
ctx.db.unsubscribe(user, &m.id).await?;
}
}
Ok(true)
}
/// Retires every group whose parent is gone from config, and returns how many derived rows that
/// dropped or unmanaged. An OPML removed before `retire_group` existed left its feeds behind:
/// davewiner's 922 were skipped by every scan and never cleared, and their stale errors were
@@ -1520,11 +1529,8 @@ pub async fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
let cfg = ctx.cfg();
let before = ctx.db.managed_feeds().await?;
let stranded: std::collections::BTreeSet<&str> = before
.iter()
.map(|m| m.group_id.as_str())
.filter(|g| !cfg.feeds.contains_key(*g))
.collect();
let stranded: std::collections::BTreeSet<&str> =
before.iter().map(|m| m.group_id.as_str()).filter(|g| !cfg.feeds.contains_key(*g)).collect();
for group in stranded {
retire_group(ctx, group).await?;
}
@@ -1592,7 +1598,10 @@ pub fn due_after(
let usual = match explicit {
Some(explicit) => explicit * 60,
// A schedule someone set is theirs; otherwise a feed long the same is checked less.
None => quiet(ttl_mins.unwrap_or(0).max(cfg.general.interval()) * 60, same_since.map(|s| (last_checked - s).max(0) as u64)),
None => quiet(
ttl_mins.unwrap_or(0).max(cfg.general.interval()) * 60,
same_since.map(|s| (last_checked - s).max(0) as u64),
),
};
let failing_for = error_since.map(|since| (last_checked - since).max(0) as u64);
backoff(usual, failing_for)
@@ -1638,7 +1647,12 @@ enum Outcome {
Empty,
Feed(Scan),
/// The URL is a list of feeds rather than a feed: an OPML, or a Patreon creator's shows.
Opml { added: Vec<String>, removed: usize, kept: usize, total: usize },
Opml {
added: Vec<String>,
removed: usize,
kept: usize,
total: usize,
},
}
/// A feed that answered from a new address after permanent redirects moves there in the
@@ -1668,7 +1682,7 @@ async fn scan_one(
force: bool,
// The body the scan already fetched for it, if it did (#104), and where it moved, if it did;
// with the site's icon when a forced scan looked it up meanwhile, "" for none (#228).
prefetched: Option<(Result<(feed::Fetched, Option<String>)>, Option<String>)>,
prefetched: Option<Prefetched>,
) -> Result<Outcome> {
let (prefetched, icon) = match prefetched {
Some((got, icon)) => (Some(got), icon),
@@ -1705,7 +1719,16 @@ async fn scan_one(
let (mut fetched, moved) = match prefetched {
Some(got) => got?,
None => feed::fetch(&ctx.feed_client, &feed_cfg.url, feed_cfg.auth(), state.etag.as_deref(), state.last_modified.as_deref()).await?,
None => {
feed::fetch(
&ctx.feed_client,
&feed_cfg.url,
feed_cfg.auth(),
state.etag.as_deref(),
state.last_modified.as_deref(),
)
.await?
}
};
if let Some(to) = moved {
follow_move(ctx, id, &to).await?;
@@ -1815,7 +1838,8 @@ async fn scan_one(
ctx.db.secure_images(id, host).await?;
}
}
ctx.db.record_feed(
ctx.db
.record_feed(
id,
&feed_cfg.url,
parsed.title.as_deref(),
@@ -1825,7 +1849,8 @@ async fn scan_one(
parsed.image.as_deref(),
parsed.category.as_deref(),
parsed.description.as_deref(),
).await?;
)
.await?;
if state.body_hash.as_deref() != Some(hash.as_str()) {
ctx.db.set_body(id, &hash).await?;
}
@@ -1837,7 +1862,7 @@ async fn scan_one(
&& (force || stored.entries == 0)
&& !parsed.entries.is_empty()
&& ctx.cfg().feeds.get(id).is_some_and(|f| f.category.is_none())
&& let Ok(key) = std::env::var("TYPESAFE_KEY")
&& let Some(key) = signals::typesafe_key()
{
match feed::classify(&ctx.client, &key, &parsed).await {
Ok(category) => {
@@ -1870,7 +1895,8 @@ async fn scan_one(
let mut scan = Scan::default();
// Its own span: the time a feed spends after its fetch was untraced (#96).
let store = tracing::info_span!("store", items = parsed.entries.len());
tracing::Instrument::instrument(async {
tracing::Instrument::instrument(
async {
for entry in &parsed.entries {
// What is not stored yet is inserted, and what the feed has changed since is written
// again (#141); the rest is left alone. A correction keeps the item's read state.
@@ -1881,7 +1907,8 @@ async fn scan_one(
}
for enc in &entry.enclosures {
// A URL not among this feed's files may still be another feed's: the insert says.
let was = if !known_files.contains(&enc.url) && ctx.db.record_enclosure(id, &entry.guid, enc).await? {
let was =
if !known_files.contains(&enc.url) && ctx.db.record_enclosure(id, &entry.guid, enc).await? {
None
} else if let Some(reason) = skipped.get(&enc.url) {
Some(reason.as_str())
@@ -1898,7 +1925,10 @@ async fn scan_one(
}
}
anyhow::Ok(())
}, store).await?;
},
store,
)
.await?;
if scan.new_entries > 0 {
ctx.db.rehide(id).await?; // what is new may hold someone's blocked words
ctx.signals.notify_one();
@@ -1917,10 +1947,7 @@ async fn scan_one(
if download::looks_like_torrent(&item.url, item.mime.as_deref()) {
if !ctx.cfg().torrent.enabled {
ctx.db.mark_enclosure(&item.url, "skipped", Some("torrents disabled")).await?;
ctx.out.emit(Event::TorrentDeferred {
feed: id.to_string(),
url: item.url.clone(),
});
ctx.out.emit(Event::TorrentDeferred { feed: id.to_string(), url: item.url.clone() });
scan.torrents += 1;
continue;
}
@@ -1987,12 +2014,10 @@ async fn scan_one(
Ok(Outcome::Feed(scan))
}
async fn sync_opml(
ctx: &Arc<Ctx>,
parent_id: &str,
parent: &config::Feed,
bytes: &[u8],
) -> Result<Outcome> {
/// A feed's body fetched by the scan ahead of its turn, and the site's icon if one was looked up.
type Prefetched = (Result<(feed::Fetched, Option<String>)>, Option<String>);
async fn sync_opml(ctx: &Arc<Ctx>, parent_id: &str, parent: &config::Feed, bytes: &[u8]) -> Result<Outcome> {
let listed = feed::parse_opml(bytes)?;
if let Some(title) = feed::opml_title(bytes) {
ctx.db.set_title(parent_id, &title).await?;
@@ -2046,7 +2071,8 @@ async fn sync_group(
// subscription means. Their own feeds are untouched.
for id in ctx
.db
.managed_feeds().await?
.managed_feeds()
.await?
.iter()
.filter(|m| m.group_id == parent_id)
.map(|m| m.id.clone())
@@ -2066,6 +2092,11 @@ async fn sync_group(
if listed.iter().any(|(_, u)| u == &m.url) {
continue;
}
if promote_kept(ctx, m).await? {
kept += 1;
tracing::info!(feed = %m.id, "dropped from the OPML but someone kept it; a feed of its own now");
continue;
}
if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
// Never orphan a downloaded file: keep the feed and say why in the UI.
ctx.db.set_orphaned(&m.id, true).await?;
@@ -2095,10 +2126,7 @@ fn reject(
}
// Blog feeds put the article's header image in an <enclosure>; without this a text
// feed reads as a podcast full of episodes and fills the disk with artwork.
let wanted = feed_cfg
.media_types
.as_deref()
.unwrap_or(&cfg.general.media_types);
let wanted = feed_cfg.media_types.as_deref().unwrap_or(&cfg.general.media_types);
if !config::wanted_media(enc.mime.as_deref(), wanted) {
return Some("not audio or video");
}
@@ -2106,12 +2134,8 @@ fn reject(
return Some("explicit");
}
let categories = entry.categories.join(" ");
let haystacks = [
url,
entry.title.as_deref().unwrap_or(""),
entry.description.as_deref().unwrap_or(""),
categories.as_str(),
];
let haystacks =
[url, entry.title.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or(""), categories.as_str()];
let text = [entry.title.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or("")];
if !policy.wanted(&haystacks, &text) {
// Worded for whichever filter could have let it through, the keywords if none blocked it.
@@ -2145,9 +2169,9 @@ impl Policy {
/// One file serves everyone subscribed, so an item is wanted if anyone wants it. Keywords
/// look at `haystacks`, blocked words at `text`, the item's title and body only.
fn wanted(&self, haystacks: &[&str], text: &[&str]) -> bool {
self.wants.iter().any(|w| {
download::matches_keywords(&w.keywords, haystacks) && !download::blocked(&w.blocked, text)
})
self.wants
.iter()
.any(|w| download::matches_keywords(&w.keywords, haystacks) && !download::blocked(&w.blocked, text))
}
}
@@ -2176,21 +2200,15 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
};
}
let mut policy = Policy {
auto_download: false,
allow_explicit: false,
wants: vec![],
budget: 0,
};
let mut policy = Policy { auto_download: false, allow_explicit: false, wants: vec![], budget: 0 };
for sub in subs {
if !sub.auto_download.unwrap_or(feed_cfg.auto_download) {
continue; // Not fetching for this person, so their wants add nothing.
}
policy.auto_download = true;
policy.allow_explicit |= sub.allow_explicit.unwrap_or(feed_cfg.allow_explicit);
policy.budget = policy
.budget
.max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check)));
policy.budget =
policy.budget.max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check)));
policy.wants.push(Want {
keywords: sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone()),
blocked: sub.blocked.clone(),
@@ -2297,10 +2315,7 @@ fn spawn_torrent(ctx: &Arc<Ctx>, feed_id: String, enclosure: i64, url: String, d
#[tracing::instrument(skip(ctx))]
async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
let cfg = ctx.cfg();
let enc = ctx
.db
.enclosure(id).await?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
let enc = ctx.db.enclosure(id).await?.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
if enc.path.is_some() {
return Ok(()); // Already here.
}
@@ -2379,9 +2394,9 @@ async fn torrent_one(
ctx.torrents()
.await?
.fetch(&cfg, url, dest_dir, |done, total| {
if total > 0 {
let pct = (done * 100 / total) as i64;
if pct > last_pct {
if let Some(pct) = (done * 100).checked_div(total).map(|p| p as i64)
&& pct > last_pct
{
last_pct = pct;
ctx.out.emit(Event::Progress {
feed: feed_id.to_string(),
@@ -2392,7 +2407,6 @@ async fn torrent_one(
total: Some(total),
});
}
}
})
.await
}
@@ -2447,14 +2461,20 @@ mod tests {
let base = empty_or_feed_server().await;
let ctx = Arc::new(test_ctx(config::Config::default()).await);
let cfg = config::Feed { url: format!("{base}/feed"), ..feed() };
assert!(matches!(scan_one(&ctx, "same", &cfg, &db::HttpState::default(), false, None).await, Ok(Outcome::Feed(_))));
assert!(matches!(
scan_one(&ctx, "same", &cfg, &db::HttpState::default(), false, None).await,
Ok(Outcome::Feed(_))
));
let state = ctx.db.http_state("same").await.unwrap();
let since = state.same_since.expect("when it was first read");
assert!(state.body_hash.is_some());
// Patreon's way: the whole feed again, no validator honoured (#242).
assert!(matches!(scan_one(&ctx, "same", &cfg, &state, false, None).await, Ok(Outcome::NotModified)));
assert_eq!(ctx.db.http_state("same").await.unwrap().same_since, Some(since), "still the same since then");
assert!(matches!(scan_one(&ctx, "same", &cfg, &state, true, None).await, Ok(Outcome::Feed(_))), "a forced check reads it");
assert!(
matches!(scan_one(&ctx, "same", &cfg, &state, true, None).await, Ok(Outcome::Feed(_))),
"a forced check reads it"
);
// Changed: read, and the same from now.
let moved = config::Feed { url: format!("{base}/fixed"), ..feed() };
assert!(matches!(scan_one(&ctx, "same", &moved, &state, false, None).await, Ok(Outcome::Feed(_))));
@@ -2514,8 +2534,8 @@ mod tests {
fn feed() -> config::Feed {
// Whatever `ipx add` would write, which is the shape every code path sees.
let mut cfg = config::Config::default();
let f = add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![]);
f
add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![])
}
/// The feed entry `add` builds, without the network round trip it does for a title.
@@ -2565,11 +2585,7 @@ mod tests {
// Two filters: an item wanted by either of them is fetched, since one file serves
// both. The larger per-scan cap wins for the same reason.
let p = merge_policy(
&[sub(Some(&["rust"]), None, Some(2)), sub(Some(&["sqlite"]), None, Some(9))],
&feed(),
3,
);
let p = merge_policy(&[sub(Some(&["rust"]), None, Some(2)), sub(Some(&["sqlite"]), None, Some(9))], &feed(), 3);
assert!(p.wanted(&["rust"], &[]) && p.wanted(&["sqlite"], &[]));
assert!(!p.wanted(&["python"], &[]));
assert_eq!(p.budget, 9);
@@ -2643,19 +2659,34 @@ mod tests {
let (name, value) = line.rsplit_once(' ').unwrap();
assert!(name.starts_with("ipx_") && value.parse::<f64>().is_ok(), "{line}");
}
for name in ["ipx_db_connections{state=\"in_use\"}", "ipx_db_connections_max", "ipx_worker_queue", "ipx_runtime_tasks"] {
for name in
["ipx_db_connections{state=\"in_use\"}", "ipx_db_connections_max", "ipx_worker_queue", "ipx_runtime_tasks"]
{
assert!(text.contains(name), "{name}");
}
assert!(metrics::spawn(&at.to_string(), metrics::Sources {
ctx: ctx.clone(), cmds: mpsc::channel(1).0, events: broadcast::channel(1).0, playing: Default::default(),
runtime: tokio::runtime::Handle::current(), started: std::time::Instant::now(),
}).is_err(), "a port already taken is an error at start");
assert!(
metrics::spawn(
&at.to_string(),
metrics::Sources {
ctx: ctx.clone(),
cmds: mpsc::channel(1).0,
events: broadcast::channel(1).0,
playing: Default::default(),
runtime: tokio::runtime::Handle::current(),
started: std::time::Instant::now(),
}
)
.is_err(),
"a port already taken is an error at start"
);
}
#[tokio::test]
async fn dead_feeds_leave_the_directory_once_a_day_not_before_every_scan() {
let ctx = test_ctx(config::Config::default()).await;
let dead = |id: &str| format!("INSERT INTO feeds (id, url, error_since, managed) VALUES ('{id}', 'u-{id}', 100, false)");
let dead = |id: &str| {
format!("INSERT INTO feeds (id, url, error_since, managed) VALUES ('{id}', 'u-{id}', 100, false)")
};
let listed = async || ctx.db.feed_urls().await.unwrap().into_iter().map(|(id, _)| id).collect::<Vec<_>>();
ctx.db.sql(&dead("a")).await.unwrap();
clean_directory(&ctx).await.unwrap();
@@ -2768,8 +2799,13 @@ mod tests {
] {
db.sql(sql).await.unwrap();
}
db.store_plans(7, r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
"500": {"cuts": [[60000,100000]], "pieces": []}}"#).await.unwrap();
db.store_plans(
7,
r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
"500": {"cuts": [[60000,100000]], "pieces": []}}"#,
)
.await
.unwrap();
// One file on disk, three listeners at once: DeadAir at the usual pause, at a longer
// one, and the original.
let (usual, long, plain) = (
@@ -2916,7 +2952,9 @@ mod tests {
let got = feed::Fetched::Body { bytes: body, etag: None, last_modified: None };
let icon = "https://example.com/favicon.ico".to_string();
let cfg = config::Feed { url: "http://127.0.0.1:9/feed".into(), ..feed() };
scan_one(&ctx, "iconic", &cfg, &db::HttpState::default(), true, Some((Ok((got, None)), Some(icon.clone())))).await.unwrap();
scan_one(&ctx, "iconic", &cfg, &db::HttpState::default(), true, Some((Ok((got, None)), Some(icon.clone()))))
.await
.unwrap();
assert_eq!(ctx.db.feed_summary("iconic").await.unwrap().image, Some(icon));
}
@@ -2963,6 +3001,46 @@ mod tests {
assert!(ctx.db.feed_summary("has-file").await.unwrap().orphaned, "and flagged as orphaned");
}
#[tokio::test]
async fn a_kept_feed_outlives_its_opml_for_whoever_kept_it() {
let mut cfg = config::Config::default();
cfg.feeds.insert("opml".into(), feed());
let ctx = Arc::new(test_ctx(cfg).await);
let (me, you) = (
ctx.db.create_user("me", None, false).await.unwrap(),
ctx.db.create_user("you", None, false).await.unwrap(),
);
for id in ["opml", "a", "b"] {
if id != "opml" {
ctx.db.upsert_managed(id, &format!("http://x/{id}.xml"), id, "opml").await.unwrap();
}
for u in [me, you] {
ctx.db.subscribe(u, id).await.unwrap();
}
}
assert!(ctx.db.set_kept(me, "a", true).await.unwrap());
assert!(ctx.db.set_kept(me, "b", true).await.unwrap());
// The OPML drops a: mine to keep, a feed of its own; yours, unkept, goes.
let parent = ctx.cfg().feeds["opml"].clone();
sync_group(&ctx, "opml", &parent, &[("b".into(), "http://x/b.xml".into())]).await.unwrap();
let a = ctx.cfg().feeds.get("a").cloned().expect("a is in the catalogue");
assert_eq!((a.url.as_str(), a.group), ("http://x/a.xml", None));
assert!(!ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "a"));
assert!(ctx.db.subscription(me, "a").await.unwrap().is_some());
assert!(ctx.db.subscription(you, "a").await.unwrap().is_none());
// Leaving the OPML takes what I did not keep and leaves b, still the OPML's while you read it.
leave(&ctx, me, "opml").await.unwrap();
assert!(ctx.db.subscription(me, "b").await.unwrap().is_some());
assert!(ctx.db.managed_feeds().await.unwrap().iter().any(|m| m.id == "b"));
// Once nobody reads it, b is mine alone, and yours goes with the OPML.
leave(&ctx, you, "opml").await.unwrap();
assert!(ctx.cfg().feeds.get("b").is_some_and(|f| f.group.is_none()));
assert!(ctx.db.subscription(me, "b").await.unwrap().is_some());
assert!(ctx.db.subscription(you, "b").await.unwrap().is_none());
}
#[tokio::test]
async fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() {
// davewiner: the OPML left config before retire_group existed, and 11 of its feeds

View File

@@ -40,6 +40,17 @@ pub struct Stats {
pub feeds: AtomicI64,
pub pending: AtomicI64,
pub downloaded: AtomicI64,
/// When the Signals judge last finished a pass, Unix seconds; 0 for not since starting.
pub signals_last_pass: AtomicI64,
/// Calls to Jev by how they ended, in `JEV_RESULTS`' order, and their microseconds all told.
pub jev_calls: [AtomicU64; 5],
pub jev_micros: AtomicU64,
/// Microseconds calls waited for their turn under TypeSafe's limits (#277), part of `jev_micros`.
pub jev_paced_micros: AtomicU64,
/// What each person's Signals, icons and key checks used, and whose key paid (#278): items
/// judged, Signals they fit, and TypeSafe's input and output tokens. Floats: a call shared by
/// several people on the server's key is split between them.
people: std::sync::Mutex<std::collections::BTreeMap<(String, &'static str), [f64; 4]>>,
/// The worker's job now, and since when.
job: std::sync::Mutex<Option<(&'static str, std::time::Instant)>>,
/// Jobs finished, by kind: how many, and their seconds all told.
@@ -64,11 +75,26 @@ impl Stats {
feeds: AtomicI64::new(-1),
pending: AtomicI64::new(-1),
downloaded: AtomicI64::new(-1),
signals_last_pass: AtomicI64::new(0),
jev_calls: [const { AtomicU64::new(0) }; 5],
jev_micros: AtomicU64::new(0),
jev_paced_micros: AtomicU64::new(0),
people: std::sync::Mutex::new(std::collections::BTreeMap::new()),
job: std::sync::Mutex::new(None),
jobs: std::sync::Mutex::new(std::collections::BTreeMap::new()),
}
}
/// Adds to what `who` used on `key` ("server" or "own"): items judged, fits, input and output
/// tokens.
pub fn bill(&self, who: &str, key: &'static str, used: [f64; 4]) {
let mut people = self.people.lock().unwrap();
let row = people.entry((who.to_owned(), key)).or_default();
for (r, u) in row.iter_mut().zip(used) {
*r += u;
}
}
/// Marks the worker as on a job of `kind` until what it returns is dropped, however the job
/// ends: finished, failed or stopped.
pub fn job(&'static self, kind: &'static str) -> Job {
@@ -90,6 +116,11 @@ impl Drop for Job {
}
}
/// How a call to Jev ended: answered, its key refused (401-403), the request refused as
/// malformed (400, 422), over TypeSafe's rate limits (429), or anything else, TypeSafe's 5xx and
/// timeouts.
pub const JEV_RESULTS: [&str; 5] = ["ok", "key_refused", "malformed", "rate_limited", "error"];
/// Counts one more of something in progress until dropped, however it ends.
pub struct Busy(&'static AtomicI64);
@@ -129,7 +160,8 @@ pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
Err(e) => return tracing::error!(error = %e, "metrics could not start"),
};
rt.block_on(async move {
let app = axum::Router::new().route(
let app = axum::Router::new()
.route(
"/metrics",
axum::routing::get(move || {
let s = s.clone();
@@ -155,22 +187,59 @@ pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
pub fn render(s: &Sources) -> String {
let mut out = String::new();
let (open, idle, max) = s.ctx.db.pool();
gauge(&mut out, "ipx_db_connections", "Database connections open, in use or idle.",
&[("state=\"in_use\"", open as f64 - idle as f64), ("state=\"idle\"", idle as f64)]);
gauge(
&mut out,
"ipx_db_connections",
"Database connections open, in use or idle.",
&[("state=\"in_use\"", open as f64 - idle as f64), ("state=\"idle\"", idle as f64)],
);
gauge(&mut out, "ipx_db_connections_max", "The most database connections the pool opens.", &[("", max as f64)]);
gauge(&mut out, "ipx_worker_queue", "Commands waiting for the worker: scans, downloads and the like.",
&[("", (s.cmds.max_capacity() - s.cmds.capacity()) as f64)]);
gauge(&mut out, "ipx_event_listeners", "Open pages and socket clients listening for live updates.",
&[("", s.events.receiver_count() as f64)]);
gauge(&mut out, "ipx_torrent_slots_free", "Torrents that could start now.",
&[("", s.ctx.torrent_slots.available_permits() as f64)]);
gauge(&mut out, "ipx_deadair_playing", "Episodes someone is playing through DeadAir's playlist.",
&[("", s.playing.lock().map_or(0, |p| p.len()) as f64)]);
work(&mut out, &STATS, s.events.len(), s.ctx.retry.lock().map_or(0, |r| r.len()), s.ctx.cfg().general.art_cache_mb * 1_048_576);
gauge(
&mut out,
"ipx_worker_queue",
"Commands waiting for the worker: scans, downloads and the like.",
&[("", (s.cmds.max_capacity() - s.cmds.capacity()) as f64)],
);
gauge(
&mut out,
"ipx_event_listeners",
"Open pages and socket clients listening for live updates.",
&[("", s.events.receiver_count() as f64)],
);
gauge(
&mut out,
"ipx_torrent_slots_free",
"Torrents that could start now.",
&[("", s.ctx.torrent_slots.available_permits() as f64)],
);
gauge(
&mut out,
"ipx_deadair_playing",
"Episodes someone is playing through DeadAir's playlist.",
&[("", s.playing.lock().map_or(0, |p| p.len()) as f64)],
);
work(
&mut out,
&STATS,
s.events.len(),
s.ctx.retry.lock().map_or(0, |r| r.len()),
s.ctx.cfg().general.art_cache_mb * 1_048_576,
);
gauge(
&mut out,
"ipx_signals_keys_refused",
"People whose TypeSafe key was refused, whose Signals wait until they save another.",
&[("", s.ctx.signals_refused.lock().map_or(0, |r| r.len()) as f64)],
);
let rt = s.runtime.metrics();
gauge(&mut out, "ipx_runtime_workers", "The async runtime's worker threads.", &[("", rt.num_workers() as f64)]);
gauge(&mut out, "ipx_runtime_tasks", "Tasks alive in the async runtime.", &[("", rt.num_alive_tasks() as f64)]);
gauge(&mut out, "ipx_runtime_queue", "Tasks waiting in the runtime's global queue.", &[("", rt.global_queue_depth() as f64)]);
gauge(
&mut out,
"ipx_runtime_queue",
"Tasks waiting in the runtime's global queue.",
&[("", rt.global_queue_depth() as f64)],
);
let (rss, fds) = ipx_directory::metrics::process();
if let Some(v) = rss {
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
@@ -178,9 +247,24 @@ pub fn render(s: &Sources) -> String {
if let Some(v) = fds {
gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]);
}
counter(&mut out, "ipx_feed_fetches_total", "Feeds fetched, answered 304 included.", &[("", ipx_feed::FETCHES.load(Relaxed) as f64)]);
counter(&mut out, "ipx_feed_bytes_total", "Bytes of feed bodies fetched.", &[("", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64)]);
gauge(&mut out, "ipx_uptime_seconds", "Seconds since the daemon started.", &[("", s.started.elapsed().as_secs() as f64)]);
counter(
&mut out,
"ipx_feed_fetches_total",
"Feeds fetched, answered 304 included.",
&[("", ipx_feed::FETCHES.load(Relaxed) as f64)],
);
counter(
&mut out,
"ipx_feed_bytes_total",
"Bytes of feed bodies fetched.",
&[("", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64)],
);
gauge(
&mut out,
"ipx_uptime_seconds",
"Seconds since the daemon started.",
&[("", s.started.elapsed().as_secs() as f64)],
);
let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION"));
gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]);
out
@@ -193,34 +277,139 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
gauge(out, "ipx_worker_busy", "1 while the worker is on a job.", &[("", job.is_some() as u8 as f64)]);
if let Some((kind, since)) = job {
let label = format!("kind=\"{kind}\"");
gauge(out, "ipx_worker_job_seconds", "How long the worker has been on its job, by the job's kind.",
&[(&label, since.elapsed().as_secs_f64())]);
gauge(
out,
"ipx_worker_job_seconds",
"How long the worker has been on its job, by the job's kind.",
&[(&label, since.elapsed().as_secs_f64())],
);
}
let jobs = st.jobs.lock().unwrap().clone();
let labels: Vec<String> = jobs.keys().map(|k| format!("kind=\"{k}\"")).collect();
let pairs = |f: fn(&(u64, f64)) -> f64| labels.iter().zip(jobs.values()).map(|(l, v)| (l.as_str(), f(v))).collect::<Vec<_>>();
let pairs = |f: fn(&(u64, f64)) -> f64| {
labels.iter().zip(jobs.values()).map(|(l, v)| (l.as_str(), f(v))).collect::<Vec<_>>()
};
counter(out, "ipx_jobs_total", "Jobs the worker has finished, by kind.", &pairs(|v| v.0 as f64));
counter(out, "ipx_job_seconds_total", "Seconds the worker has spent on jobs, by kind.", &pairs(|v| v.1));
let last = st.last_scan_done.load(Relaxed);
if last > 0 {
gauge(out, "ipx_last_scan_age_seconds", "Seconds since a scan last finished.", &[("", (now - last) as f64)]);
}
gauge(out, "ipx_http_requests_in_flight", "Web requests being answered.", &[("", st.http_in_flight.load(Relaxed) as f64)]);
gauge(out, "ipx_scan_feeds_fetching", "Feeds a scan is fetching at once.", &[("", st.feeds_fetching.load(Relaxed) as f64)]);
gauge(
out,
"ipx_http_requests_in_flight",
"Web requests being answered.",
&[("", st.http_in_flight.load(Relaxed) as f64)],
);
gauge(
out,
"ipx_scan_feeds_fetching",
"Feeds a scan is fetching at once.",
&[("", st.feeds_fetching.load(Relaxed) as f64)],
);
gauge(out, "ipx_downloads_active", "Files downloading now.", &[("", st.downloads_active.load(Relaxed) as f64)]);
counter(out, "ipx_download_bytes_total", "Bytes downloaded since the daemon started.", &[("", st.download_bytes.load(Relaxed) as f64)]);
counter(
out,
"ipx_download_bytes_total",
"Bytes downloaded since the daemon started.",
&[("", st.download_bytes.load(Relaxed) as f64)],
);
gauge(out, "ipx_event_queue", "Live updates sent and not yet read by every listener.", &[("", event_queue as f64)]);
counter(out, "ipx_events_missed_total", "Live updates dropped for a listener that fell behind.", &[("", st.events_missed.load(Relaxed) as f64)]);
gauge(out, "ipx_deadair_backlog", "Files of shows someone has DeadAir on that it has not looked at, those already heard through included.", &[("", st.deadair_backlog.load(Relaxed) as f64)]);
counter(
out,
"ipx_events_missed_total",
"Live updates dropped for a listener that fell behind.",
&[("", st.events_missed.load(Relaxed) as f64)],
);
gauge(
out,
"ipx_deadair_backlog",
"Files of shows someone has DeadAir on that it has not looked at, those already heard through included.",
&[("", st.deadair_backlog.load(Relaxed) as f64)],
);
let since = st.deadair_busy_since.load(Relaxed);
gauge(out, "ipx_deadair_busy_seconds", "How long DeadAir has been on its file; 0 when on none.",
&[("", if since > 0 { (now - since) as f64 } else { 0.0 })]);
gauge(
out,
"ipx_deadair_busy_seconds",
"How long DeadAir has been on its file; 0 when on none.",
&[("", if since > 0 { (now - since) as f64 } else { 0.0 })],
);
gauge(out, "ipx_feed_retries", "Feeds waiting to try a failed name lookup again.", &[("", retries as f64)]);
gauge(out, "ipx_art_cache_bytes", "Artwork kept on disk, as of the last sweep.", &[("", st.art_cache_bytes.load(Relaxed) as f64)]);
gauge(
out,
"ipx_art_cache_bytes",
"Artwork kept on disk, as of the last sweep.",
&[("", st.art_cache_bytes.load(Relaxed) as f64)],
);
gauge(out, "ipx_art_cache_limit_bytes", "The most artwork kept.", &[("", art_limit as f64)]);
counter(out, "ipx_art_evicted_total", "Images thrown out to keep the artwork under its limit.", &[("", st.art_evicted.load(Relaxed) as f64)]);
counter(out, "ipx_art_requests_total", "Artwork asked for: served from disk, or fetched from the publisher.",
&[("result=\"hit\"", st.art_hits.load(Relaxed) as f64), ("result=\"miss\"", st.art_misses.load(Relaxed) as f64)]);
counter(
out,
"ipx_art_evicted_total",
"Images thrown out to keep the artwork under its limit.",
&[("", st.art_evicted.load(Relaxed) as f64)],
);
counter(
out,
"ipx_art_requests_total",
"Artwork asked for: served from disk, or fetched from the publisher.",
&[
("result=\"hit\"", st.art_hits.load(Relaxed) as f64),
("result=\"miss\"", st.art_misses.load(Relaxed) as f64),
],
);
// By person and whose key paid (#278). An account's name is often an email address: /metrics
// is on the LAN only, as Prometheus is.
let people = st.people.lock().unwrap().clone();
let labels: Vec<String> =
people.keys().map(|(who, key)| format!("user=\"{}\",key=\"{key}\"", label(who))).collect();
let by = |n: usize| labels.iter().zip(people.values()).map(|(l, v)| (l.as_str(), v[n])).collect::<Vec<_>>();
counter(
out,
"ipx_signals_judged_total",
"Items Jev has judged for someone's Signals, by person and whose key paid.",
&by(0),
);
counter(out, "ipx_signals_hits_total", "Signals an item was found to fit, by person and whose key paid.", &by(1));
let tokens: Vec<String> =
labels.iter().flat_map(|l| [format!("kind=\"input\",{l}"), format!("kind=\"output\",{l}")]).collect();
let used: Vec<(&str, f64)> =
tokens.iter().zip(people.values().flat_map(|v| [v[2], v[3]])).map(|(l, v)| (l.as_str(), v)).collect();
counter(
out,
"ipx_jev_tokens_total",
"Tokens TypeSafe counted for calls to Jev, what they cost, by person and whose key paid.",
&used,
);
let last = st.signals_last_pass.load(Relaxed);
if last > 0 {
gauge(
out,
"ipx_signals_last_pass_age_seconds",
"Seconds since the Signals judge last finished a pass.",
&[("", (now - last) as f64)],
);
}
let labels: Vec<String> = JEV_RESULTS.iter().map(|r| format!("result=\"{r}\"")).collect();
let calls: Vec<(&str, f64)> =
labels.iter().zip(&st.jev_calls).map(|(l, c)| (l.as_str(), c.load(Relaxed) as f64)).collect();
counter(
out,
"ipx_jev_requests_total",
"Calls to TypeSafe's Jev, Signals' and their icons', by how they ended.",
&calls,
);
counter(
out,
"ipx_jev_seconds_total",
"Seconds spent on calls to Jev, their wait for a turn included.",
&[("", st.jev_micros.load(Relaxed) as f64 / 1e6)],
);
counter(
out,
"ipx_jev_paced_seconds_total",
"Seconds calls to Jev waited for their turn under TypeSafe's rate limits.",
&[("", st.jev_paced_micros.load(Relaxed) as f64 / 1e6)],
);
for (name, help, v) in [
("ipx_feeds", "Feeds in the catalogue, as last counted.", &st.feeds),
("ipx_downloads_pending", "Files a scan will download on its own, as last counted.", &st.pending),
@@ -233,6 +422,11 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
}
}
/// A label's value as Prometheus's text format reads it.
fn label(v: &str) -> String {
v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
}
/// A counter, which only goes up while the daemon runs, in Prometheus's text format.
pub fn counter(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
write(out, "counter", name, help, samples);
@@ -278,15 +472,37 @@ mod tests {
assert_eq!(T.downloads_active.load(Relaxed), 1);
let mut out = String::new();
work(&mut out, &T, 0, 0, 0);
assert!(out.contains("ipx_worker_busy 1\n") && out.contains("ipx_worker_job_seconds{kind=\"scan\"}"), "{out}");
assert!(
out.contains("ipx_worker_busy 1\n") && out.contains("ipx_worker_job_seconds{kind=\"scan\"}"),
"{out}"
);
}
// Dropped, as an error or a stop drops them: nothing left in progress, one scan done.
assert_eq!(T.downloads_active.load(Relaxed), 0);
let mut out = String::new();
work(&mut out, &T, 3, 1, 10);
assert!(out.contains("ipx_worker_busy 0\n") && !out.contains("ipx_worker_job_seconds"), "{out}");
assert!(out.contains("ipx_jobs_total{kind=\"scan\"} 1\n") && out.contains("# TYPE ipx_jobs_total counter"), "{out}");
assert!(
out.contains("ipx_jobs_total{kind=\"scan\"} 1\n") && out.contains("# TYPE ipx_jobs_total counter"),
"{out}"
);
assert!(out.contains("ipx_event_queue 3\n") && out.contains("ipx_feed_retries 1\n"));
assert!(
out.contains("ipx_jev_requests_total{result=\"key_refused\"} 0\n")
&& !out.contains("ipx_signals_last_pass_age_seconds"),
"{out}"
);
T.bill("sam@example.net", "server", [1.0, 1.0, 600.0, 50.0]);
T.bill("sam@example.net", "server", [1.0, 0.0, 300.5, 25.0]);
T.bill("a \"b\"", "own", [0.0, 0.0, 10.0, 1.0]);
let mut out = String::new();
work(&mut out, &T, 0, 0, 0);
assert!(out.contains("ipx_signals_judged_total{user=\"sam@example.net\",key=\"server\"} 2\n"), "{out}");
assert!(
out.contains("ipx_jev_tokens_total{kind=\"input\",user=\"sam@example.net\",key=\"server\"} 900.5\n"),
"{out}"
);
assert!(out.contains("ipx_jev_tokens_total{kind=\"output\",user=\"a \\\"b\\\"\",key=\"own\"} 1\n"), "{out}");
// The library's counts are left out until the daemon has counted it.
assert!(!out.contains("ipx_feeds "));
}

View File

@@ -39,19 +39,23 @@ pub fn pick(candidates: &[Candidate], total: u64, limit: u64) -> Vec<Candidate>
/// Files older than the cutoff. Flagged ones are already excluded by the query.
pub fn aged(candidates: &[Candidate], cutoff: i64) -> Vec<Candidate> {
candidates
.iter()
.filter(|c| c.age_key > 0 && c.age_key < cutoff)
.cloned()
.collect()
candidates.iter().filter(|c| c.age_key > 0 && c.age_key < cutoff).cloned().collect()
}
pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
let mut report = Report::default();
// Someone may have deleted a file by hand; the row must stop claiming it exists.
// Someone may have deleted a file by hand; the row must stop claiming it exists. Not while
// the folder itself is gone (the array stopped, shfs dropped): every file would look deleted
// and the whole library be forgotten at once (#293).
// Each step is a span of the reap's trace, so a slow one says which part was slow.
for (id, path) in db.missing_files().instrument(step("missing_files")).await? {
let missing = if cfg.general.download_dir.is_dir() {
db.missing_files().instrument(step("missing_files")).await?
} else {
tracing::warn!(dir = %cfg.general.download_dir.display(), "the downloads folder is not there; left every file as it was");
vec![]
};
for (id, path) in missing {
if !dry_run {
db.mark_reaped(id).await?;
}
@@ -79,11 +83,8 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
if cfg.general.max_total_gb > 0.0 {
let limit = (cfg.general.max_total_gb * 1_073_741_824.0) as u64;
// Re-read: the age sweep may already have freed enough.
let remaining: Vec<Candidate> = candidates
.iter()
.filter(|c| !report.aged_out.iter().any(|a| a.id == c.id))
.cloned()
.collect();
let remaining: Vec<Candidate> =
candidates.iter().filter(|c| !report.aged_out.iter().any(|a| a.id == c.id)).cloned().collect();
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
report.over_quota = pick(&remaining, total, limit);
for c in &report.over_quota {
@@ -99,10 +100,7 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
return Ok(c.bytes.max(0) as u64);
}
// A torrent's folder measures as a directory entry, not what is in it: the recorded size then.
let size = std::fs::metadata(&c.path)
.ok()
.filter(|m| m.is_file())
.map_or(c.bytes.max(0) as u64, |m| m.len());
let size = std::fs::metadata(&c.path).ok().filter(|m| m.is_file()).map_or(c.bytes.max(0) as u64, |m| m.len());
// Missing is fine: the point is that it is gone.
if let Err(e) = crate::download::remove(std::path::Path::new(&c.path))
&& e.kind() != std::io::ErrorKind::NotFound
@@ -119,14 +117,7 @@ mod tests {
use super::*;
fn cand(id: i64, bytes: i64, age_key: i64, read: bool) -> Candidate {
Candidate {
id,
url: format!("https://x/{id}"),
path: format!("/tmp/{id}"),
bytes,
age_key,
read,
}
Candidate { id, url: format!("https://x/{id}"), path: format!("/tmp/{id}"), bytes, age_key, read }
}
#[test]
@@ -183,7 +174,8 @@ mod tests {
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
).await
)
.await
.unwrap();
let got: Vec<i64> = db.reap_candidates().await.unwrap().iter().map(|c| c.id).collect();
@@ -212,13 +204,31 @@ mod tests {
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'heard', 'u1', '/tmp/heard', 10, 'done', 10),
(2, 'f', 'pinned', 'u2', '/tmp/pinned', 10, 'done', 20);",
).await
)
.await
.unwrap();
let got: Vec<(i64, bool)> = db.reap_candidates().await.unwrap().iter().map(|c| (c.id, c.read)).collect();
assert_eq!(got, vec![(1, false), (2, false)], "sam has not heard it, and nobody here pinned it (#156)");
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "the old item goes, a leaver's pin or not");
}
#[tokio::test]
async fn a_downloads_folder_that_is_gone_forgets_no_file() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1, 'f', 'a', 'u1', '/nowhere/ipx-test/a.mp3', 'done');",
)
.await
.unwrap();
let mut cfg = Config::default();
cfg.general.download_dir = "/nowhere/ipx-test".into();
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 0, "the folder is gone, not the file");
cfg.general.download_dir = std::env::temp_dir();
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 1, "the folder is there and the file is not");
}
#[tokio::test]
async fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().await.unwrap();
@@ -233,7 +243,8 @@ mod tests {
('f', 'recent', 900);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
).await
)
.await
.unwrap();
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "only the old, fileless, unflagged one");

View File

@@ -5,23 +5,40 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use futures_util::StreamExt as _;
use anyhow::Result;
use tracing::Instrument;
use std::sync::atomic::Ordering::Relaxed;
use crate::Ctx;
use crate::db::{Ask, Unjudged};
use crate::db::{Ask, Unjudged, step};
use crate::feed;
use crate::metrics::STATS;
/// Items put to Jev a pass. A pass ends early once there are none left, so this only says how
/// long the queue is held before people who got a key later are looked at.
const BATCH: i64 = 50;
/// Between passes when nothing wakes it: a scan or a new Signal does, so this is a backstop.
const IDLE: std::time::Duration = std::time::Duration::from_secs(300);
/// Calls to Jev in flight at once (#275). One at a time, about 150 ms each, a new Signal's month
/// of items took twenty minutes. Enough for `MAX_RATE` at that pace; the pace is `pace`'s to keep.
const IN_FLIGHT: usize = 10;
/// TypeSafe's default limits are 80 requests and 100,000 input tokens a second (#277). Calls are
/// paced under both, with room left for the icon picks and key checks that share them.
const MAX_RATE: f64 = 60.0;
const MAX_TOKENS: f64 = 80_000.0;
/// When the next call may start, for every call ipx makes to Jev.
static NEXT_CALL: tokio::sync::Mutex<Option<tokio::time::Instant>> = tokio::sync::Mutex::const_new(None);
/// How sure Jev has to be that an item is about the keywords.
const THRESHOLD: f64 = 0.5;
/// Of an item's text, which is a podcast's whole show notes at times.
const TEXT_CHARS: usize = 2000;
/// Of an item's text, which is a podcast's whole show notes at times. 2000 until #275: on 55 real
/// items and six Signals, 1000 changed none of 330 verdicts and saved an eighth of the input
/// tokens; 500 changed one, an Apple podcast's episode no longer about Apple.
const TEXT_CHARS: usize = 1000;
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
loop {
@@ -29,6 +46,7 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
_ = stop.changed() => return,
done = pass(&ctx) => done,
};
crate::metrics::STATS.signals_last_pass.store(crate::db::now(), Relaxed);
match done {
Ok(true) => continue,
Ok(false) => {}
@@ -42,62 +60,149 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
}
}
/// The key every Signal is judged on, when an admin has chosen that the server pays for Jev
/// (#271) and the server has a key; None leaves each person to their own.
pub fn server_key(ctx: &Ctx) -> Option<String> {
if !ctx.cfg().general.signals_on_server {
return None;
}
typesafe_key()
}
/// The server's own TypeSafe key, `TYPESAFE_KEY`; set but empty is none, as the browser suite
/// sets it so the test daemon never spends a real one.
pub fn typesafe_key() -> Option<String> {
std::env::var("TYPESAFE_KEY").ok().filter(|k| !k.trim().is_empty())
}
/// One batch. True when there may be more waiting.
async fn pass(ctx: &Ctx) -> Result<bool> {
let skip: Vec<i64> = ctx.signals_refused.lock().unwrap().iter().copied().collect();
let items = ctx.db.unjudged(&skip, BATCH).await?;
let server = server_key(ctx);
// People's refused keys are not what is asked with when the server's is.
let skip: Vec<i64> =
if server.is_some() { Vec::new() } else { ctx.signals_refused.lock().unwrap().iter().copied().collect() };
let days = ctx.cfg().general.signal_days;
let items = ctx.db.unjudged(&skip, BATCH, server.is_some(), days).await?;
if items.is_empty() {
return Ok(false);
}
let span = tracing::info_span!("signals", entries = items.len());
let span = tracing::info_span!(
"signals",
entries = items.len(),
calls = tracing::field::Empty,
judged = tracing::field::Empty,
hits = tracing::field::Empty
);
async {
let mut refused = HashSet::new();
let groups = calls(&items, server.is_some());
let paid = if server.is_some() { "server" } else { "own" };
tracing::Span::current().record("calls", groups.len());
let refused = std::sync::Mutex::new(HashSet::new());
let stopped = AtomicBool::new(false);
let mut hits_for = std::collections::BTreeMap::<i64, usize>::new();
let mut stopped = false;
for item in &items {
if refused.contains(&item.user_id) {
continue;
let mut judged = 0;
// Each call starts only when a place is free, so one that sees a key refused or the pass
// stopped is not sent; those already in flight are let finish, and their answers kept.
// By index: a closure taking a reference here is not Send for every lifetime, which
// tokio::spawn asks of the judge's future.
let mut answers = futures_util::stream::iter(0..groups.len())
.map(|n| {
let (group, refused, stopped, server) = (&groups[n], &refused, &stopped, &server);
async move {
let first = group[0];
if stopped.load(Relaxed) || refused.lock().unwrap().contains(&first.user_id) {
return None;
}
match judge(ctx, item).await {
let key = server.as_deref().or(first.key.as_deref())?;
Some((group, judge(ctx, group, key, paid).await))
}
})
.buffer_unordered(IN_FLIGHT);
while let Some(answer) = answers.next().await {
let Some((group, answer)) = answer else { continue };
let first = group[0];
match answer {
Ok(verdicts) => {
for (item, verdicts) in group.iter().zip(verdicts) {
judged += 1;
let hits = verdicts.iter().filter(|(_, h)| *h).count();
STATS.bill(&item.user_name, paid, [1.0, hits as f64, 0.0, 0.0]);
if hits > 0 {
*hits_for.entry(item.user_id).or_default() += hits;
tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal");
}
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).await?;
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).instrument(step("record_verdicts")).await?;
}
}
// The server's key refused is everyone's: the queue waits for the next pass.
Err(e) if refuses_key(&e) && server.is_some() => {
if !stopped.swap(true, Relaxed) {
tracing::warn!(error = %format!("{e:#}"), "TypeSafe refused the server's key for Signals");
}
}
// A key TypeSafe will not take is not sent the rest of the queue. It is tried
// again once its owner saves one (`web::patch_me`) or the daemon restarts.
Err(e) if refuses_key(&e) => {
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
refused.insert(item.user_id);
if refused.lock().unwrap().insert(first.user_id) {
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
}
}
// TypeSafe says the request itself is wrong, so every other item's would be too:
// the rest wait for the next pass rather than each failing the same way (#257).
Err(e) if malformed(&e) => {
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
stopped = true;
break;
if !stopped.swap(true, Relaxed) {
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
}
}
// Over TypeSafe's limits: what is in flight finishes, nothing more starts, and
// the next pass waits for `back_off`.
Err(e) if rate_limited(&e) => {
if !stopped.swap(true, Relaxed) {
tracing::warn!(error = %format!("{e:#}"), "TypeSafe asked Signals to slow down");
}
}
// Left unjudged, so it is asked again next pass.
Err(e) => tracing::warn!(user = item.user_id, feed = %item.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
Err(e) => tracing::warn!(user = first.user_id, feed = %first.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
}
}
drop(answers);
let span = tracing::Span::current();
span.record("judged", judged);
span.record("hits", hits_for.values().sum::<usize>());
// Once a pass, not once an item: a page fetches its counts again on each.
for (user, hits) in hits_for {
ctx.out.emit(crate::ipc::Event::SignalHits { user, hits });
}
let refused = refused.into_inner().unwrap();
let some_refused = !refused.is_empty();
ctx.signals_refused.lock().unwrap().extend(refused);
// A pass that only failed would otherwise go straight round again on the same items, and
// one TypeSafe refused as malformed would ask again at once, however full.
anyhow::Ok(!stopped && (full(&items) || some_refused))
anyhow::Ok(!stopped.into_inner() && (full(&items) || some_refused))
}
.instrument(span)
.await
}
/// The calls a batch makes, each one item and the people asking about it. On the server's key
/// everyone's Signals on an item go in one call, its text sent once (#275); on people's own keys
/// each pays for their own. `unjudged` puts one item's rows together.
fn calls(items: &[Unjudged], share: bool) -> Vec<Vec<&Unjudged>> {
let mut out: Vec<Vec<&Unjudged>> = Vec::new();
for item in items {
match out.last_mut() {
Some(g)
if share
&& (g[0].feed_id.as_str(), g[0].guid.as_str()) == (item.feed_id.as_str(), item.guid.as_str()) =>
{
g.push(item)
}
_ => out.push(vec![item]),
}
}
out
}
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status
/// alone said only 422 (#257).
#[derive(Debug)]
@@ -125,51 +230,90 @@ fn full(items: &[Unjudged]) -> bool {
items.iter().map(|i| i.signals.len()).sum::<usize>() as i64 >= BATCH
}
fn refuses_key(e: &anyhow::Error) -> bool {
matches!(status(e), Some(401 | 402 | 403))
pub fn refuses_key(e: &anyhow::Error) -> bool {
matches!(status(e), Some(401..=403))
}
fn rate_limited(e: &anyhow::Error) -> bool {
status(e) == Some(429)
}
/// A call's turn: it starts at `next` or now, whichever is later, and holds the line for its
/// share of a second under both limits. Gives when it starts and when the one after may.
fn slot(
next: tokio::time::Instant,
now: tokio::time::Instant,
tokens: f64,
) -> (tokio::time::Instant, tokio::time::Instant) {
let start = next.max(now);
(start, start + std::time::Duration::from_secs_f64((1.0 / MAX_RATE).max(tokens / MAX_TOKENS)))
}
/// Waits for this call's turn. Its tokens are guessed from the request, a token to three bytes
/// of JSON, on the high side: TypeSafe says the true count only in the answer.
async fn pace(body: &serde_json::Value) {
let tokens = body.to_string().len() as f64 / 3.0;
let start = {
let mut next = NEXT_CALL.lock().await;
let now = tokio::time::Instant::now();
let (start, after) = slot(next.unwrap_or(now), now, tokens);
*next = Some(after);
start
};
let waited = start.saturating_duration_since(tokio::time::Instant::now());
tokio::time::sleep_until(start).await;
// How long the limits held this call back: near nothing unless ipx is near them (#277).
STATS.jev_paced_micros.fetch_add(waited.as_micros() as u64, Relaxed);
tracing::Span::current().record("paced_ms", waited.as_millis() as u64);
}
/// TypeSafe said too many: nobody calls again until it says, or a second.
async fn back_off(retry_after: Option<u64>) {
let until = tokio::time::Instant::now() + std::time::Duration::from_secs(retry_after.unwrap_or(1).min(60));
let mut next = NEXT_CALL.lock().await;
*next = Some(next.map_or(until, |n| n.max(until)));
}
fn malformed(e: &anyhow::Error) -> bool {
matches!(status(e), Some(400 | 422))
}
async fn judge(ctx: &Ctx, item: &Unjudged) -> Result<Vec<(i64, bool)>> {
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
/// One call about one item, for everyone in `group`: their verdicts, in the group's order.
async fn judge(ctx: &Ctx, group: &[&Unjudged], key: &str, paid: &'static str) -> Result<Vec<Vec<(i64, bool)>>> {
let asks: Vec<&Ask> = group.iter().flat_map(|i| &i.signals).collect();
let span = tracing::info_span!(
"jev",
user = item.user_id,
signal_count = item.signals.len(),
user = group[0].user_id,
people = group.len(),
signal_count = asks.len(),
"http.response.status_code" = tracing::field::Empty,
error = tracing::field::Empty,
"otel.status_code" = tracing::field::Empty,
input_tokens = tracing::field::Empty,
output_tokens = tracing::field::Empty,
paced_ms = tracing::field::Empty,
);
let answer: serde_json::Value = async {
let r = ctx.client.post(url).bearer_auth(&item.key).json(&request(item)).timeout(feed::FEED_TIMEOUT).send().await?;
let status = r.status().as_u16();
tracing::Span::current().record("http.response.status_code", status);
if !r.status().is_success() {
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
tracing::Span::current().record("error", body.as_str());
return Err(Refused { status, body }.into());
let answer = called(span, ask(ctx, key, &request(group[0], &asks))).await?;
// A shared call is billed by each person's share of its questions, which are most of it.
let [input, output] = usage(&answer);
for item in group {
let share = item.signals.len() as f64 / asks.len() as f64;
STATS.bill(&item.user_name, paid, [0.0, 0.0, input * share, output * share]);
}
anyhow::Ok(r.json().await?)
}
.instrument(span)
.await?;
// A question Jev left unanswered stays unjudged and is asked again.
Ok(item.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect())
Ok(group.iter().map(|i| i.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect()).collect())
}
/// The item as Jev sees it, as text, which is what its docs show: an object with an empty feed
/// title in it sent a null. One yes-or-no question for each Signal, keyed by its id.
fn request(item: &Unjudged) -> serde_json::Value {
fn request(item: &Unjudged, asks: &[&Ask]) -> serde_json::Value {
let text: String = text_of(item.description.as_deref().unwrap_or("")).chars().take(TEXT_CHARS).collect();
let state = format!(
"Feed: {}\nTitle: {}\n\n{text}",
item.feed_title.as_deref().unwrap_or(""),
item.title.as_deref().unwrap_or("")
);
let questions: serde_json::Map<String, serde_json::Value> =
item.signals.iter().map(|a| (key(a.id), question(a))).collect();
let questions: serde_json::Map<String, serde_json::Value> = asks.iter().map(|a| (key(a.id), question(a))).collect();
serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
}
@@ -183,7 +327,11 @@ fn key(id: i64) -> String {
/// 0.02, with AI code review still 0.94 (#259). `criteria` is an object of what true and false
/// mean: a sentence on its own was refused with a 422 (#257).
fn question(a: &Ask) -> serde_json::Value {
let mut said = format!("The reader follows a subject they call \"{}\", described by these words: {}.", a.name, a.keywords.join(", "));
let mut said = format!(
"The reader follows a subject they call \"{}\", described by these words: {}.",
a.name,
a.keywords.join(", ")
);
if let Some(d) = &a.description {
said.push_str(&format!(" In their own words: {d}"));
}
@@ -206,25 +354,124 @@ fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
/// chooses among options and cannot name one, so the page searches Font Awesome, whose index is
/// ipx-web's, and sends the likeliest. On the server's key, not the Signal owner's: it is the
/// server's page doing it, once a Signal.
pub async fn pick_icon(ctx: &Ctx, key: &str, name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> Result<String> {
pub async fn pick_icon(
ctx: &Ctx,
who: &str,
key: &str,
name: &str,
words: &[String],
description: Option<&str>,
candidates: &[(String, String)],
) -> Result<String> {
let span = tracing::info_span!(
"jev_icon",
candidates = candidates.len(),
"http.response.status_code" = tracing::field::Empty,
error = tracing::field::Empty,
"otel.status_code" = tracing::field::Empty,
input_tokens = tracing::field::Empty,
output_tokens = tracing::field::Empty,
paced_ms = tracing::field::Empty,
);
called(span, async {
let answer = ask(ctx, key, &icon_request(name, words, description, candidates)).await?;
let [input, output] = usage(&answer);
STATS.bill(who, "server", [0.0, 0.0, input, output]);
chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}"))
})
.await
}
/// Whether TypeSafe takes `key`, by one short question, before it is saved (#271): a key it
/// refuses would sit saved with nothing ever picked out, and nobody told why.
pub async fn check_key(ctx: &Ctx, who: &str, key: &str) -> Result<()> {
let span = tracing::info_span!(
"jev_key_check",
"http.response.status_code" = tracing::field::Empty,
error = tracing::field::Empty,
"otel.status_code" = tracing::field::Empty,
input_tokens = tracing::field::Empty,
output_tokens = tracing::field::Empty,
paced_ms = tracing::field::Empty,
);
let body = serde_json::json!({
"model": "jev-latest",
"state": "A reader is saving their key.",
"questions": { "ok": { "type": "noul", "instructions": "Is this a key check?",
"criteria": { "true": "It is.", "false": "It is not." } } },
});
let answer = called(span, ask(ctx, key, &body)).await?;
let [input, output] = usage(&answer);
STATS.bill(who, "own", [0.0, 0.0, input, output]);
Ok(())
}
/// Puts `body` to Jev on `key` and gives its answer, or what TypeSafe refused it with. Carries
/// this trace's `traceparent`, as every call out of ipx does.
async fn ask(ctx: &Ctx, key: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
let span = tracing::info_span!("jev_icon", candidates = candidates.len(), "http.response.status_code" = tracing::field::Empty);
async {
let r = ctx.client.post(url).bearer_auth(key).json(&icon_request(name, words, description, candidates)).timeout(feed::FEED_TIMEOUT).send().await?;
let mut req = ctx.client.post(url).bearer_auth(key).json(body).timeout(feed::FEED_TIMEOUT);
if let Some(tp) = ipx_directory::otel::traceparent() {
req = req.header("traceparent", tp);
}
pace(body).await;
let r = req.send().await?;
let status = r.status().as_u16();
tracing::Span::current().record("http.response.status_code", status);
if status == 429 {
back_off(r.headers().get("retry-after").and_then(|v| v.to_str().ok()?.parse().ok())).await;
}
if !r.status().is_success() {
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
return Err(Refused { status, body }.into());
}
let answer: serde_json::Value = r.json().await?;
chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}"))
// What a call cost, which TypeSafe says and nothing else would (#275); billed by the caller,
// who knows whose it was (#278).
for kind in ["input_tokens", "output_tokens"] {
if let Some(t) = answer["usage"][kind].as_u64() {
tracing::Span::current().record(kind, t);
}
.instrument(span)
.await
}
Ok(answer)
}
fn icon_request(name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> serde_json::Value {
/// Input and output tokens, as an answer counts them; 0 when it does not.
fn usage(answer: &serde_json::Value) -> [f64; 2] {
["input_tokens", "output_tokens"].map(|k| answer["usage"][k].as_f64().unwrap_or(0.0))
}
/// Runs a call to Jev in `span`, counting it in /metrics by how it ended, and marking the span
/// failed, which a field named `error` alone does not: Tempo's failed traces left Jev's out.
async fn called<T>(span: tracing::Span, call: impl std::future::Future<Output = Result<T>>) -> Result<T> {
let started = std::time::Instant::now();
let r = call.instrument(span.clone()).await;
STATS.jev_micros.fetch_add(started.elapsed().as_micros() as u64, Relaxed);
STATS.jev_calls[outcome(&r)].fetch_add(1, Relaxed);
if let Err(e) = &r {
span.record("error", format!("{e:#}").as_str());
span.record("otel.status_code", "ERROR");
}
r
}
/// Which of `metrics::JEV_RESULTS` a call's result is.
fn outcome<T>(r: &Result<T>) -> usize {
match r {
Ok(_) => 0,
Err(e) if refuses_key(e) => 1,
Err(e) if malformed(e) => 2,
Err(e) if rate_limited(e) => 3,
Err(_) => 4,
}
}
fn icon_request(
name: &str,
words: &[String],
description: Option<&str>,
candidates: &[(String, String)],
) -> serde_json::Value {
let mut state = format!("Signal: {name}\nListens for: {}", words.join(", "));
if let Some(d) = description {
state.push_str(&format!("\nIn their words: {d}"));
@@ -307,7 +554,10 @@ mod tests {
#[test]
fn an_icon_is_one_jev_was_offered() {
let offered = vec![("solid/robot".to_string(), "Robot: ai, bot".to_string()), ("solid/laptop-code".to_string(), "Laptop Code".to_string())];
let offered = vec![
("solid/robot".to_string(), "Robot: ai, bot".to_string()),
("solid/laptop-code".to_string(), "Laptop Code".to_string()),
];
let r = icon_request("AI Development", &["ai".into()], Some("AI for writing software."), &offered);
assert_eq!(r["questions"]["icon"]["type"], "choice");
assert_eq!(r["questions"]["icon"]["criteria"]["solid/robot"], "Robot: ai, bot");
@@ -327,13 +577,76 @@ mod tests {
assert_eq!(text_of(r#"<img src="x.png" alt="">"#), "");
}
#[test]
fn a_call_is_counted_by_how_it_ended() {
let refused = |status| -> Result<()> { Err(Refused { status, body: String::new() }.into()) };
let named = |r: Result<()>| crate::metrics::JEV_RESULTS[outcome(&r)];
assert_eq!(named(Ok(())), "ok");
assert_eq!(named(refused(402)), "key_refused");
assert_eq!(named(refused(422)), "malformed");
assert_eq!(named(refused(503)), "error");
assert_eq!(named(refused(429)), "rate_limited");
assert_eq!(named(Err(anyhow::anyhow!("timed out"))), "error");
}
#[test]
fn on_the_servers_key_one_item_is_one_call_for_everyone() {
let row = |user_id, guid: &str| Unjudged {
user_id,
user_name: format!("u{user_id}"),
key: None,
feed_id: "f".into(),
guid: guid.into(),
feed_title: None,
title: None,
description: None,
signals: vec![Ask { id: user_id * 10, name: "s".into(), keywords: vec![], description: None }],
};
let items = [row(1, "x"), row(2, "x"), row(1, "y")];
let shape = |share| {
calls(&items, share).iter().map(|g| g.iter().map(|i| i.user_id).collect::<Vec<_>>()).collect::<Vec<_>>()
};
assert_eq!(shape(true), [vec![1, 2], vec![1]], "x asked once, for both");
assert_eq!(shape(false), [vec![1], vec![2], vec![1]], "each on their own key");
let both: Vec<&Ask> = calls(&items, true)[0].iter().flat_map(|i| &i.signals).collect();
let r = request(&items[0], &both);
assert!(r["questions"]["signal_10"].is_object() && r["questions"]["signal_20"].is_object());
}
#[test]
fn calls_are_paced_under_both_of_typesafes_limits() {
let t0 = tokio::time::Instant::now();
let ms = |a: tokio::time::Instant| (a - t0).as_secs_f64() * 1000.0;
// A small call holds the line for a 60th of a second, under 80 requests a second.
let (start, next) = slot(t0, t0, 1000.0);
assert_eq!((ms(start), ms(next).round()), (0.0, 17.0));
// One of 8,000 tokens, a tenth of a second, under 100,000 tokens a second.
let (start, next) = slot(next, t0, 8000.0);
assert_eq!((ms(start).round(), ms(next).round()), (17.0, 117.0));
// After a quiet spell, a call starts at once.
let later = t0 + std::time::Duration::from_secs(5);
assert_eq!(slot(next, later, 1000.0).0, later);
}
#[test]
fn a_batch_is_full_by_its_rows_not_its_items() {
let item = |n: usize| Unjudged {
user_id: 1, key: "k".into(), feed_id: "f".into(), guid: "g".into(), feed_title: None, title: None, description: None,
signals: (0..n as i64).map(|id| Ask { id, name: "s".into(), keywords: vec![], description: None }).collect(),
user_id: 1,
user_name: "a".into(),
key: Some("k".into()),
feed_id: "f".into(),
guid: "g".into(),
feed_title: None,
title: None,
description: None,
signals: (0..n as i64)
.map(|id| Ask { id, name: "s".into(), keywords: vec![], description: None })
.collect(),
};
assert!(full(&(0..25).map(|_| item(2)).collect::<Vec<_>>()), "25 items of two Signals each is the 50 asked for");
assert!(
full(&(0..25).map(|_| item(2)).collect::<Vec<_>>()),
"25 items of two Signals each is the 50 asked for"
);
assert!(!full(&(0..24).map(|_| item(2)).collect::<Vec<_>>()));
}
@@ -341,7 +654,8 @@ mod tests {
fn each_signal_is_its_own_question_on_the_items_words() {
let item = Unjudged {
user_id: 1,
key: "k".into(),
user_name: "a".into(),
key: Some("k".into()),
feed_id: "f".into(),
guid: "g".into(),
feed_title: Some("Pit Lane".into()),
@@ -349,17 +663,33 @@ mod tests {
description: Some("<p>Qualifying <b>report</b></p>".into()),
signals: vec![
Ask { id: 3, name: "F1".into(), keywords: vec!["formula 1".into(), "f1".into()], description: None },
Ask { id: 4, name: "Food".into(), keywords: vec!["cooking".into()], description: Some("Recipes, not restaurants.".into()) },
Ask {
id: 4,
name: "Food".into(),
keywords: vec!["cooking".into()],
description: Some("Recipes, not restaurants.".into()),
},
],
};
let r = request(&item);
let r = request(&item, &item.signals.iter().collect::<Vec<_>>());
assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report");
let q = &r["questions"]["signal_3"];
assert_eq!(q["type"], "noul");
let says = q["instructions"].as_str().unwrap();
assert!(says.contains("\"F1\"") && says.contains("formula 1, f1"), "one subject, by its name and its words: {says}");
assert!(
says.contains("\"F1\"") && says.contains("formula 1, f1"),
"one subject, by its name and its words: {says}"
);
assert!(!says.contains("own words"), "no sentence, none said");
assert!(q["criteria"]["true"].is_string() && q["criteria"]["false"].is_string(), "TypeSafe takes an object, not a sentence");
assert!(r["questions"]["signal_4"]["instructions"].as_str().unwrap().contains("In their own words: Recipes, not restaurants."));
assert!(
q["criteria"]["true"].is_string() && q["criteria"]["false"].is_string(),
"TypeSafe takes an object, not a sentence"
);
assert!(
r["questions"]["signal_4"]["instructions"]
.as_str()
.unwrap()
.contains("In their own words: Recipes, not restaurants.")
);
}
}

View File

@@ -26,10 +26,7 @@ impl Torrents {
let listen_addr: SocketAddr = format!("0.0.0.0:{lo}").parse()?;
let opts = SessionOptions {
listen: Some(librqbit::ListenerOptions {
listen_addr,
..Default::default()
}),
listen: Some(librqbit::ListenerOptions { listen_addr, ..Default::default() }),
client_name_and_version: Some(concat!("ipx ", env!("CARGO_PKG_VERSION")).into()),
..Default::default()
};
@@ -72,12 +69,7 @@ impl Torrents {
),
)
.await
.map_err(|_| {
anyhow::anyhow!(
"no metadata after {} minutes, gave up",
limit.as_secs() / 60
)
})?
.map_err(|_| anyhow::anyhow!("no metadata after {} minutes, gave up", limit.as_secs() / 60))?
.context("adding the torrent")?;
let (id, handle) = match added {
@@ -116,10 +108,7 @@ impl Torrents {
let size = handle.stats().total_bytes;
self.seed(cfg, &handle).await;
self.session
.delete(id.into(), false)
.await
.context("releasing the torrent")?;
self.session.delete(id.into(), false).await.context("releasing the torrent")?;
Ok((path, size))
}

File diff suppressed because it is too large Load Diff

34
deny.toml Normal file
View File

@@ -0,0 +1,34 @@
# cargo deny check: known vulnerabilities, licences and where crates come from. Run before a push
# (CLAUDE.md, Tests). It reads Cargo.lock and the RustSec database, and builds nothing.
[graph]
# Production runs on Linux; Windows and macOS-only crates are not ipx's concern.
targets = ["x86_64-unknown-linux-gnu"]
[advisories]
ignore = [
# ringbuf 0.4's double free needs an element whose Drop panics; librqbit-utp's buffer holds
# only u8, which has no Drop. Its fix is ringbuf 0.5, which librqbit 9.0.1 cannot take (#281).
{ id = "RUSTSEC-2026-0293", reason = "librqbit-utp's ring buffer holds u8: no Drop to panic" },
]
[licenses]
# Permissive licences only; anything else is a question for the owner before it comes in.
allow = [
"MIT", "MIT-0", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause",
"ISC", "Zlib", "Unlicense", "0BSD", "CC0-1.0", "BSL-1.0", "Unicode-3.0", "MPL-2.0",
"CDLA-Permissive-2.0",
]
# ipx's own crates have no licence field and are not published.
private = { ignore = true }
[bans]
# Two versions of one crate (syn 1 and 2, and 25 more) only cost build time, and a warning for
# each on every run would be read by nobody.
multiple-versions = "allow"
wildcards = "deny"
allow-wildcard-paths = true
[sources]
unknown-registry = "deny"
unknown-git = "deny"

View File

@@ -165,7 +165,11 @@ subscribes to is checked once a day. One that is dead (failing for 30 days) or q
new in a year), with nobody subscribed and no file on disk, is removed from the catalogue.
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry. So
does Keep, the plus on a show's page, for whoever turns it on (`kept` on their subscription, #280): when
the OPML stops listing the show, or nobody subscribes to the OPML any more, it becomes a catalogue
entry outside the OPML instead of going, and stays on the lists of those who kept it. Anyone who
had it only through the OPML loses it as before. Leaving the OPML yourself keeps what you kept.
## Environment

View File

@@ -6,13 +6,21 @@ mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and p
about that subject: the name, the words and the sentence together, not any one word. Those items show in the Signal as well as in their
own feed. The rest stays noise, where it was.
Each person brings their own TypeSafe key, entered in Settings. Nothing is judged for someone
without one. Jev is asked once for each item, on that person's key, with one question for each of
their Signals that has not heard about the item yet.
Whose TypeSafe key pays for Jev is the admin's choice (#271), under Admin, Server, **Signals for
everyone, on this server's TypeSafe key** (`signals_on_server`):
- **On**, with the server's key in `TYPESAFE_KEY`: everyone has Signals, with nothing to set up.
Every item is asked about on the server's key, and keys people saved are kept but not used.
- **Off**, the default, or on with no `TYPESAFE_KEY`: each person turns Signals on in Settings
with a key of their own. Nothing is judged for someone without one, and their Signals folder is
not shown.
Jev is asked once for each item, on that key, with one question for each of the person's Signals
that has not heard about the item yet.
## What someone sees
- **The sidebar.** A Signals folder comes first in the feed list, under All Subscriptions (#255,
- **The sidebar.** Once Signals are on for someone, a Signals folder comes first in the feed list, under All Subscriptions (#255,
#256). It is closed or open as an OPML's folder is, remembered in each browser. Its row is like
any folder's (#265); New signal is the + on the folder's page. Inside the folder are the
person's Signals, each with its items and unread counted under its name.
@@ -22,15 +30,19 @@ their Signals that has not heard about the item yet.
**Auto** asks Jev to choose (#263), and a Signal made without an icon gets Jev's pick by
itself, a moment after it is made. Every Signal's tile is in the accent tint the folder has.
- **Each Signal's row** says "12 items · 3 unread", as a feed's row counts its own.
- **The folder's page** lists the Signals, as an OPML's page lists its feeds, with the + for a new one.
- **The folder's page** lists the Signals, with the + for a new one.
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
Its header says what it listens for (the sentence, or the words), and that nothing is picked
out without a key. Edit changes the name, the words or the sentence; Delete removes the Signal
Its header says what it listens for (the sentence, or the words), and, on people's own keys,
that nothing is picked out without one. Edit changes the name, the words or the sentence; Delete removes the Signal
and leaves the items in their feeds.
- **Read state is the item's.** Reading an item in a Signal reads it in its feed, and the other way
round. The counts follow without a reload (#258).
- **Settings, TypeSafe key.** A password field. A key saves on leaving the field, and the bin
forgets it. The key is never sent back to the page, which only learns whether one is set.
- **Settings, Signals.** On the server's key, a line saying the server pays. On people's own, a
Signals switch: turning it on shows a password field for the key, which saves on leaving the
field once TypeSafe has taken it (one short question on that key); a key TypeSafe refuses is
not saved, and the page says so. Turning the switch off, or the bin, forgets the key and hides
the folder; the Signals are kept. The key is never sent back to the page, which only learns
whether one is set.
## How an item gets into a Signal
@@ -65,24 +77,39 @@ scan worker, so a slow TypeSafe never holds up a scan.
`Db::unjudged` finds, newest first, up to 50 items that are:
- in a feed the Signal's owner subscribes to,
- published (or, with no date, first seen) in the last 30 days (`SIGNAL_DAYS`),
- not hidden from them by their block lists,
- not yet in `signal_verdicts` for that Signal,
- for someone with a key that TypeSafe has not refused since the daemon started.
- for someone with a key that TypeSafe has not refused since the daemon started,
- and either published (or, with no date, first seen) in the look-back window and not yet in
`signal_verdicts` for that Signal, or picked out by it before it was edited (`recheck`), however
old.
So a new Signal looks back a month, and after that sees each new item. A show subscribed to today
has its back catalogue skipped, apart from its last month.
The window is the admin's, Admin, Server, **Signals look back (days)** (`signal_days`, 7 by
default, 1 to 365; #276): each item in it is a call, and a month of production's feeds was about
8,000. So a new Signal looks back that far, and after that sees each new item. A show subscribed
to today has its back catalogue skipped, apart from the window.
The recent half reads the index `entries_recent`, on each feed's items by date, the expression it
is asked by; without it each wake read all of `entries`, 605 MB, 566 ms to find nothing (#274).
Rows for one item and one person are grouped, so an item is one call whatever the number of
Signals. A full batch means there may be more, and the next pass starts at once. Otherwise the
judge sleeps until woken or for 5 minutes.
Signals. On the server's key, everyone's Signals on the same item go in one call too, its text
sent once (#275). Up to ten calls are in flight at once (`IN_FLIGHT`): one at a time, about 150 ms
each, a month of items took twenty minutes. Every call to Jev, Signals', icons' and key checks',
takes a turn (`pace`, #277): TypeSafe's default limits are 80 requests and 100,000 input tokens a
second, and ipx keeps to 60 and 80,000, each call holding the line for a 60th of a second or its
guessed tokens' share, whichever is longer. A 429 holds every call back for its `Retry-After`, or
a second, and ends the pass. Each `jev` span says how long it waited (`paced_ms`), and
`ipx_jev_paced_seconds_total` adds them up; the dashboard draws calls and tokens a second against
the limits. A full batch means there may be more, and the next pass
starts at once. Otherwise the judge sleeps until woken or for 5 minutes.
### Every answer is kept
`signal_verdicts` records each answer, fitting or not. An item with no row has not been asked yet;
an item with a row is never asked again. So the cost is one call per item per person, once.
Changing a Signal's name, words or sentence deletes its verdicts, and everything in the window is
asked again. The first start with sentences (#259) deleted every verdict, since they answered the
Changing a Signal's name, words or sentence marks what it picked out to be asked again
(`recheck`), and those stay in its list until answered; what it passed over stays passed over, and
new items are asked the new question as they come (#276). The first start with sentences (#259) deleted every verdict, since they answered the
old question.
## TypeSafe
@@ -94,7 +121,7 @@ Questions in one call are judged independently and in parallel.
### A request
Sent with `Authorization: Bearer <the person's key>`, for an item and two of their Signals:
Sent with `Authorization: Bearer <the key>`, the server's or the person's, for an item and two of their Signals:
```json
{
@@ -122,7 +149,9 @@ Sent with `Authorization: Bearer <the person's key>`, for an item and two of the
```
- `state` is text: the feed's title, the item's title, and its show notes with the HTML taken out,
cut to 2,000 characters (`TEXT_CHARS`).
cut to 1,000 characters (`TEXT_CHARS`). It was 2,000 until #275: on 55 real items and six
Signals, 1,000 changed none of 330 verdicts and saved 12% of the input tokens (1,266 to 1,112 a
call); 500 changed one. Most of the input is the questions themselves, not the item.
- Each question is keyed `signal_<id>`, and its answer comes back under the same key.
- `criteria` is an object saying what true and false mean. A sentence there is refused with a 422
(#257).
@@ -155,8 +184,10 @@ missing from the answer is left unjudged and asked again next pass.
| TypeSafe answers | What the judge does |
|---|---|
| 401, 402, 403 | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". |
| 401, 402, 403, on the server's key | Everyone's key is refused. The pass ends and the queue waits for the next one, five minutes on. Logged: "TypeSafe refused the server's key for Signals". |
| 401, 402, 403, on a person's key | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". |
| 400, 422 | The request itself is wrong, so every item's would be. The pass ends; the items wait for the next one. Logged: "TypeSafe refused a Signals request as malformed", with TypeSafe's reason. |
| 429 | Over TypeSafe's limits. Every call waits for `Retry-After` or a second; the pass ends, what was in flight kept, and the rest wait for the next. Logged once: "TypeSafe asked Signals to slow down". |
| anything else, or no answer | That item is left unjudged and asked again next pass. Logged: "Jev could not judge an item". |
The error keeps TypeSafe's reply, cut to 500 characters, for example:
@@ -238,9 +269,18 @@ anywhere else.
{ "typesafe_key": "ts_…" }
```
A string saves the key and lets the judge try it again. `null` or an empty string forgets it. A
key is up to 500 printable characters. `GET /api/me` says `"typesafe_key_set": true` and nothing
more.
A string is checked with TypeSafe, by one short question on that key, then saved, and the judge
tries it again. `null` or an empty string forgets it. A key is up to 500 printable characters.
A key TypeSafe refuses is a `400` ("TypeSafe did not accept that key") and not saved; TypeSafe not
answering is a `502`, and the key is not saved either. `GET /api/me` says `"typesafe_key_set":
true` and nothing more about the key, and `"signals": "server"` or `"own"`, whose key is used.
### `PATCH /api/settings`, who pays
An admin's `{ "signals_on_server": true }` judges everyone's Signals on `TYPESAFE_KEY`, and
`false` on each person's own. `GET /api/settings` has `signals_on_server` and
`typesafe_server_key`, whether the server has a key to use; without one the setting changes
nothing. A change wakes the judge and lets keys refused before be tried again.
### `POST /api/signals/icon`
@@ -351,10 +391,15 @@ erDiagram
- **Refused keys are remembered in memory**, so a restart tries each once again.
- **Keywords changed while an item is being judged** can leave that one item's verdict from the old
words.
- **There is no meter** of how many calls a key has made. It is one call per item in the person's
feeds in the window: a person with 1,500 feeds busy enough to publish 3,000 items a month spends
about 3,000 calls the first month and then as many as come in.
- **Only the last 30 days** are ever judged, by publish date.
- **The meter is the server's**, `ipx_jev_tokens_total` and `ipx_jev_requests_total` on
`/metrics`, and the Signals row of the dashboard: tokens as TypeSafe's answers count them, by
person (`user`, the account's name) and whose key paid (`key`, `server` or `own`; #278), with
`ipx_signals_judged_total` and `ipx_signals_hits_total` the same way. A call shared by several
people on the server's key is split by each one's share of its questions. Icon picks bill the
person on the server's key, key checks the person on their own. The dashboard's **Who used what**
sums them over its time range. It is
one call per item in the person's feeds as they come, and per item in the window for a new Signal.
- **Only the window** is judged by publish date, apart from what an edit asks again.
## Testing
@@ -365,4 +410,6 @@ erDiagram
- `npx playwright test -g Signal`: makes a Signal from the folder's +, and checks that the matching
item shows and that reading it reads it in its feed and clears the badge. TypeSafe is stood in for
by the fixture server's `/jev` (`IPX_TYPESAFE_URL`), which refuses the shape TypeSafe refuses, and
`/signal.xml` is dated today, since only the last 30 days are judged.
`/signal.xml` is dated today, since only the window is judged. They spend the TypeSafe key, so
each runs only when the Signals code or the test has changed since it last passed (`signalsTest`;
`IPX_SIGNALS_TESTS=1` runs them anyway).

View File

@@ -254,6 +254,68 @@ table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev=
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
y += 8
# ---- ipx: Signals, items put to TypeSafe's Jev on each person's own key (#254)
row("ipx: Signals")
ts("Items judged", [prom('sum(increase(ipx_signals_judged_total{job="ipx"}[$__rate_interval]))', legend="judged"),
prom('sum(increase(ipx_signals_hits_total{job="ipx"}[$__rate_interval]))', ref="B", legend="fit a Signal")],
0, w=8, bars=True, desc="Each item is judged once against every Signal of its subscribers that has not had it; a burst follows a scan or a new Signal.")
ts("Calls to Jev", [prom('sum by (result) (increase(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', legend="{{result}}"),
prom('(sum(increase(ipx_jev_seconds_total{job="ipx"}[$__rate_interval])) - sum(increase(ipx_jev_paced_seconds_total{job="ipx"}[$__rate_interval]))) / '
'sum(increase(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', ref="B", legend="seconds a call")],
8, w=8, bars=True, stack=True,
desc="key_refused stops that person's Signals until they save another key; malformed stops the pass and is ipx's to fix; "
"rate_limited is TypeSafe saying slow down (#277), and every call waits; "
"error is TypeSafe failing or slow, and the item is asked again next pass. Seconds a call is TypeSafe's, without the wait for a turn.")["fieldConfig"]["overrides"] = [
{"matcher": {"id": "byName", "options": "seconds a call"},
"properties": [{"id": "unit", "value": "s"}, {"id": "custom.axisPlacement", "value": "right"},
{"id": "custom.drawStyle", "value": "line"}, {"id": "custom.stacking", "value": {"mode": "none"}}]}]
ts("Signals judge", [prom('max(ipx_signals_last_pass_age_seconds{job="ipx"})', legend="since the last pass"),
prom('max(ipx_signals_keys_refused{job="ipx"})', ref="B", legend="keys refused")],
16, w=8, unit="s", desc="It passes at least every five minutes; much longer means it is stuck on Jev. Keys refused, on the right, "
"are people whose Signals wait for a new key.")["fieldConfig"]["overrides"] = [
{"matcher": {"id": "byName", "options": "keys refused"},
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"}]}]
y += 8
ts("Tokens", [prom('sum by (kind) (increase(ipx_jev_tokens_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
0, w=8, bars=True, stack=True, desc="What TypeSafe counted for each call, as its answers say: what Signals, icons and key checks cost.")
ts("Tokens a call", [prom('sum by (kind) (increase(ipx_jev_tokens_total{job="ipx"}[$__rate_interval])) / ignoring(kind) group_left '
'sum(increase(ipx_jev_requests_total{job="ipx", result="ok"}[$__rate_interval]))', legend="{{kind}}")],
8, w=8, desc="Most of the input is the questions, one for each Signal, then the item's text, cut at 1,000 characters.")
# TypeSafe's default limits (#277): ipx paces itself to 60 calls and 80,000 input tokens a second.
LIMIT = lambda n: [{"color": "transparent", "value": None}, {"color": "red", "value": n}]
p = ts("Against TypeSafe's limits", [prom('sum(rate(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', legend="calls a second"),
prom('sum(rate(ipx_jev_tokens_total{job="ipx", kind="input"}[$__rate_interval])) / 1000', ref="B", legend="thousand input tokens a second"),
prom('sum(rate(ipx_jev_paced_seconds_total{job="ipx"}[$__rate_interval]))', ref="C", legend="seconds waited for a turn, a second")],
16, w=8, desc="TypeSafe allows 80 calls and 100,000 input tokens a second; the red line is 80 on both scales (calls, and thousands of tokens). "
"Waiting for a turn means ipx is holding itself under them, and is the time a new Signal's backfill takes longer for it.")
p["fieldConfig"]["defaults"]["custom"]["thresholdsStyle"] = {"mode": "line"}
p["fieldConfig"]["defaults"]["thresholds"] = {"mode": "absolute", "steps": LIMIT(80)}
y += 8
# By person and whose key paid (#278): who the server's TypeSafe bill is for. An item shared on the
# server's key is split by each person's share of its questions.
# Counts from before #278 have no person; user!="" leaves them out rather than drawing " ( key)".
ts("Input tokens by person", [prom('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="input", user!=""}[$__rate_interval]))',
legend="{{user}} ({{key}} key)")],
0, w=8, bars=True, stack=True, desc="server is the server's TypeSafe key, what the server pays for; own is the person's own key.")
ts("Items judged by person", [prom('sum by (user, key) (increase(ipx_signals_judged_total{job="ipx", user!=""}[$__rate_interval]))', legend="{{user}} ({{key}} key)"),
prom('sum by (user) (increase(ipx_signals_hits_total{job="ipx", user!=""}[$__rate_interval]))', ref="B", legend="{{user}}: fit a Signal")],
8, w=8, bars=True)
def ptab(expr, ref):
return {"refId": ref, "datasource": PROM, "expr": expr, "instant": True, "format": "table"}
panel("table", "Who used what", 8, 8, 16,
[ptab('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="input", user!=""}[$__range]))', "A"),
ptab('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="output", user!=""}[$__range]))', "B"),
ptab('sum by (user, key) (increase(ipx_signals_judged_total{job="ipx", user!=""}[$__range]))', "C")],
description="Over the dashboard's time range: each person's input and output tokens and items judged, and whose key paid. "
"Icon picks are on the server's key, key checks on the person's own.",
transformations=[{"id": "merge", "options": {}},
{"id": "organize", "options": {"excludeByName": {"Time": True},
"renameByName": {"user": "Person", "key": "Key", "Value #A": "Input tokens",
"Value #B": "Output tokens", "Value #C": "Items judged"}}}],
options={"showHeader": True, "sortBy": [{"displayName": "Input tokens", "desc": True}]},
fieldConfig={"defaults": {"decimals": 0}, "overrides": []})
y += 8
# ---- ipx: the process, what it holds between requests and jobs (#194)
row("ipx: process")
# Open connections stay open, idle, for ten minutes after a burst, so open at the max is not

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ipx-web",
"version": "1.1.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ipx-web",
"version": "1.1.0",
"version": "1.2.0",
"dependencies": {
"@fortawesome/fontawesome-free": "7.3.1",
"@swc/core": "^1.16.2",

View File

@@ -1,6 +1,6 @@
{
"name": "ipx-web",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
"scripts": {

View File

@@ -60,6 +60,9 @@ module.exports = defineConfig({
// production's Tempo and never opens production's database.
OTEL_EXPORTER_OTLP_ENDPOINT: '',
IPX_DATABASE_URL: '',
// The server's TypeSafe key is the shell's: /src/.envrc's is a development key, for this.
// Without one, the tests of the server paying for Signals see that it cannot.
TYPESAFE_KEY: process.env.TYPESAFE_KEY ?? '',
IPX_TYPESAFE_URL: 'http://127.0.0.1:8792/jev',
},
},

4
rustfmt.toml Normal file
View File

@@ -0,0 +1,4 @@
# Matches how the code was written by hand before rustfmt was adopted: long lines, and short
# expressions kept on one line.
max_width = 120
use_small_heuristics = "Max"

View File

@@ -67,9 +67,11 @@ export const ipx = {
},
{
method: 'PATCH', path: '/api/me', slug: 'patch-me',
summary: 'Change the words that hide an item in every feed you read.',
summary: 'Change the words that hide an item in every feed you read, or your TypeSafe key for Signals.',
doc: '<p>A key is checked with TypeSafe, by one short question on it, before it is saved.</p>',
body: 'MePatch', returns: '204 No Content',
errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters.']],
errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters; or a key TypeSafe did not accept.'],
['502 Bad Gateway', 'TypeSafe could not be asked about the key just now. It is not saved.']],
example: ipxCurl('PATCH', '/api/me', '{"blocked":["sponsor","ad read"]}'),
},
{
@@ -115,7 +117,7 @@ export const ipx = {
{
method: 'PATCH', path: '/api/feeds/{id}', slug: 'patch-feed',
summary: 'Change your settings for a feed, or (an admin) the feed’s own.',
doc: '<p>Send only the fields to change. Which episodes you want, whether to download them, how many at a time, DeadAir and pinning are yours, and change for nobody else. The feed’s address, folder, schedule and category, and marking it shareable again, are the same for everyone and an admin’s to change.</p>',
doc: '<p>Send only the fields to change. Which episodes you want, whether to download them, how many at a time, DeadAir, pinning and keeping a show from an OPML are yours, and change for nobody else. The feed’s address, folder, schedule and category, and marking it shareable again, are the same for everyone and an admin’s to change.</p>',
pathParams: [['id', 'String', 'The feed’s id.']],
body: 'FeedPatch', returns: '204 No Content',
errors: [['400 Bad Request', 'A value out of range, such as a DeadAir pause that is not 200, 300, 400 or 500.'], ['403 Forbidden', 'A field only an admin may change.'], NOT_SUBSCRIBED],
@@ -166,7 +168,7 @@ export const ipx = {
items: [
{
method: 'GET', path: '/api/feeds/{id}/entries', slug: 'get-feed-entries',
summary: 'A page of one feed’s episodes, newest first unless sorted.',
summary: 'A page of one feed’s episodes, newest first unless sorted. For an OPML list or a Patreon creator, those of the shows in it you subscribe to, together.',
pathParams: [['id', 'String', 'The feed’s id.']],
query: 'Page', returns: 'EntryPage', errors: [NOT_SUBSCRIBED],
example: ipxCurl('GET', '/api/feeds/99-invisible/entries?filter=unread&limit=10'),
@@ -205,6 +207,54 @@ export const ipx = {
},
],
},
{
id: 'signals', name: 'signals', title: 'Signals',
summary: 'Subjects picked out of every feed you subscribe to by TypeSafe.',
items: [
{
method: 'GET', path: '/api/signals', slug: 'get-signals',
summary: 'Your Signals, each with its items and unread counted.',
returns: 'Vec<Signal>',
example: ipxCurl('GET', '/api/signals'),
},
{
method: 'POST', path: '/api/signals', slug: 'post-signals',
summary: 'Make a Signal. It looks back over the last week (an admin’s setting), then at each new item.',
doc: '<p>Each item is one call to TypeSafe: on the server’s key when an admin has chosen that (<code>signals_on_server</code>), otherwise on yours, saved with <a href="/docs/api/ipx/auth/patch-me"><code>PATCH /api/me</code></a>. Without a key, nothing is picked out.</p>',
body: 'SignalBody', returns: '{ id: i64 }',
errors: [['400 Bad Request', 'No name or one over 100 characters, not 1 to 50 words, a description over 500 characters, or an icon that is not a style and a name.']],
example: ipxCurl('POST', '/api/signals', '{"name":"Formula 1","keywords":["formula 1","f1"],"description":"Grand prix racing, not road cars."}'),
},
{
method: 'PATCH', path: '/api/signals/{id}', slug: 'patch-signal',
summary: 'Change a Signal. A new name, words or description asks again about what it picked out, and about new items as they come; a new icon asks nothing.',
pathParams: [['id', 'i64', 'The Signal’s id.']],
body: 'SignalBody', returns: '204 No Content',
errors: [['400 Bad Request', 'As for a new Signal.'], ['404 Not Found', 'No Signal of yours has that id.']],
example: ipxCurl('PATCH', '/api/signals/3', '{"keywords":["f1","grand prix"]}'),
},
{
method: 'DELETE', path: '/api/signals/{id}', slug: 'delete-signal',
summary: 'Delete a Signal. Its items stay in their feeds.',
pathParams: [['id', 'i64', 'The Signal’s id.']],
returns: '204 No Content', errors: [['404 Not Found', 'No Signal of yours has that id.']],
},
{
method: 'GET', path: '/api/signals/{id}/entries', slug: 'get-signal-entries',
summary: 'A page of the items picked out for a Signal, as a feed’s list is.',
pathParams: [['id', 'i64', 'The Signal’s id.']],
query: 'Page', returns: 'EntryPage', errors: [['404 Not Found', 'No Signal of yours has that id.']],
example: ipxCurl('GET', '/api/signals/3/entries?filter=unread&limit=10'),
},
{
method: 'POST', path: '/api/signals/icon', slug: 'post-signal-icon',
summary: 'Have TypeSafe choose a Signal’s icon from the ones offered, on the server’s TypeSafe key.',
body: 'IconAsk', returns: '{ icon: String }',
errors: [['400 Bad Request', 'No icons, more than 60, or one that is not a style and a name.'],
['404 Not Found', 'The server has no TypeSafe key.'], ['502 Bad Gateway', 'TypeSafe could not choose just now.']],
},
],
},
{
id: 'files', name: 'files', title: 'Files',
summary: 'Downloading, deleting and playing an episode’s file.',
@@ -379,8 +429,28 @@ export const ipx = {
['sign_out', 'Option<String>', 'Where signing out should go, for someone single sign-on signed in.'],
['deadair_saved', 'i64', 'Seconds DeadAir has saved you, all told.'],
['version', 'String', 'ipx’s version.'],
['typesafe_key_set', 'bool', 'You have a TypeSafe key saved for Signals. The key itself is never sent back.'],
['signals', 'String', 'Whose key Signals are judged on: <code>server</code>, for everyone, or <code>own</code>, each person’s.'],
['signal_days', 'u64', 'How many days back a new Signal reads.'],
] },
{ name: 'MePatch', doc: 'What <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.'],
['typesafe_key', 'Option<Option<String>>', 'Your TypeSafe key for Signals, checked with TypeSafe and then saved; <code>null</code> or empty forgets it.']] },
{ name: 'Signal', doc: 'A Signal in your sidebar.', fields: [
['id', 'i64', ''], ['name', 'String', ''], ['keywords', 'Vec<String>', 'The words it listens for.'],
['description', 'Option<String>', 'What you mean, in your own words.'],
['icon', 'Option<String>', 'A Font Awesome icon, such as <code>solid/robot</code>; none shows its initials.'],
['items', 'i64', 'Items picked out that are still in a feed you subscribe to.'], ['unread', 'i64', 'Those of them not read.'],
] },
{ name: 'SignalBody', doc: 'A Signal to make, or what to change of one. Leave out what stays.', fields: [
['name', 'Option<String>', 'Up to 100 characters; needed for a new one.'],
['keywords', 'Option<Vec<String>>', '1 to 50 words, each up to 200 characters; needed for a new one.'],
['description', 'Option<Option<String>>', 'Up to 500 characters; <code>null</code> or empty clears it.'],
['icon', 'Option<Option<String>>', '<code>solid/</code>, <code>regular/</code> or <code>brands/</code> and a name; <code>null</code> or empty clears it.'],
] },
{ name: 'IconAsk', doc: 'The icons to choose a Signal’s from.', fields: [
['name', 'String', ''], ['keywords', 'Vec<String>', ''], ['description', 'Option<String>', ''],
['candidates', 'Vec<(String, String)>', '1 to 60 of <code>[icon, label]</code>, the label its name and search terms.'],
] },
{ name: 'MePatch', doc: 'What <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.']] },
{ name: 'Token', doc: 'An API token, as listed.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['created', 'i64', ''], ['last_used', 'Option<i64>', '']] },
{ name: 'NewToken', doc: 'A token to make.', fields: [['name', 'String', 'What it is for, to know it by when revoking it.']] },
{ name: 'NewTokenMade', doc: 'A token just made: the only time it is shown.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['token', 'String', 'Begins <code>ipx_</code>. Send it as <code>Authorization: Bearer</code>.']] },
@@ -407,6 +477,7 @@ export const ipx = {
['entries', 'i64', ''], ['downloaded', 'i64', ''], ['unread', 'i64', ''],
['subscribers', 'i64', 'People on this server, you included.'],
['pinned', 'bool', 'At the top of your list.'],
['kept', 'bool', 'From an OPML, and yours to keep as a feed of its own should the OPML drop it or you leave the OPML.'],
['private', 'bool', 'Kept out of the directory.'],
['private_by_address', 'bool', 'Private whatever it is marked: a login, or a key in its address.'],
] },
@@ -423,6 +494,7 @@ export const ipx = {
['allow_explicit', 'Option<bool>', 'Yours.'], ['auto_download', 'Option<bool>', 'Yours.'],
['max_new_per_check', 'Option<Option<usize>>', 'Yours; <code>null</code> for the server’s.'],
['pinned', 'Option<bool>', 'Yours.'],
['kept', 'Option<bool>', 'Yours: keep a show from an OPML should the OPML drop it.'],
['deadair', 'Option<Option<bool>>', 'Yours; <code>null</code> follows its OPML list’s.'],
['deadair_pause', 'Option<i64>', 'Yours: 200, 300, 400 or 500.'],
['private', 'Option<bool>', 'Anyone subscribed may mark it private; only an admin unmarks it.'],
@@ -447,6 +519,8 @@ export const ipx = {
['guid', 'String', ''], ['feed_id', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''],
['published', 'Option<i64>', ''],
['description', 'Option<String>', 'Show notes, HTML, already sanitized.'],
['markdown', 'Option<String>', 'The item’s Markdown as written, when its feed sends Markdown beside its HTML (RSS’s source:markdown).'],
['markdown_html', 'Option<String>', 'That Markdown made HTML, sanitized as description is.'],
['read', 'bool', ''], ['flagged', 'bool', 'Kept: its file is never cleared to save space.'],
['image', 'Option<String>', ''], ['duration', 'Option<i64>', 'Seconds.'],
['episode', 'Option<i64>', ''], ['season', 'Option<i64>', ''],
@@ -493,6 +567,7 @@ export const ipx = {
['download_error', '{ feed, enclosure, msg, url }', ''],
['deadair_done', '{ feed, enclosure, saved_s }', 'DeadAir finished a file.'],
['scan_done', '{ feeds }', 'A check finished; how many feeds it looked at.'],
['signal_hits', '{ user, hits }', 'Items were picked out for someone’s Signals; theirs to fetch again.'],
] },
{ name: 'Settings', doc: 'The server’s settings.', fields: [
['schedule', 'String', 'How often feeds are checked, such as <code>every 60m</code>.'], ['every_mins', 'u64', 'The same in minutes.'],
@@ -502,12 +577,17 @@ export const ipx = {
['max_age_days', 'u64', '0 for no limit.'], ['art_cache_mb', 'u64', 'Artwork kept.'],
['directory_url', 'String', ''], ['directory_token_set', 'bool', 'The token itself is never sent back.'],
['directory_share', 'bool', ''],
['signals_on_server', 'bool', 'Everyone’s Signals are judged on the server’s TypeSafe key, not each person’s.'],
['signal_days', 'u64', 'How many days back a new Signal reads; 7 unless an admin sets it.'],
['typesafe_server_key', 'bool', 'The server has a TypeSafe key (<code>TYPESAFE_KEY</code>); without one, <code>signals_on_server</code> changes nothing.'],
] },
{ name: 'SettingsPatch', doc: 'What <code>PATCH /api/settings</code> changes. Leave out what stays.', fields: [
['schedule', 'Option<String>', ''], ['max_new_per_check', 'Option<usize>', ''], ['media_types', 'Option<Vec<String>>', ''],
['max_total_gb', 'Option<f64>', ''], ['max_age_days', 'Option<u64>', ''], ['art_cache_mb', 'Option<u64>', ''],
['directory_url', 'Option<String>', 'Empty for no directory.'], ['directory_token', 'Option<String>', 'Replaces it; empty clears it.'],
['directory_share', 'Option<bool>', ''],
['signals_on_server', 'Option<bool>', 'Judge everyone’s Signals on the server’s TypeSafe key.'],
['signal_days', 'Option<u64>', '1 to 365.'],
] },
{ name: 'UserRow', doc: 'An account.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['admin', 'bool', ''], ['password', 'bool', 'Has one; without, it signs in only by single sign-on.'], ['created', 'Option<i64>', ''], ['last_login', 'Option<i64>', '']] },
{ name: 'NewUser', doc: '', fields: [['name', 'String', 'No commas, semicolons or line breaks.'], ['password', 'String', 'Empty for single sign-on only.'], ['admin', 'bool', '']] },

View File

@@ -50,6 +50,7 @@ docker build -f Dockerfile.web -t ipx-web .</code></pre>
<tbody>
<tr><td><code>IPX_DATABASE_URL</code></td><td>A <code>postgres://user:password@host:5432/database</code> address, to keep everything in Postgres instead of SQLite</td></tr>
<tr><td><code>PUID</code>, <code>PGID</code></td><td>The user and group that own the files ipx writes</td></tr>
<tr><td><code>TYPESAFE_KEY</code></td><td>The server's own <a href="https://typesafe.ai">typesafe.ai</a> API key: it picks a podcast's Directory category and a Signal's icon, and, when an admin turns it on, pays for everyone's Signals</td></tr>
<tr><td><code>IPX_LOG_FORMAT</code></td><td><code>json</code> logs one JSON object a line, for Loki and the like</td></tr>
<tr><td><code>OTEL_EXPORTER_OTLP_ENDPOINT</code></td><td>Sends traces of requests, checks and downloads to an OpenTelemetry collector; set <code>WEB_TRACING=on</code> on ipx-web too</td></tr>
<tr><td><code>IPX_UPSTREAM</code></td><td>On ipx-web: where ipx is, <code>ipx:8099</code> by default</td></tr>

View File

@@ -56,17 +56,7 @@
<!-- Each feature beside the part of the app that shows it, cut from a scratch iPX on public
podcasts, in a navy frame like the dial's. -->
<section class="does" aria-labelledby="does">
<h2 id="does">What it does</h2>
<div class="feature">
<div class="say">
<h3>An account for everyone at home</h3>
<p>Each person has their own shows, sees what they have and have not heard, and picks up where they left off.</p>
</div>
<figure class="frame"><img src="/shots/feeds.webp" width="290" height="276" loading="lazy"
alt="The feed list: Directory, Currently Listening and All Subscriptions, then each show with its cover and how many of its episodes are unread."></figure>
</div>
<section class="does" aria-label="What it does">
<div class="feature">
<div class="say">
@@ -90,8 +80,8 @@
episode ends sooner. CSS only; the page runs no script of its own. -->
<div class="feature">
<div class="say">
<h3>Cut the dead air</h3>
<p>Turn on DeadAir for a show and its long silences shrink to a short pause, so episodes finish sooner without anyone talking faster. You choose how long a pause to leave.</p>
<h3>All talk, no dead air</h3>
<p>Radio has a name for silence on air: dead air. Turn on DeadAir for a show and its long silences shrink to a pause as short as you like. Everyone still talks at their own pace; the episode just ends sooner.</p>
</div>
<figure class="frame deadair" role="img" aria-label="A waveform whose silent gaps shrink to short pauses, so the episode ends sooner.">
<div class="wave" aria-hidden="true">
@@ -105,6 +95,15 @@
</figure>
</div>
<div class="feature">
<div class="say">
<h3>Tune out the noise</h3>
<p>Your feeds bring in more than anyone has time for. A Signal listens across all of them for one subject: give it a name, the words to listen for, and a sentence saying what you mean if the words are not enough. What fits lands in a list of its own, starting with the last week, and it keeps listening. To use Signals you will need an API key from <a href="https://typesafe.ai">typesafe.ai</a>, unless your server's admin provides one.</p>
</div>
<figure class="frame"><img class="cut" src="/shots/signals.webp" width="874" height="398" loading="lazy"
alt="A Signal called Space, picked out of the noise of eight feeds: episodes and posts about galaxies, Artemis II, Mars and a rocket launch from Hackaday, Universe Today, NASA's Houston We Have a Podcast and Ars Technica."></figure>
</div>
<div class="feature">
<div class="say">
<h3>See what others listen to</h3>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

BIN
site/shots/signals.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -64,12 +64,15 @@ h1{margin:0;color:var(--navy);font-weight:900;font-size:clamp(6.5rem,24vw,15rem)
h2{margin:0 0 1.5rem;color:var(--navy);font-weight:800;font-size:clamp(1.75rem,4vw,2.4rem);line-height:1.1;letter-spacing:-.02em}
.does,.then{padding-block:4rem 0}
/* Each feature on one row: what it does on the left, beside the part of the app that shows it,
/* Each feature on one row: what it does beside the part of the app that shows it,
framed in the dial's navy at no more than its own size. */
.feature{display:grid;grid-template-columns:minmax(0,22rem) minmax(0,1fr);gap:clamp(1.5rem,5vw,4rem);align-items:center;padding-block:2.5rem}
.feature h3{margin:0 0 .5rem;color:var(--navy);font-weight:750;font-size:1.35rem;line-height:1.25}
.feature p{margin:0;max-width:34rem}
.frame{margin:0;justify-self:start;max-width:100%;padding:clamp(.6rem,1.6vw,1.1rem);background:var(--navy);border-radius:1.1rem}
/* Every other row the picture comes first, so the page zigzags rather than running down one side. */
.feature:nth-of-type(even){grid-template-columns:minmax(0,1fr) minmax(0,22rem)}
.feature:nth-of-type(even) .say{order:2}
.frame{margin:0;justify-self:center;max-width:100%;padding:clamp(.6rem,1.6vw,1.1rem);background:var(--navy);border-radius:1.1rem}
.frame img{display:block;max-width:100%;height:auto;border-radius:.55rem}
/* DeadAir: the track is the episode's full length; talk keeps its width, each silence lights
in the needle's orange and shrinks to a pause, and the end comes in from the right. */
@@ -175,7 +178,8 @@ footer p{margin:0}
main,.bar,footer{padding-inline:1rem}
.bar nav{gap:1rem}
.hero{padding-block:1.5rem 2rem}
.feature{grid-template-columns:minmax(0,1fr);gap:1.25rem;padding-block:1.75rem}
.feature,.feature:nth-of-type(even){grid-template-columns:minmax(0,1fr);gap:1.25rem;padding-block:1.75rem}
.feature:nth-of-type(even) .say{order:0}
/* A wide crop at this width is too small to read: its left part, larger. */
.frame img.cut{width:100%;aspect-ratio:4/3;object-fit:cover;object-position:0 0}
.frame img.cut.player{aspect-ratio:2.4/1}

View File

@@ -54,7 +54,7 @@ const drive = PAGE === 'admin.html' ? [
['keysModal', () => ctx.keysModal()],
// `const S` is not reachable from here: top-level const/let do not become properties
// of a vm context the way var and function declarations do.
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
['groupHead', () => ctx.groupHead(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
];
for (const [name, fn] of drive) {
try {

View File

@@ -144,6 +144,18 @@ test('a Patreon feed is told by its address', () => {
assert.equal(run('isPatreon(null)'), false);
});
test("All Subscriptions' unread count leaves out feeds hidden from it, and an OPML's shows with it", () => {
run(`S.feeds = [
{id: 'o', group: null, unread: 0, hide_from_all: true},
{id: 'a', group: 'o', unread: 2, hide_from_all: false},
{id: 'c', group: null, unread: 3, hide_from_all: true},
{id: 'd', group: null, unread: 5, hide_from_all: false}]`);
assert.equal(run('unreadInAll()'), 5);
run(`S.feeds[0].hide_from_all = false`);
assert.equal(run('unreadInAll()'), 7);
run(`S.feeds = []`);
});
test('a Signal is a place of its own, found by its id', () => {
run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`);
assert.equal(run(`signalId(':signal:7')`), 7);
@@ -199,3 +211,12 @@ test('Jev is offered the icons found for each word of a Signal', () => {
assert.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']);
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
});
test('Signals are everyone\'s on the server\'s key, and yours on your own once a key is saved', () => {
const on = me => { run(`S.me = ${JSON.stringify(me)}`); return run('signalsOn()'); };
assert.equal(on({ signals: 'server', typesafe_key_set: false }), true);
assert.equal(on({ signals: 'own', typesafe_key_set: true }), true);
assert.equal(on({ signals: 'own', typesafe_key_set: false }), false);
assert.equal(on(null), false);
run('S.me = null');
});

View File

@@ -1,6 +1,38 @@
const { test, expect } = require('@playwright/test');
const { TOKEN } = require('./global-setup');
// Signals' tests run only when the Signals code has changed since each last passed: they spend the
// TypeSafe key (#271). The Signals code is signals.rs and signals.ts whole, and the lines naming
// Signals, TypeSafe or Jev anywhere else in the daemon and the page; with the test's own source,
// hashed, and kept under target/ once the test passes. IPX_SIGNALS_TESTS=1 runs them anyway.
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '../..');
const SIGNALS_CODE = (() => {
const h = require('node:crypto').createHash('sha256');
for (const dir of ['crates/ipx/src', 'web/src']) {
for (const f of fs.readdirSync(path.join(ROOT, dir)).sort()) {
const text = fs.readFileSync(path.join(ROOT, dir, f), 'utf8');
h.update(f + '\n' + (/^signals\./.test(f) ? text : text.split('\n').filter(l => /signal|typesafe|jev/i.test(l)).join('\n')));
}
}
return h.digest('hex');
})();
function signalsTest(name, fn) {
const stamp = path.join(ROOT, 'target/signals-tests',
require('node:crypto').createHash('sha256').update(name).digest('hex').slice(0, 16));
const want = require('node:crypto').createHash('sha256').update(SIGNALS_CODE + fn.toString()).digest('hex');
// Playwright reads the fixtures a test asks for from its parameters; these ask for the page alone.
test(name, async ({ page }) => {
let had = '';
try { had = fs.readFileSync(stamp, 'utf8'); } catch {}
test.skip(had === want && !process.env.IPX_SIGNALS_TESTS, 'the Signals code is as it was when this last passed');
await fn({ page });
fs.mkdirSync(path.dirname(stamp), { recursive: true });
fs.writeFileSync(stamp, want);
});
}
// The token sets a cookie, so every test starts by presenting it once.
test.beforeEach(async ({ page }) => {
await page.goto(`/?token=${TOKEN}`);
@@ -415,9 +447,12 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
// Scoped to the sidebar: the name also appears as the page heading once selected.
await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible();
// The subscription's own page lists what is inside it.
// The subscription's own page lists the items of the shows inside it, together (#283).
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
await expect(page.locator('.childrow')).toHaveCount(2);
await page.locator('#content .tabs button[data-f="all"]').click();
await expect(page.locator('.ep', { hasText: 'Aardvark Ep' })).toBeVisible();
await expect(page.locator('.ep', { hasText: 'Grouped Ep' })).toBeVisible();
await expect(page.locator('.childrow')).toHaveCount(0);
});
test('inside an OPML, feeds with unread items are listed first', async ({ page }) => {
@@ -450,8 +485,68 @@ test('inside an OPML, feeds with unread items are listed first', async ({ page }
await page.reload();
const want = ['Grouped Show', 'Aardvark Radio'];
await expect(page.locator('#feedlist .feed.child b')).toHaveText(want, { timeout: 20_000 });
// The OPML's Unread tab has the unread show's item and not the read one's (#283).
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
await expect(page.locator('.childrow b')).toHaveText(want);
await page.locator('#content .tabs button[data-f="unread"]').click();
await expect(page.locator('.ep', { hasText: 'Grouped Ep' })).toBeVisible();
await expect(page.locator('.ep', { hasText: 'Aardvark Ep' })).toHaveCount(0);
});
test('a show inside an OPML can be kept, and only it', async ({ page }) => {
await page.evaluate(() =>
fetch('/api/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
}));
const chev = page.locator('.feed.group .chev');
await expect(chev).toBeVisible({ timeout: 20_000 });
if (await chev.getAttribute('aria-expanded') === 'false') await chev.click();
await page.locator('#feedlist').getByText('Aardvark Radio').click();
// A plus on the show's page; pressed, a check (#280).
const keep = page.locator('#content .acts [data-a="keep"]');
await expect(keep).toHaveAttribute('aria-pressed', 'false');
await expect(keep).toHaveAttribute('title', /Test Subscriptions drops it/);
await keep.click();
await expect(keep).toHaveAttribute('aria-pressed', 'true');
const kept = await page.evaluate(() => fetch('/api/feeds').then((r) => r.json()));
expect((kept.feeds || kept).find((f) => f.title === 'Aardvark Radio')?.kept).toBe(true);
await keep.click();
await expect(keep).toHaveAttribute('aria-pressed', 'false');
// The OPML itself has no Keep.
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
await expect(page.locator('#content h2')).toHaveText('Test Subscriptions');
await expect(page.locator('#content [data-a="keep"]')).toHaveCount(0);
});
test('an item sent as HTML and Markdown switches between them', async ({ page }) => {
const api = (p, init) => page.evaluate(([p, init]) => fetch(p, { headers: { 'Content-Type': 'application/json' }, ...init })
.then((r) => r.json().catch(() => null)), [p, init]);
const added = await api('/api/feeds', { method: 'POST', body: JSON.stringify({ url: 'http://127.0.0.1:8792/markdown.xml' }) });
const id = added.id || added.feed?.id || 'markdown-blog';
await page.reload();
await page.locator('#feedlist .feed', { hasText: 'Markdown Blog' }).click({ timeout: 20_000 });
await page.locator('#content .tabs button[data-f="all"]').click();
await page.locator('.ep', { hasText: 'Both Ways' }).click();
// The publisher's HTML first; the choice is between the title and the item's buttons (#285).
const view = page.locator('#detail .dmeta .tview');
await expect(view).toHaveValue('html');
await expect(page.locator('#detail .dbody b')).toHaveText('HTML');
await view.selectOption('markdown');
await expect(page.locator('#detail .dbody strong')).toHaveText('Markdown');
await expect(view.locator('option')).toHaveText(['HTML', 'Markdown']);
// Kept for the next item; set back so nothing after this sees it.
expect(await page.evaluate(() => localStorage.getItem('ipx.textview'))).toBe('markdown');
await view.selectOption('html');
// An item with no Markdown has no choice to make.
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
await page.locator('.ep').first().click();
await expect(page.locator('#detail .tview')).toHaveCount(0);
await api('/api/feeds/' + encodeURIComponent(id), { method: 'DELETE' });
});
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {
@@ -1143,7 +1238,8 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
() => page.locator('#content .acts [data-a="settings"]').click(),
() => page.locator('#content .acts [data-a="dl"]').click(),
() => page.locator('#content .acts [data-a="rm"]').click(),
async () => { await page.locator('#feedlist .sigs').click(); await page.locator('#content .acts [data-a="new"]').click(); },
// The Signals folder is not there without a key (#271); the dialog is the same either way.
() => page.evaluate(() => signalModal()),
];
for (const open of dialogs) {
await open();
@@ -1855,6 +1951,22 @@ test('a page brought back into view catches up with what was read elsewhere', as
await expect(badge).toHaveText(String(before - 1));
});
test('a search box has an x that clears it once something is typed', async ({ page }) => {
// #291: inside the box, at its right end, and the list comes back whole.
const box = page.locator('#feedFilter');
const x = page.locator('.searchwrap .clearx');
const all = await page.locator('#feedlist .feed').count();
await expect(x).toBeHidden();
await box.fill('zzz-nothing');
await expect(x).toBeVisible();
await expect(page.locator('#feedlist .feed')).toHaveCount(0);
await x.click();
await expect(box).toHaveValue('');
await expect(box).toBeFocused();
await expect(x).toBeHidden();
await expect(page.locator('#feedlist .feed')).toHaveCount(all);
});
test('a feed\'s settings say what downloads, take words as chips, and keep what is saved', async ({ page }) => {
// The redesigned dialog (#167): the download choice, word chips, the fold, and a save that holds.
const open = async () => {
@@ -2073,7 +2185,7 @@ test('the page comes compressed, and the compressed copies cannot be asked for b
}
});
test('a Signal holds the items Jev says fit its keywords, read where they are read', async ({ page }) => {
signalsTest('a Signal holds the items Jev says fit its keywords, read where they are read', async ({ page }) => {
// #254. Jev is the fixture server's stand-in; the show is added here and removed at the end.
expect((await page.request.patch('/api/me', { data: { typesafe_key: 'test-key' } })).status()).toBe(204);
const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json();
@@ -2116,7 +2228,7 @@ test('a Signal holds the items Jev says fit its keywords, read where they are re
await page.request.patch('/api/me', { data: { typesafe_key: null } });
});
test('a Signal with no key to judge it links to where one is got and where it goes', async ({ page }) => {
signalsTest('a Signal with no key to judge it links to where one is got and where it goes', async ({ page }) => {
await page.request.patch('/api/me', { data: { typesafe_key: null } });
const { id } = await (await page.request.post('/api/signals', { data: { name: 'Keyless', keywords: ['x'] } })).json();
await page.reload();
@@ -2127,3 +2239,55 @@ test('a Signal with no key to judge it links to where one is got and where it go
await page.keyboard.press('Escape');
await page.request.delete(`/api/signals/${id}`);
});
signalsTest('Signals are turned on in Settings with a key TypeSafe takes, and off again by forgetting it', async ({ page }) => {
// #271. The test daemon has no key of its own, so each person brings theirs.
await page.request.patch('/api/me', { data: { typesafe_key: null } });
await page.reload();
await expect(page.locator('#feedlist .sigs')).toHaveCount(0);
await page.evaluate(() => prefsModal());
await expect(page.locator('#ssigkey')).toBeHidden();
await page.locator('#ssigon').check();
await page.locator('#stskey').fill('bad-key');
await page.locator('#stskey').press('Enter');
await page.locator('#stskey').blur();
await expect(page.locator('.toast', { hasText: 'TypeSafe did not accept that key' })).toBeVisible();
expect((await (await page.request.get('/api/me')).json()).typesafe_key_set).toBe(false);
await page.locator('#stskey').fill('test-key');
await page.locator('#stskey').blur();
await expect(page.locator('#feedlist .sigs')).toHaveCount(1);
page.once('dialog', d => d.accept());
await page.locator('#ssigon').uncheck();
await expect(page.locator('#feedlist .sigs')).toHaveCount(0);
expect((await (await page.request.get('/api/me')).json()).typesafe_key_set).toBe(false);
await page.keyboard.press('Escape');
});
signalsTest('the server pays for Signals only when it has a key to pay with', async ({ page }) => {
// #271. With the shell's TYPESAFE_KEY (/src/.envrc's development key) the server can pay;
// Jev itself is still the fixture server's stand-in.
const { typesafe_server_key: has } = await (await page.request.get('/api/settings')).json();
await page.request.patch('/api/me', { data: { typesafe_key: null } });
expect((await page.request.patch('/api/settings', { data: { signals_on_server: true } })).status()).toBe(204);
try {
expect((await (await page.request.get('/api/me')).json()).signals).toBe(has ? 'server' : 'own');
await page.goto('/admin#server');
if (!has) { await expect(page.locator('#gsig')).toBeDisabled(); return; }
await expect(page.locator('#gsig')).toBeChecked();
// No key of your own, and Signals are there, judged on the server's.
await page.goto('/');
await expect(page.locator('#feedlist .sigs')).toHaveCount(1);
await page.evaluate(() => prefsModal());
await expect(page.locator('#stskey')).toHaveCount(0);
await page.keyboard.press('Escape');
const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json();
const { id } = await (await page.request.post('/api/signals', { data: { name: 'Bread', keywords: ['baking'] } })).json();
const shown = async () => (await (await page.request.get(`/api/signals/${id}/entries`)).json()).entries.map(e => e.title);
await expect.poll(shown, { timeout: 15_000 }).toEqual(['Baking bread']);
await page.request.delete(`/api/signals/${id}`);
await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`);
} finally {
await page.request.patch('/api/settings', { data: { signals_on_server: false } });
}
});

View File

@@ -0,0 +1,8 @@
<?xml version="1.0"?>
<rss version="2.0" xmlns:source="https://source.scripting.com/">
<channel><title>Markdown Blog</title><link>http://127.0.0.1:8792/</link><description>HTML and Markdown both, as Scripting News sends.</description>
<item><title>Both Ways</title><guid>md-1</guid>
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
<description>&lt;p&gt;The &lt;b&gt;HTML&lt;/b&gt; copy.&lt;/p&gt;</description>
<source:markdown>The **Markdown** copy.</source:markdown></item>
</channel></rss>

View File

@@ -13,6 +13,11 @@ http.createServer((req, res) => {
let body = '';
req.on('data', c => { body += c; });
req.on('end', () => {
// As TypeSafe answers a key it does not know (#271).
if (req.headers.authorization === 'Bearer bad-key') {
res.writeHead(401, { 'content-type': 'application/json' }).end('{"detail":"invalid api key"}');
return;
}
const { state, questions } = JSON.parse(body);
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}');
@@ -21,7 +26,8 @@ http.createServer((req, res) => {
const title = String(state).toLowerCase();
const answers = {};
for (const [id, q] of Object.entries(questions)) {
const words = /described by these words: ([^.]*)\./.exec(q.instructions)[1].split(', ');
// A key's check asks about no Signal's words.
const words = /described by these words: ([^.]*)\./.exec(q.instructions)?.[1].split(', ') ?? [];
answers[id] = { noul: words.some(w => title.includes(w.toLowerCase())) ? 0.9 : 0.1 };
}
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ answers }));

View File

@@ -11,6 +11,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.2.0] - 2026-10-19
### Added
- A search box has an x at its right end once something is typed in it, which clears it: items, the feed list, the log in Admin and a Signal's icons (#291).
- A feed's, an OPML subscription's or a Patreon creator's settings have In All Subscriptions: off keeps its items out of All Subscriptions and its unread count, still there on its own page, in Signals and in Currently Listening (#288).
- A feed's settings have a Signals switch beside All Subscriptions, to keep its items out of every Signal (#289).
- An item sent as both HTML and Markdown, as Scripting News's are, has a choice beside its buttons to show it as the publisher's HTML or as the Markdown, kept from one item to the next (#285).
- A show inside an OPML subscription has a Keep button, a plus, on its page, so it stays on your list as a show of its own if the OPML drops it or you unsubscribe from the OPML (#280).
- Admin, Server: a setting to give everyone Signals on the server's TypeSafe key (#271).
- Admin, Server: how many days back a new Signal reads (#276).
### Changed
- A feed's settings are in parts, each named beside its controls: Address, Downloads, Playback, Where it shows and Directory, with All Subscriptions and Signals drawn as the sidebar draws them, and DeadAir's pause as a slider without a heading (#290).
- An OPML subscription's page lists the items of all its shows together, newest first, with the same tabs, search and sort as a show's, in place of a list of the shows; the shows are still in the folder in the feed list (#283).
- Unless the server pays for them, Signals are turned on in Settings with your own TypeSafe key, and the Signals folder shows only once they are on (#271).
- A Signal's dialog says how far back it reads, and that an edit reads again only what was picked out (#276).
- Each request's span in Tempo is named for its method and path, such as `GET /api/entries`, instead of `ipx-web` for every one (#286).
### Fixed
- Another person's download, its progress and its failure, no longer shows on your page (#294).
## [1.1.0] - 2026-10-16
### Added
@@ -62,7 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212).
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.1.0...main
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.2.0...main
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/web-v1.1.0...web-v1.2.0
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.2...web-v1.1.0
[1.0.2]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...web-v1.0.2
[1.0.1]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...web-v1.0.1

View File

@@ -30,7 +30,9 @@
(tracing-on) {
tracing {
span ipx-web
# Named for the request, as ipx names its own (#286): one name for all left every span in
# Tempo looking the same.
span "{http.request.method} {http.request.uri.path}"
}
}
(tracing-off) {

View File

@@ -537,7 +537,7 @@ a{color:var(--accent)}
position:sticky;top:0;
}
#topbar .grow{flex:1}
#topbar #epSearch{width:260px;flex:none}
#topbar #epBox{width:260px;flex:none}
.tgroup{display:flex;flex:none;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel2)}
.tgroup button,.tgroup a{padding:5px 11px;min-width:34px;color:var(--dim);font-size:14px;line-height:1.3}
.tgroup a{display:inline-grid;place-items:center}
@@ -567,6 +567,17 @@ input[type=search],input[type=text],input[type=password],input[type=number],sele
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
}
input:focus,select:focus{outline:0;border-color:var(--accent)}
/* A search box and the x that clears it, inside its right end, there only once something is typed
(#291). The browser's own x, in some browsers and not others, gives way to it. */
.clearable{position:relative;display:flex;align-items:center;min-width:0}
/* With [type=search], or the phone's padding for every text box, later on, takes the room back. */
.clearable>input[type=search]{flex:1;min-width:0;padding-right:32px}
.clearable>input::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none}
.clearx{position:absolute;right:4px;top:50%;transform:translateY(-50%);display:grid;place-items:center;width:26px;height:26px;padding:0;border-radius:50%;color:var(--faint)}
.clearx:hover{color:var(--fg)}
.clearx svg{width:11px;height:11px}
.clearable:has(>input:placeholder-shown) .clearx{display:none}
.inline .clearable{flex:1}
/* The wider left gutter is the folder triangle's; everything in the list shifts with it, so the
feeds still line up with the places above. */
#feedlist{overflow-y:auto;padding:0 8px 12px 16px;flex:1;min-height:0}
@@ -848,6 +859,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
/* The reader's buttons go as one: as items of the line itself, a long feed name took them onto
the next line one at a time (#146). */
.dacts{display:flex;gap:inherit;flex:none}
.dmeta .tview{width:auto;padding:3px 6px;font-size:12px}
#detail .encbox{margin:0 0 10px}
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
.dbody img{max-width:100%;height:auto;border-radius:6px}
@@ -1094,14 +1106,30 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
/* ---------- a feed's settings (#167) ---------- */
/* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been
three screens down. */
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(520px,100%)}
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(580px,100%);container-type:inline-size}
.fsetbody{overflow:auto;min-height:0;padding:22px 24px 24px}
.card.fset .fsetfoot{flex:none;margin:0;padding:12px 24px;border-top:1px solid var(--line)}
.fsethead{display:flex;gap:14px;align-items:center;margin-bottom:18px}
.fsethead{display:flex;gap:14px;align-items:center;margin-bottom:20px}
.fsethead .art{width:56px;height:56px;border-radius:12px;font-size:18px}
.fsethead h3{margin:0;font-size:19px;line-height:1.25;letter-spacing:-.01em;overflow-wrap:anywhere}
.fsethead p{margin:3px 0 0;font-size:12.5px;line-height:1.45;color:var(--dim)}
.faddr{margin-bottom:18px}
/* A part of the settings, named in a column of its own beside its controls (#290); on a phone the
name sits above them. Ruled apart rather than boxed: the boxes, one round downloads and none
round the rest, read as a difference between the settings that was not there. */
.fsec{display:grid;grid-template-columns:104px minmax(0,1fr);gap:6px 20px;padding:14px 0 16px;border-top:1px solid var(--line)}
.fsec h4{margin:0;padding-top:10px;font-size:13px;font-weight:600;line-height:1.3;color:var(--dim)}
.fsecin{display:grid;gap:6px;min-width:0}
@container (max-width:480px){
.fsec{grid-template-columns:minmax(0,1fr);gap:4px}
.fsec h4{padding-top:0}
}
/* All Subscriptions and Signals with the icons the sidebar gives them, so the switch reads as that place. */
.switch.fplace>span:first-child{display:flex;align-items:center;gap:10px}
.fico{display:inline-flex;justify-content:center;width:18px;flex:none;color:var(--accent)}
.fico svg{width:15px;height:15px}
.fsig img{display:block;width:18px;height:18px;border-radius:5px}
/* Set on the OPML it came from, not here: shown, and plainly out of reach. */
.fplace:has(input:disabled){opacity:.5;cursor:default}
.moved{margin:2px 0 0;font-size:13px;color:var(--accent)}
/* On or off, drawn as iOS draws it, as the app is on a phone (#53). The box is the real control,
unseen over the track, so a click, a tap, the keyboard and a screen reader all reach it. */
@@ -1116,16 +1144,14 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.sw input:checked+i{background:var(--accent);border-color:var(--accent)}
.sw input:checked+i::after{transform:translateX(16px)}
.sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
/* The switch and what only matters while it downloads on its own, in one box; the rest folds away
when it is off. In --line, not --edge, so the box shows on a phone, which draws no outlines. The
rows in a grid of one row, 1fr open and 0fr shut, so the fold has a height to move. */
.fdl{border:1px solid var(--line);border-radius:12px;padding:4px 14px}
/* What only matters while it downloads on its own folds away when it is off: the rows in a grid of
one row, 1fr open and 0fr shut, so the fold has a height to move. */
/* The same box round a part of a dialog that stands apart: API tokens in Settings. */
.field.boxed{border:1px solid var(--line);border-radius:12px;padding:12px 14px}
.fdeps{display:grid;grid-template-rows:1fr;transition:grid-template-rows .22s ease}
.fdeps.off{grid-template-rows:0fr}
.fdeps>div{min-height:0;overflow:hidden}
.fdepsin{padding:6px 2px 10px}
.fdepsin{padding:6px 0 2px}
/* The app's own segmented control, with radio buttons in it, for the arrow keys. */
.seg{margin-bottom:12px}
.seg label{flex:1;position:relative;text-align:center;white-space:nowrap;padding:5px 10px;border-radius:7px;font-size:13px;color:var(--dim);cursor:pointer}
@@ -1145,9 +1171,12 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.wcap{margin:0 0 16px;font-size:13px;line-height:1.55;color:var(--dim)}
.wcap input[type=number]{display:inline-block;width:4.2em;margin:0 2px;padding:1px 4px;font-size:13px;text-align:center;font-variant-numeric:tabular-nums;color:var(--fg)}
.ffield{display:grid;gap:6px;margin-bottom:14px}
.fsecin>.ffield:last-child{margin-bottom:0}
.fsecin>.cap+.ffield{margin-top:10px}
/* DeadAir's pause: four stops on one scale, a dot at each so it reads as four places to stop. */
.dpause{display:grid;gap:6px;margin-top:6px}
.dphead{display:flex;justify-content:space-between;align-items:baseline}
/* The pause it leaves, over the slider's right end; the slider is named for a screen reader alone. */
.dphead{display:flex;justify-content:flex-end;align-items:baseline}
.dphead output{font-variant-numeric:tabular-nums;font-weight:600}
.dpslide{--t:22px;position:relative;height:var(--t)}
.dpslide::before,.dpslide::after{content:'';position:absolute;top:50%;pointer-events:none}
@@ -1164,9 +1193,6 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.dpends{display:flex;justify-content:space-between;font-size:12.5px;color:var(--dim)}
.dpause:has(input:disabled){opacity:.45}
.dpslide input[type=range]:disabled{cursor:default}
.fdl+.ffield{margin-top:18px}
/* DeadAir shares the box but not the fold: it applies whether or not this show downloads on its own. */
.fdead{border-top:1px solid var(--line);padding-top:10px;margin-top:4px}
.flabel{font-size:14px;font-weight:500}
.cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--dim)}
/* Words and phrases as chips: in one comma-separated box, one ran into the next. */
@@ -1236,7 +1262,7 @@ body:has(#player.on) #status{padding-bottom:3px}
padding-left:calc(8px + var(--safe-l));padding-right:calc(8px + var(--safe-r));
}
#topbar .grow,.tgroup.item{display:none}
#topbar #epSearch{width:auto;flex:1;min-width:0}
#topbar #epBox{width:auto;flex:1;min-width:0}
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
.fhead.slim .acts{flex:1 1 100%}
.iconbtn{width:38px;height:38px}
@@ -1344,7 +1370,7 @@ body:has(#player.on) #status{padding-bottom:3px}
input[type=search],input[type=text],input[type=password],input[type=number],select{
font-size:17px;background:var(--panel2);border-color:var(--edge);border-radius:10px;padding:9px 12px;
}
#topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 14px}
#topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 34px 9px 14px}
input:focus,select:focus{border-color:var(--accent)}
/* The feed's name as the page's large title, the way Podcasts heads a show. */
@@ -1397,6 +1423,7 @@ body:has(#player.on) #status{padding-bottom:3px}
.fsetbody{padding:20px 16px 24px}
.card.fset .fsetfoot{padding:12px 16px calc(12px + var(--safe-b))}
.fsethead .art{width:64px;height:64px;border-radius:14px}
.fsec h4{font-size:14px}
.fsethead p,.wcap,.cap{font-size:14px}
.switch,.flabel{font-size:17px}
.switch{min-height:44px}
@@ -1412,7 +1439,7 @@ body:has(#player.on) #status{padding-bottom:3px}
.chips{background:var(--panel2);border-color:var(--edge);border-radius:10px}
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
.chipx{width:26px;height:26px}
.fdl,.field.boxed{border-radius:14px}
.field.boxed{border-radius:14px}
.toast{border-radius:14px}
}
@keyframes sheet{from{transform:translateY(100%)}}

View File

@@ -28,7 +28,7 @@
<button id="tbFlag" title="Pin the selected item, so it is never deleted" aria-label="Pin" data-icon="pin" disabled></button>
</div>
<span class="grow"></span>
<input type="search" id="epSearch" placeholder="Search items…">
<span class="clearable" id="epBox"><input type="search" id="epSearch" placeholder="Search items…"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span>
<div class="tgroup" id="admintools">
<button id="prefs" title="Settings" aria-label="Settings" data-icon="settings"></button>
<a id="admin" href="/admin" title="Admin: the server's settings, accounts and the log" aria-label="Admin" data-icon="admin"></a>
@@ -36,7 +36,7 @@
</header>
<div id="shell">
<aside id="sidebar">
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
<div class="searchwrap"><span class="clearable"><input type="search" id="feedFilter" placeholder="Filter feeds…"><button type="button" class="clearx" title="Clear" aria-label="Clear" data-icon="close"></button></span></div>
<div id="feedlist"></div>
<div class="sidefoot">
<div class="who"><span id="who"></span><button id="signout" title="Sign out" aria-label="Sign out" data-icon="signout"></button></div>

View File

@@ -53,6 +53,16 @@ async function drawServer(){
<input type="number" id="gart" min="0" value="${g.art_cache_mb}">
<span class="hint">Show and episode artwork is kept here once shown, so it loads from iPX
instead of each publisher. Over this, what has gone longest unshown is dropped first.</span></div>
<div class="field"><label class="check"><input type="checkbox" id="gsig"${g.signals_on_server ? ' checked' : ''}${g.typesafe_server_key ? '' : ' disabled'}>
Signals for everyone, on this server's TypeSafe key</label>
<span class="hint">${g.typesafe_server_key
? 'On, everyone has Signals and every item read for one is a call on this server\'s key. Off, each person turns Signals on in their own Settings with a key of their own.'
: 'This server has no TypeSafe key (<code>TYPESAFE_KEY</code>), so each person uses their own.'}</span></div>
<div class="field"><label for="gsigdays">Signals look back (days)</label>
<input type="number" id="gsigdays" min="1" max="365" value="${g.signal_days}">
<span class="hint">How far back a new Signal reads. Each item read is a call to TypeSafe, so a
longer look back costs more, once for each new Signal. Editing a Signal reads again only what
it had picked out.</span></div>
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
@@ -64,7 +74,10 @@ async function drawServer(){
media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0)})});
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0),
signal_days: Math.min(365, Math.max(1, Number($<HTMLInputElement>('#gsigdays').value) || 7)),
// Left alone while the server has no key to offer, rather than switched off.
...($<HTMLInputElement>('#gsig').disabled ? {} : {signals_on_server: $<HTMLInputElement>('#gsig').checked})})});
toast('Settings saved');
}catch(e){ toast(why(e), true); }
};
@@ -249,7 +262,7 @@ function drawLogView(){
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
<input type="search" id="logq" class="grow" placeholder="Filter…">
${searchBox('<input type="search" id="logq" placeholder="Filter…">','grow')}
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
</div>

View File

@@ -440,13 +440,18 @@ async function prefsModal(){
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
one in its title or text is hidden from you and not downloaded for you. Each feed's
settings can add more.</span></div>
<div class="field"><label for="stskey">TypeSafe key</label>
${S.me?.signals==='server'?`<div class="field"><label>Signals</label>
<span class="hint">Signals pick what matters out of everything you subscribe to. This server
pays for them, so there is nothing to set up.</span></div>`:`<div class="field">
<label class="check"><input type="checkbox" id="ssigon"${S.me?.typesafe_key_set?' checked':''}> Signals</label>
<span class="hint">Signals pick what matters out of everything you subscribe to. To use Signals
you will need an API key from <a href="https://typesafe.ai" target="_blank" rel="noopener">typesafe.ai</a>.</span>
<div id="ssigkey"${S.me?.typesafe_key_set?'':' hidden'}>
<label for="stskey">TypeSafe key</label>
<div class="inline"><input type="password" id="stskey" autocomplete="off" spellcheck="false"
placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}">
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
<span class="hint">Signals pick what matters out of everything you subscribe to, on your own
key from <a href="https://typesafe.ai" target="_blank" rel="noopener">typesafe.ai</a>.
The key is kept on this server and never shown again.</span></div>
<span class="hint">Checked with TypeSafe before it is saved, kept on this server and never shown again.</span></div></div>`}
<div class="field boxed"><label for="tokname">API tokens</label>
<div id="tokens"></div>
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
@@ -476,6 +481,9 @@ async function prefsModal(){
$<HTMLInputElement>('#tokval').select();
}catch(err){ toast(why(err),true); }
};
// Your own key, unless the server's is used for everyone (#271). Signals are on once TypeSafe
// has taken a key, and off again when it is forgotten.
if(S.me?.signals!=='server'){
const saveKey=async(typesafe_key: string|null)=>{
try{
await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})});
@@ -483,11 +491,21 @@ async function prefsModal(){
$<HTMLInputElement>('#stskey').value='';
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
toast(typesafe_key?'Saved':'Forgotten');
$<HTMLInputElement>('#ssigon').checked=!!typesafe_key; $('#ssigkey').hidden=!typesafe_key;
toast(typesafe_key?'Signals are on':'Signals are off');
renderFeeds();
}catch(err){ toast(why(err),true); }
};
$<HTMLInputElement>('#ssigon').onchange=(e: Event)=>{
const on=(e.target as HTMLInputElement).checked;
if(on){ $('#ssigkey').hidden=false; $<HTMLInputElement>('#stskey').focus(); return; }
if(!S.me?.typesafe_key_set){ $('#ssigkey').hidden=true; return; }
if(confirm('Turn Signals off and forget your TypeSafe key? Your signals are kept for when you turn them on again.')) saveKey(null);
else (e.target as HTMLInputElement).checked=true;
};
$<HTMLInputElement>('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); };
$<HTMLButtonElement>('#stsforget').onclick=()=>saveKey(null);
$<HTMLButtonElement>('#stsforget').onclick=()=>$<HTMLInputElement>('#ssigon').click();
}
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
const blocked=splitWords((e.target as HTMLInputElement).value);
try{
@@ -571,13 +589,23 @@ function settingsModal(f: Feed, newUrl?: string){
? `It comes from ${esc(parent.title||parent.id)}, and follows your settings there until you save these.`
: 'Your settings for this feed. Everyone else who reads it keeps their own.';
const sw=(input: string, text: string, cls='')=>`<label class="switch${cls}"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
// Each part named in a column of its own, the controls beside it (#290): the dialog had grown one
// captioned setting at a time into a single column three screens long on a phone.
const sect=(title: string, body: string)=>`<section class="fsec"><h4>${title}</h4><div class="fsecin">${body}</div></section>`;
const these=isGroup?'these shows':'this show';
// The places in the sidebar this show's items go to, drawn as the sidebar draws them (#288, #289).
const place=(id: string, key: 'hide_from_all'|'hide_from_signals', icon: string, title: string)=>{
const byList=f.group?S.feeds.find(g=>g.id===f.group&&g[key]):undefined;
return sw(`<input type="checkbox" role="switch" id="${id}"${shownIn(f,key)?' checked':''}${byList?' disabled':''}>`,
`<span class="fico" aria-hidden="true">${icon}</span>${title}`,' fplace');
};
const listHides=f.group?S.feeds.find(g=>g.id===f.group&&(g.hide_from_all||g.hide_from_signals)):undefined;
openModal(`<div class="fsetbody">
<header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header>
<div class="field faddr"><label for="surl">Address</label>
<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false"${admin?'':' readonly'}>
${sect('Address',`<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" aria-label="Address"${admin?'':' readonly'}>
<button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}</div>
<div class="fdl">${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}`)}
${sect('Downloads',`${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
<div class="fdeps${f.auto_download?'':' off'}" id="sdeps"><div><div class="fdepsin">
<div class="tabs seg" role="radiogroup" aria-label="How many to download">${
[['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=>
@@ -588,27 +616,29 @@ function settingsModal(f: Feed, newUrl?: string){
<div class="chips" id="skw">${chipsHTML(f.keywords,'skwin')}</div>
<p class="cap">Any one of these will do, and a phrase needs all its words. Empty takes everything.</p></div>
${sw(`<input type="checkbox" role="switch" id="sexp"${f.allow_explicit?' checked':''}>`,'Include explicit items')}
</div></div></div>
<div class="ffield fdead">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
<p class="cap">Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so
episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses
are part of the telling.</p>
</div></div></div>`)}
${sect('Playback',`${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
<p class="cap">Shrinks long silences in ${these} to a beat, so episodes finish sooner. Leave it off
for stories whose pauses are part of the telling.</p>
<div class="dpause">
<div class="dphead"><label class="flabel" for="sdpause">Breathing room</label><output id="sdpauseout" for="sdpause"></output></div>
<div class="dpslide"><input type="range" id="sdpause" min="200" max="500" step="100" value="${f.deadair_pause||300}"${f.deadair?'':' disabled'}></div>
<div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div>
<p class="cap">The silence kept where a long one is cut. More is easier on the ear; less saves more time.</p></div></div></div>
<div class="dphead"><output id="sdpauseout" for="sdpause"></output></div>
<div class="dpslide"><input type="range" id="sdpause" aria-label="Breathing room" min="200" max="500" step="100" value="${f.deadair_pause||300}"${f.deadair?'':' disabled'}></div>
<div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div></div>`)}
${sect('Where it shows',`<div class="fplaces">${place('sinall','hide_from_all',ICON.all,'All Subscriptions')}${
signalsOn()?place('sinsig','hide_from_signals',`<span class="fsig">${tile('signals')}</span>`,'Signals'):''}</div>
<p class="cap">${listHides
?`Off where ${esc(listHides.title||listHides.id)} is off; its settings bring them back.`
:`Off leaves ${these} out of that list and its count. ${isGroup?'Their':'Its'} own page still has everything.`}</p>
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
with what Settings hides everywhere.</p></div>`}
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
<p class="cap">Whole words or phrases, hidden everywhere and not downloaded for you.</p></div>`}`)}
${sect('Directory',`${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')}
<p class="cap">${f.private_by_address
?'Its address or login shows it is someone’s own, so it is never shared with the directory.'
:f.private&&!admin
?'Never shared with the directory. Only an admin can share it again.'
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p></div>
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p>`)}
</div>
<div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
@@ -649,7 +679,7 @@ function settingsModal(f: Feed, newUrl?: string){
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
};
for(const r of $$<HTMLInputElement>('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
// Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
// The pause slider only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
$<HTMLInputElement>('#sdead').onchange=(e: Event)=>{ $<HTMLInputElement>('#sdpause').disabled=!(e.target as HTMLInputElement).checked; };
const pause=$<HTMLInputElement>('#sdpause'), pauseText=()=>{ const s=`${Number(pause.value)/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
pause.oninput=pauseText; pauseText();
@@ -698,6 +728,11 @@ function settingsModal(f: Feed, newUrl?: string){
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
const priv=$<HTMLInputElement>('#sprivate');
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
// Sent only when changed, and not while the OPML decides it.
for(const [id,key] of [['#sinall','hide_from_all'],['#sinsig','hide_from_signals']] as const){
const x=document.querySelector<HTMLInputElement>(id);
if(x&&!x.disabled&&x.checked===!!f[key]) patch[key]=!x.checked;
}
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
// API refuses it from anyone else. The folder and category are not this dialog's at all.
if(admin) patch.url=$<HTMLInputElement>('#surl').value.trim();

View File

@@ -23,6 +23,8 @@ function connect(){
const preloadSoon=soon(preloadLists,2000);
sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
// Someone else's download is theirs: its file's name and its failure stay off this page (#294).
if((ev.ev==='progress'||ev.ev==='download_error')&&!mine(ev.feed)) return;
if(ev.ev==='progress'){
const pct=ev.total?ev.done/ev.total*100:0;
// Only the row actually downloading. Without the enclosure id this used to paint
@@ -62,7 +64,7 @@ function connect(){
else if(ev.ev==='feed_done'){
setScanning(ev.feed,false);
if(!mine(ev.feed)) return;
if(ev.feed===S.feed||S.feed===':all') refreshEntries();
if(ev.feed===S.feed||S.feed===':all'||S.feeds.some(f=>f.id===ev.feed&&f.group===S.feed)) refreshEntries();
}
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
// red ! marks the feed instead, and its page says why.

View File

@@ -14,9 +14,8 @@ function renderFeed(){
const name=f?(f.title||f.id):sg?sg.name:v.title;
$<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
if(kids.length){ box.classList.add('plain'); renderGroup(f!,kids); return; }
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
box.innerHTML = (f ? `
const unreadAll=unreadInAll();
box.innerHTML = (f&&kids.length ? groupHead(f,kids) : f ? `
<div class="fhead slim">
${artHTML(f.image,name)}
<div class="meta">
@@ -34,6 +33,7 @@ function renderFeed(){
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
${keepHTML(f)}
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
@@ -60,7 +60,8 @@ function renderFeed(){
const pane=document.createElement('div');
pane.id='split';
if(f) pane.className='one';
// One feed's list leaves out the feed column; an OPML's shows are told apart by it.
if(f&&!kids.length) pane.className='one';
pane.innerHTML='<div id="list">'+sortHead()+
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!));
@@ -99,26 +100,25 @@ function sortBy(col: string){
S.offset=0; renderFeed(); loadEntries();
}
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
/// An OPML subscription's page lists the items of the shows inside it together, as All
/// Subscriptions does every feed's (#283), under the header a feed has, its buttons in the
/// same places: it is still an ordinary feed entry underneath.
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||'');
function renderGroup(f: Feed,kids: Feed[]){
function groupHead(f: Feed,kids: Feed[]){
const unread=kids.reduce((n,c)=>n+c.unread,0);
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
const gone=kids.filter(c=>c.orphaned).length;
$('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`;
// The same header as a feed's, buttons in the same places: it is a feed underneath.
$('#content').innerHTML = `
return `
<div class="fhead slim">
${folderArt(f,kids)}
<div class="meta">
<h2>${esc(f.title||f.id)}</h2>
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
· ${plural(kids.length,'show')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
${failBannerHTML(f)}
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
${gone?`<div class="sub" style="color:var(--warn)">${gone} show${gone===1?' is':'s are'} no longer
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
</div>
<div class="acts">
@@ -129,38 +129,18 @@ function renderGroup(f: Feed,kids: Feed[]){
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div>
</div>
<div class="toolbar">
<input type="search" class="grow" id="kidSearch" placeholder="Search these feeds…">
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
</div>
<div class="childlist" id="kidlist"></div>`;
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a!,f));
wireFailBanner($('#content'),f);
const draw=()=>{
const q=($<HTMLInputElement>('#kidSearch').value||'').trim().toLowerCase();
const box=$('#kidlist'); box.innerHTML='';
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
for(const c of rows){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML = artHTML(c.image,c.title||c.id)+
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
(c.orphaned?'<span class="tag">Gone</span>':'')+
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</span>`:'')+
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
el.onclick=()=>selectFeed(c.id);
box.appendChild(el);
}
};
$<HTMLInputElement>('#kidSearch').oninput=draw;
draw();
</div>`;
}
/// Keep, for a show from an OPML or a Patreon creator: it stays yours, a show of its own, should
/// the list drop it or you leave the list (#280). A plus to keep it, a check once kept.
function keepHTML(f: Feed){
if(!f.managed||!f.group) return '';
const list=S.feeds.find(p=>p.id===f.group);
const name=esc(list?(list.title||list.id):f.group);
const title=f.kept?`Kept: stays on your list if ${name} drops it. Stop keeping it`:`Keep it on your list if ${name} drops it`;
return `<button class="btn ico" data-a="keep" title="${title}" aria-label="${f.kept?'Kept':'Keep'}" aria-pressed="${!!f.kept}">${f.kept?ICON.check:ICON.plus}</button>`;
}
async function feedAction(a: string,f: Feed){
if(a==='scan'){
spinStart(f.id);
@@ -175,6 +155,12 @@ async function feedAction(a: string,f: Feed){
await loadFeeds(true); renderFeed();
}catch(e){ toast(why(e),true); }
}
if(a==='keep'){
try{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({kept:!f.kept})});
await loadFeeds(true); renderFeed();
}catch(e){ toast(why(e),true); }
}
if(a==='settings') settingsModal(f);
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
if(a==='dl') downloadLatestModal(f);

View File

@@ -35,6 +35,12 @@ const VIEWS: Record<string,{title: string, icon: string, url?: string, blurb?: s
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
':all':{title:'All Subscriptions',icon:ICON.all},
};
/// Whether a feed's items are in All Subscriptions, or in Signals: not when it, or the OPML or
/// creator it came from, is hidden from them (#288, #289). The server leaves out the same.
const shownIn=(f: Feed, key: 'hide_from_all'|'hide_from_signals')=>!f[key]&&!(f.group&&S.feeds.some(g=>g.id===f.group&&g[key]));
const inAll=(f: Feed)=>shownIn(f,'hide_from_all');
/// All Subscriptions' unread count.
const unreadInAll=()=>S.feeds.reduce((n,f)=>n+(inAll(f)?f.unread||0:0),0);
/// Feeds being checked right now, from the event stream, and since when: their rows, and the row
/// of a folder holding one, carry a spinner.
const scanning=new Map<string, number>();
@@ -90,7 +96,7 @@ function renderFeeds(){
const drawn=new Map<string,HTMLImageElement[]>();
for(const img of $$<HTMLImageElement>('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); }
list.innerHTML='';
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
const unreadAll=unreadInAll();
const places=document.createElement('div');
places.className='places';
for(const [id,v] of Object.entries(VIEWS)){

View File

@@ -24,11 +24,9 @@ function keepList(key: string, r: ListPage){
/// Fetches a list's first page ahead of a click: when the page has loaded, and when the pointer
/// reaches a feed or a filter. Not one kept already or on its way.
function prefetch(feed: string, filter=S.filter){
// Currently Listening and the Directory draw from their own address; a folder's page lists its
// feeds, not items.
// Currently Listening and the Directory draw from their own address.
const url=VIEWS[feed]?.url;
if(url) return prefetchPage(url);
if(S.feeds.some(c=>c.group===feed)) return;
const key=listKey(feed,filter);
if(lists.has(key)||fetching.has(key)) return;
const got: Promise<ListPage>=api(listUrl(feed,filter));
@@ -51,8 +49,9 @@ const prefetchPage=(url: string)=>{ if(!pages.has(url)) fetchPage(url).catch(()=
/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
/// are fetched again when next wanted.
function forgetLists(feed: string){
// A Signal holds items of any feed.
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')) lists.delete(k);
// A Signal holds items of any feed, and an OPML's list its shows' (#283).
const group=S.feeds.find(f=>f.id===feed)?.group;
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')||(group&&k.startsWith(group+'\n'))) lists.delete(k);
}
/// What is likely to be opened next, fetched two at a time once the page is idle: All Subscriptions
/// and the feeds with something unread, most first.
@@ -125,7 +124,9 @@ function listLoading(box=$('#eps')){
function renderEntries(){
const box=$('#eps'); if(!box) return;
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''], sg=signalOf(S.feed);
const unread=f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
// An OPML's own row counts nothing: its shows do.
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
const unread=kids.length?kids.reduce((n,x)=>n+x.unread,0):f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
$('#count').textContent=
`${f?(f.title||f.id):sg?sg.name:v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
const pane=$('#list'), top=pane?pane.scrollTop:0;
@@ -375,6 +376,12 @@ function showDetail(e: Entry|null){
box.innerHTML=detailHtml(e);
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
$<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
const view=box.querySelector<HTMLSelectElement>('.tview');
if(view) view.onchange=()=>{
textView=view.value;
try{ localStorage.setItem('ipx.textview',textView); }catch{}
box.querySelector('.dbody')!.innerHTML=textOf(e);
};
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
b.onclick=()=>epAction(b.dataset.a!,e,null,b.dataset.enc?Number(b.dataset.enc):undefined));
syncPlayButtons();
@@ -400,6 +407,8 @@ function detailHtml(e: Entry){
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration),
e.enclosures.some(x=>x.deadair_pending)&&DEAD_PENDING]
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
${e.markdown?`<select class="tview" aria-label="Show the text as">${TEXT_VIEWS.map(([v,label])=>
`<option value="${v}"${v===textView?' selected':''}>${label}</option>`).join('')}</select>`:''}
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
@@ -409,7 +418,17 @@ function detailHtml(e: Entry){
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
</div>
${narrow?encs:''}
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
<div class="dbody">${textOf(e)}</div>`;
}
/// How an item whose feed sends Markdown beside its HTML is shown, kept from one item to the
/// next (#285): the publisher's HTML, or the Markdown made HTML.
const TEXT_VIEWS=[['html','HTML'],['markdown','Markdown']];
let textView=(()=>{ try{ return localStorage.getItem('ipx.textview'); }catch{ return null; } })()||'html';
function textOf(e: Entry){
// Cleaned server-side with ammonia, as the description is.
if(e.markdown&&textView==='markdown'&&e.markdown_html) return e.markdown_html;
return (e.description&&e.description.trim())||'<em>No show notes.</em>';
}
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.

View File

@@ -18,6 +18,10 @@ function signalOf(view: string|null){
return id===null ? undefined : S.signals.find(s=>s.id===id);
}
/// Whether you have Signals (#271): everyone does on the server's key; on your own, once you have
/// turned them on in Settings with a key TypeSafe took.
const signalsOn=()=>S.me?.signals==='server'||!!S.me?.typesafe_key_set;
/// The Signals folder: first in the feed list, under All Subscriptions, open or closed as an OPML's
/// is (#255, #256). As rows under the places, a long list of them pushed the feeds down.
const SIGNALS=':signals';
@@ -42,6 +46,7 @@ function signalTile(sg: {name: string, icon: string|null}){
}
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
function signalFolder(list: HTMLElement, q: string){
if(!signalsOn()) return;
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
if(q&&!mine.length) return;
const open=expanded.has(SIGNALS)||!!q;
@@ -101,7 +106,7 @@ function renderSignals(){
}
function signalHead(sg: Signal){
const nokey=S.me&&!S.me.typesafe_key_set;
const nokey=S.me&&!signalsOn();
return `
<div class="fhead slim">
${signalTile(sg)}
@@ -130,14 +135,14 @@ async function signalAction(a: string, sg: Signal){
/// Making a Signal, or changing one.
function signalModal(sg?: Signal){
const nokey=S.me&&!S.me.typesafe_key_set;
const nokey=S.me&&!signalsOn();
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
<div class="field"><label for="sgname">Name</label>
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
aria-expanded="false"></button>
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
<div id="sgpicker" hidden>
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
<div class="inline">${searchBox('<input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">')}
<button type="button" class="dlink" id="sgjev" title="Pick an icon for me">Auto</button>
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
@@ -146,10 +151,9 @@ function signalModal(sg?: Signal){
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
<div class="field"><label for="sgdesc">In your own words (optional)</label>
<input type="text" id="sgdesc" value="${esc(sg?.description||'')}" placeholder="AI tools for writing software: coding agents, vibe coding">
<span class="hint">Every item in the feeds you subscribe to is read, the last 30 days of them
<span class="hint">Every item in the feeds you subscribe to is read, the last ${plural(S.me?.signal_days??7,'day')} of them
and each new one, and the ones about this subject are pulled out: its name, these words and
your sentence together. The rest stays noise, in its own feed. Each item is one call on your
TypeSafe key.${sg?' A change here reads everything again.':''}</span></div>
your sentence together. The rest stays noise, in its own feed.${S.me?.signals==='server'?'':' Each item is one call on your TypeSafe key.'}${sg?' A change here reads again what was picked out, and listens for it in new items; what was passed over stays passed over.':''}</span></div>
${nokey?`<p class="hint" style="color:var(--warn)">Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}</p>`:''}
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
@@ -280,6 +284,7 @@ const KEY_LINKS=`<a href="https://typesafe.ai" target="_blank" rel="noopener">Ge
async function keySetting(){
closeModal();
await prefsModal();
$<HTMLInputElement>('#ssigon').checked=true; $('#ssigkey').hidden=false;
const k=$<HTMLInputElement>('#stskey'); k.scrollIntoView({block:'center'}); k.focus();
}
document.addEventListener('click',(e: MouseEvent)=>{

View File

@@ -35,6 +35,12 @@ interface Feed {
unread: number;
subscribers: number;
pinned: boolean;
/** From an OPML, and yours to keep should it drop the feed (#280). */
kept: boolean;
/** Left out of All Subscriptions by its own setting; a show is also when its OPML is (#288). */
hide_from_all: boolean;
/** The same for Signals (#289). */
hide_from_signals: boolean;
private: boolean;
private_by_address: boolean;
}
@@ -47,6 +53,9 @@ interface Entry {
link: string | null;
published: number | null;
description: string | null;
/** The item's Markdown as written, when its feed sends it beside the HTML, and made HTML (#285). */
markdown: string | null;
markdown_html: string | null;
read: boolean;
flagged: boolean;
image: string | null;
@@ -84,6 +93,10 @@ interface Me {
version: string;
/// A TypeSafe key is saved for Signals; the key itself never comes back.
typesafe_key_set: boolean;
/// Whose TypeSafe key Signals are judged on (#271): the server's, for everyone, or each person's own.
signals: 'server' | 'own';
/// How many days back a new Signal reads (#276), an admin's setting.
signal_days: number;
}
/// A Signal in your sidebar, `/api/signals` (db.rs SignalRow).

View File

@@ -61,6 +61,20 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon!]);
/// A search box with an x at its right end that clears it, shown once something is typed (#291).
const searchBox=(input: string, cls='')=>
`<span class="clearable ${cls}">${input}<button type="button" class="clearx" title="Clear" aria-label="Clear">${ICON.close}</button></span>`;
// Every box's x, wherever it is drawn: cleared as if the typing were deleted, so the box's own input
// handler runs and its list comes back whole.
document.addEventListener('click',(e: MouseEvent)=>{
const x=(e.target as Element).closest?.('.clearx');
const box=x?.parentElement?.querySelector('input');
if(!box) return;
box.value='';
box.dispatchEvent(new Event('input',{bubbles:true}));
box.focus();
});
/// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line
/// becomes how much shorter it is once the copy is ready (the dead_air event).
const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>';