138 Commits

Author SHA1 Message Date
0be8eb5cdf ipx 1.5.0, ipx-directory 1.4.0 and ipx-web 1.3.0
The security audit's 26 findings, three dependency advisories and the
performance issues found in traces (#318-#352), each already deployed
as a -dev build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:41:57 +00:00
e1291c1627 Count unread feed by feed (#352)
After #347 an unread count read only the items since each feed's
unread_from, but Postgres guessed a range join across every feed
keeps a third of its rows (17,000 where there are 200), so it hashed
all 22,949 of the person's entry_state rows. Counted in a subquery per
subscription, the feed and its unread_from are constants there, and
it looks up the few items since and their read state by key: on
production the feed list's unread counts went from 19.7 to 14.8 ms and
All Subscriptions' unread count from 19 to 13.

The feed list's unread counts and count_of for Unread without a search
(All Subscriptions, a feed, an OPML's shows) use it; a page of unread
items still joins, since it needs rows in order, not a count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:14:36 +00:00
a11e3b6367 Unread lists start from each person's oldest unread item (#347)
All Subscriptions' Unread page, its count and the feed list's unread
counts read every item of the person's feeds and hashed all their
entry_state rows to find the few unread: 11,727 items and 22,949 rows
for 44, 27-32 ms on production, growing with the back catalogue. A
NOT EXISTS rewrite of the same scan was no faster.

subscriptions.unread_from says every item of the feed first seen
before it is read by this person. Unread queries join the subscription
on e.first_seen >= coalesce(unread_from, 0), and the new index
entries_seen (feed_id, first_seen) makes that a range per feed.

Nothing has to keep a list in step, so nothing can drift: the reap job
and daemon start move it up to the oldest unread item (hidden ones
too, since a block list can be lifted), never past an hour ago, so an
item a scan is storing meanwhile is not passed over. The one way an
older item becomes unread, marking it so, clears the person's value
for that feed. NULL, or stale, only means reading more.

The filter test now subscribes its reader, as every list's reader is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:04:32 +00:00
cb4ee5a570 Kept artwork off the async threads, and one fetch per missing image (#345 #349)
#345: a cache hit made four blocking std::fs calls on a tokio worker,
and the cache was on shfs (FUSE). A page asks for about fifty images at
once, so hits ran 11 ms at p50 and over a second at p90. Reading,
keeping and the reap job's trim now run in spawn_blocking. Production's
cache also moves to /mnt/fast in the compose file, outside the repo.

#349: two requests for the same uncached image both fetched it from its
publisher. A per-address lock now makes the second wait for the first,
then find the image kept. The lock is forgotten once its last holder is
done, whichever way the request ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:33:41 +00:00
c4878731c6 Count a Signal's items and unread in one pass (#348)
signal_rows ran the same correlated subquery over signal_verdicts,
entries, subscriptions and entry_state twice a Signal, once for items
and once for unread. One grouped subquery now gives both: count(*) and
a sum of the unread, joined to the person's Signals. Same results on
SQLite and Postgres (the existing counts test runs on both).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:30:30 +00:00
21fa0ae476 Run the item page's count and the feed list's queries together (#346)
entry_page awaited the page's query and then its count, about 40 and
24 ms on /api/entries, the busiest route; feed_list awaited five
independent queries in turn (entries, downloaded, unread, blocked,
feeds), 59 ms of a 67 ms /api/feeds, with the pool's connections idle.
Both now use tokio::try_join!. On SQLite they queue on its connection
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:28:09 +00:00
aa430f6d50 A page's files are looked up by feed and item, not item alone (#344)
entries_where found a page's enclosures with guid IN (...). The only
index is enclosures_entry (feed_id, guid), so Postgres read the whole
of it: 587k rows, 9,150 buffers and 250 ms for user 2's 50 newest, on
every /api/entries, feed page, Signal page and single item. With the
page's feed ids as well it is an index lookup, 0.5 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:24:29 +00:00
df643536c0 ipx-web drops the header TRUSTED_HEADER names from anyone but the tunnel (#323)
ipx trusts [web] trusted_header from Caddy's address, and Caddy
stripped only the two Cf-Access-* names from requests not from
TUNNEL_PEER. With trusted_header set to anything else (X-Remote-User
for another proxy), a request through ipx-web carried it to ipx as
Caddy's and was believed: anyone could sign in as anyone.

TRUSTED_HEADER, defaulting to Cloudflare's name, is stripped the same
way. Production uses Cloudflare's header and sets nothing, so it
behaves as before. caddy adapt shows the delete list with and without.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:20:55 +00:00
543d35f02c A snapshot's subscriber counts are bounded above (#342)
Store::snapshot clamped a count only from below, and LISTING sums each
feed's counts across instances, cast to BIGINT. Two instances sending
i64::MAX for a feed overflowed it (Postgres: bigint out of range;
SQLite: integer overflow), and list() and popular() failed for every
feed: the public pages 503, /v1/feeds and /v1/popular 500, until an
admin dropped the instance. Counts are now clamped to a million.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:17:43 +00:00
0de0fb4332 Bound icon picks per person, and keep people's Signals apart (#340 #343)
#340: POST /api/signals/icon spends the server's TYPESAFE_KEY for
anyone signed in, whatever signals_on_server says, and is not tied to
a Signal, so a loop on it ran up the bill and, through the shared pace
and a 429's back-off, held back everyone's judging. Each person now has
20 picks an hour; past that it is a 429 the page shows as a toast.

#343: on the server's key, calls() put everyone's Signals on an item in
one call (#275), so one person's free-text description was part of the
question that decided another's verdicts. Each person's Signals on an
item are now a call of their own. The saving #275 made is lost only
where two people have Signals on the same item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:13:02 +00:00
79b496bdfb A password check holds its turn until it finishes (#324)
check_password took a permit from CHECKS in the async function and
ran Argon2 in spawn_blocking. A client that hung up dropped the
future and with it the permit, while the blocking check ran on, so a
flood of sign-ins that disconnected at once ran unbounded Argon2 work,
the very thing CHECKS was added for (#137).

The permit is now owned (CHECKS is an Arc) and moves into the blocking
closure, so it is freed when the check ends, whoever stopped waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:03:19 +00:00
a20fade8ad OPML uploads are capped at 2,000 feeds and checked by address once (#338)
subscribe_opml, reachable by anyone signed in through POST /api/opml,
held the catalogue lock while, for each outline, it searched every
subscription and the whole catalogue for its address and probed
unique_slug's suffixes in order. 2 MB holds about 40,000 outlines, so
one upload was quadratic work with every other catalogue change
waiting, and its feeds stayed in the catalogue for good.

opml_feeds lists a document's feeds and refuses more than MAX_LISTED,
the limit a subscribed list already has (#337): a 400 from the web, a
message from `ipx opml`. subscribe_opml takes that list and looks
addresses up in a map built once, the first feed with an address
winning as the search in order did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:00:29 +00:00
daf95a25cd Only a subscriber or an admin marks a feed private (#333)
patch_feed let private = true through for anyone signed in, as the
field's doc comment meant for subscribers, but never checked that the
caller subscribed. Ids are slugs of titles, so anyone could mark
another person's feed, or a whole OPML (whose shows go with it),
private, keeping it out of the directory, and promote a derived feed
they did not read out of its OPML; only an admin could undo either.

A non-admin now needs a subscription to the feed, or gets 404, as the
per-person settings above already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:56:36 +00:00
91de1114d8 Live updates go to those who read the feed, while their account exists (#325 #335)
/api/events sent every daemon event to every open page. Event::redacted
strips the query and the password from addresses, not the path, so a
paid feed's key in its path (feeds.supercast.com/feeds/<key>) reached
everyone signed in through its FeedError or FeedMoved (#325). And the
stream checked who was asking once, when it opened: a removed account's
page kept getting events until it closed (#335).

Each stream now looks its account and subscriptions up again at most
every 30 s, as an event arrives, ends when the account is gone, and
sends an event about a feed only to someone subscribed to it. Admins
get everything, for the log; Reaped, a file path, goes to admins only;
SignalHits to the person it is for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:51:12 +00:00
37c08195f1 A new password signs the account out everywhere (#334)
ipx user passwd, the one way to change a password and the step taken
when one may be known to someone else, only replaced the hash. Every
session stayed, and session_user moves `seen` on each use, so a
session someone kept using never reached the idle cutoff.

set_password now deletes the account's sessions too. API tokens stay:
a reset that broke every script would be a reason not to reset, and
the owner lists and removes tokens in Settings. The command says how
many still work.

Also corrects the first-start log line from #321: there is no way to
change a password in the page, so it names the command alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:45:41 +00:00
d1149e1236 Requests that change something are refused from another site (#336)
Six POST routes take no body, so a cross-origin form or no-cors fetch
reached them with no preflight: Sign out, Read all (one feed's and
every feed's), Download, Refresh a listing and subscribing to a
Directory feed. The ipx cookies are SameSite=Lax, which stops a form
on an unrelated site but not one on a sibling subdomain, and the
Cloudflare Access path does not use ipx's cookies at all.

cross_site_refused wraps every route, login included: anything but
GET, HEAD and OPTIONS is a 403 when Sec-Fetch-Site is not same-origin
or none, or, from a browser too old to send it, when Origin is not the
Host. A request with neither header is not a browser's (a script with
a token, the healthcheck, k6) and goes through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:33:39 +00:00
46fe0dddbf directory.toml's fetch_internal lets the refresher reach listed addresses (#329)
3bfd8f8 gave the refresher the guard with no exceptions, which broke
the browser suite: its directory refreshes fixture feeds on 127.0.0.1,
and five Directory tests waited on listings that never filled. The
cargo tests passed because none of them fetches over HTTP.

directory.toml takes fetch_internal as config.toml does, empty by
default; the suite's lists 127.0.0.1. Production's has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:33:30 +00:00
d35b8054b4 Bound the scan work one feed's author can ask for (#339 #337)
Both run on the daemon's single worker, so every other feed's check
and download waited behind them.

#339: prefer_https asked each new artwork host whether it served the
image on https, up to 20 s each, one after another, and the per-scan
cache is keyed by the authority, which ports and user names multiply
for one server. It now asks at most eight hosts a read; past that an
http image stays http, for /api/art to fetch, as one that fails does.

#337: a list (OPML or Patreon) may bring in 2,000 feeds; past that
sync_group refuses it whole, so the feed shows an error and nothing
changes. davewiner's, the largest seen, had 922. sync_group also read
the whole feeds table and rebuilt an id map with a Feed cloned per
entry for every new feed; it now reads the table once and checks ids
against sets, and unique_slug takes a predicate rather than a map.
same_feed skips parsing two URLs unless both are Patreon's, since only
those can differ and match.

sync_group and sync_opml lose the parent argument, which nothing used
once promote_kept stopped taking it (#332).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:18:32 +00:00
3bfd8f848b The directory's refresher refuses internal addresses (#329)
The refresher fetches every feed a snapshot names, and anyone with a
write token sends snapshots; a listed feed's redirect goes wherever its
publisher likes. Nothing checked the destination, so the directory
fetched loopback, its Docker network and the LAN, and what answered
became a listing's title and description on its public pages.

Its client now has ipx-feed's guard (#320) with nothing allowed: no
feed the directory lists is on this network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:10:32 +00:00
fc04a741a6 The directory's public pages read the list once a minute (#341)
/, /all, /search and /category are anyone's to ask for (#215), and each
called Store::list: the whole catalogue, joined with the snapshots and
grouped, on the 10-connection pool the refresher and every server's
/v1 calls share, on the Postgres that holds production ipx's database.
The pages' Cache-Control did nothing for a request with a query string
of its own, such as /search?q=<random>.

Directory::pages_list keeps the list for a minute behind a mutex held
through the read, so a burst of misses waits on one query rather than
each sending its own. Filtering and rendering still happen per request,
in memory. /v1/feeds, behind a token, still reads the database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:06:19 +00:00
80f12cda70 A feed's credentials go to its own host alone (#327 #328)
feed::fetch already sent basic auth only to the feed's own host, but
two paths around it did not:

- download::download attached the feed's username and password to
  every enclosure, on whatever host the feed named: a CDN, or the
  measurement and ad-insertion prefixes podcasts route through (#328).
- subscriptions() and promote_kept() gave every feed an OPML or Patreon
  list derives the list's credentials, and fetch's same-host check then
  compared against the derived feed's own URL, so the list's password
  went to every host the OPML named, on every scan (#327).

config::Feed::auth_for and credentials_for compare hosts once, and
each of the three uses them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:02:10 +00:00
4f7dab9c04 A kept show keeps its list's private mark and credentials (#332)
promote_kept copied the list's settings from the current config, but
leave, rm and the directory cleanup remove the list from config before
calling retire_group, so the lookup found nothing: the promoted show
got private = false and no username or password, and the next snapshot
sent its URL to the directory. Only sync_group, which promotes while
the list is still there, kept them.

The callers now pass the list's settings as they were before removal.
Where they are no longer known (retire_stranded, for a list removed
long ago), the promoted show is private: a URL kept back by mistake
can be shared by hand, one shared by mistake cannot be taken back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:59:02 +00:00
cc3b8742bc The first admin gets a random password, not admin / ipodderx (#321)
A database with no accounts created admin with the hard-coded password
ipodderx, published in the README, the docs and the sample compose
file, and the log line announcing it printed the password. /api/login
is open to anyone who reaches the port, and nothing made anyone change
it, so a fresh install was an admin account anyone could sign into.

Now the password is IPX_ADMIN_PASSWORD when set, for scripted installs,
or 24 random hex characters written to admin-password beside
config.toml, created 0600 so it is never readable by others even for a
moment. The log says where it is, never what it is, as it does for the
web token (#40). Existing databases are untouched: seeding runs only
with no accounts at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:55:34 +00:00
c99f481124 Update rustls-webpki, webpki-roots and ctutils for three advisories (#351)
cargo deny check failed on main:
- RUSTSEC-2026-0382, rustls-webpki 0.103.15: verification may accept
  distrusted intermediates. Now 0.103.16.
- RUSTSEC-2026-0381, webpki-roots 1.0.9: bundled roots trust distrusted
  emSign intermediates. Now 1.0.11. It surfaced once the first was fixed.
- RUSTSEC-2026-0385, ctutils 0.4.2: non-constant-time masks and u128
  comparisons. Now 0.4.5.

All transitive; Cargo.lock only. cargo deny check passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:09 +00:00
a20062d0f7 Fetches refuse internal addresses unless fetch_internal lists them (#320)
ipx's clients had no destination policy and followed redirects
anywhere. Adding a feed returned the status or first line of whatever
answered at the address; an enclosure declared as audio was downloaded
whole from any URL and served back through /media; /api/art fetched
any image a feed named. Feeds come from publishers and anyone signed
in, so loopback, the Docker network and the LAN were all reachable.

ipx-feed's guard is a connector layer: after the name resolves and the
connection opens, before the request is written, it looks at the
peer's address and refuses anything off the public internet unless
config.toml's [general] fetch_internal lists it. Being on the
connection, it covers every request on ctx.client and feed_client,
each redirect hop, IP literals (which never reach a DNS resolver) and
a name that resolves differently the second time. reqwest's rustls
connection reports the peer address; a test run by hand checks HTTPS
gets through, since were it lost every https fetch would be refused.

The directory is on the server's own network by design
(http://ipx-directory:8100), so it moves to local_client, unguarded,
set by an admin. The browser suite and load tests serve their feeds on
loopback and list 127.0.0.1.

Not covered: librqbit's own connections (trackers, peers), which do
not go through these clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:27:40 +00:00
6d71613e19 ipx fetches a .torrent itself, capped at 10 MB (#330)
Handed an http(s) URL, librqbit 9.0.1 fetched the .torrent with its own
reqwest client (no timeout) and response.bytes(), buffering the whole
body: a publisher could serve one without end, for up to the 30-minute
metadata budget, two torrents at a time, on a host with no swap.

Now ipx fetches it on ctx.client (connect and read timeouts), stops past
10 MB, holds it to the stall budget too so a trickle cannot keep a slot,
and gives librqbit the bytes. Magnet links go to librqbit as before.

Not fixed here: librqbit reads each HTTP tracker announce the same way,
and offers no setting to bound it short of disable_trackers, which would
break torrents that need their tracker. #330 stays open for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:15:17 +00:00
f398211fd6 OPML nested past 100 levels is refused before it is parsed (#322)
The opml crate's Outline reader (hard-xml's derive) recurses once per
<outline> level with no limit. A 2 MB upload holds tens of thousands of
levels, enough to overflow a tokio worker's 2 MiB stack, and a stack
overflow aborts the process, not the task. A fetched OPML (up to 50 MB)
reached the same parse on every scan, so a publisher could keep the
daemon in a crash loop.

feed::read_opml counts nesting with quick-xml's streaming reader, which
does not recurse, and refuses past 100 before handing the text to the
opml crate. Every place that parsed OPML goes through it: the upload,
`ipx opml`, a fetched OPML's feeds and its title. The limit also bounds
collect_outlines' recursion and the drop of the nested Vec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:11:21 +00:00
708b69c083 Feed links are followed only when they are web addresses (#326)
An item's link and an enclosure's URL are the publisher's, stored as
written, and the page put them in href and window.open through esc(),
which escapes characters, not schemes: a javascript: or data: link
survived as Open the original, View in a new tab (for a file not on
disk), the Directory's item links and the v key. Show notes already
lose those schemes to ammonia; these four sinks were the exception.

webUrl() passes http(s) only, and each sink goes through it. A link
that fails it shows no link, as an item with none does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:07:47 +00:00
62a2456dde Downloads open in the browser only when they cannot run script (#319)
/media served a downloaded file with ServeFile, which types it by its
extension, and the file and its name are the publisher's. An enclosure
saved as .svg or .html, which download::sniff lets through unless it
starts <html or <!doctype html, opened from "View in a new tab" ran its
script on iPX's origin with the viewer's session.

Now every /media response is nosniff, anything but audio, video, a
non-SVG image, a PDF or plain text is sent as an attachment, and all
but PDFs get the sandbox CSP artwork already had. PDFs go without it
because Chrome refuses to render one in a sandboxed document; its
viewer does not run the PDF's script as the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:01:00 +00:00
ec3dedf113 Torrents download into a folder of their own (#318 #331)
The recorded path was output_folder.join(handle.name()). The name is the
publisher's: librqbit checks it for traversal only when it chooses the
subfolder itself, and ipx passes output_folder, so a multi-file torrent
named "/config" or ".." pointed Delete, the reaper and /media at that
path. With output_folder set, librqbit also puts a multi-file torrent's
files straight into it, not under the name, so the joined path was not
where the files were even for an honest torrent.

Each enclosure now gets torrent-<id> inside its feed's folder, and that
folder is the path: playable() already picks the largest file under a
folder and remove() deletes one (#158). The same folder keeps
overwrite: true from replacing another enclosure's files of the same
name. A torrent half-downloaded before this restarts in its new folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:55:35 +00:00
511246acb2 More step spans where flame graphs showed time with nothing under it (#350)
A week of traces folded with query.py flame left whole requests opaque:
entries_in at 46-82 ms a request with the page's query and its enclosures
together, /api/art 37 ms, /api/status 95 ms, PATCH /api/feeds/{id} 72 ms and
DELETE 45 ms. Each now has a step around what it waits on: the page and its
enclosures; the artwork cache, the check that a feed names it, the fetch and the
write; the status counts; and in changing or leaving a feed, each database call,
the wait for the catalogue's lock and storing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:45:58 +00:00
ccc4766b03 query.py: flame graphs from traces
`query.py flame '<TraceQL>' [since] [out.svg]` folds the matching spans of up to
100 traces into stacks of self time and draws them as a flame graph, with the
folded stacks beside it for speedscope or inferno. A wall-clock picture of where
a route's time goes, waiting included, which a CPU profiler would not show: ipx
waits on Postgres and the disk far more than it computes. The traces are fetched
in one container, a docker run apiece being a second each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:41:12 +00:00
78b81e43aa CLAUDE.md: the style guide path is /src/styleguide, not sytleguide
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:14:06 +00:00
4af12363bc Read a WordPress.com site's posts from its API when the feed is behind a browser check (#317)
pelgranepress.com is on WordPress.com, whose CDN answers /feed/, /feed/atom/ and ?feed=rss2
with HTTP 403 and "Checking your browser...", a JavaScript challenge, whatever the User-Agent.
The checks that hit it failed for hours (8 in the day before this), and the publisher has
not changed anything.

The API at public-api.wordpress.com/wp/v2/sites/<host>/posts has the same posts with no check,
and each post's guid is the feed's own (https://host/?p=<id>), so the items read from it are
the ones already stored. fetch recognises the challenge by its /__challenge/ script, asks the
API instead, and parse reads WordPress's posts JSON as a feed. It sends no title or image for
the feed, which record_feed keeps as they were.

shortcut: the API's default ten posts, as many as WordPress puts in a feed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:06:22 +00:00
84b4203daf A feed's page follows its row after a check (#316)
The header was drawn once, on opening the feed. feed_row put the new row in S.feeds and redrew the
feed list but not the open page, so a check that cleared an error left the error showing, with the
old "checked" time, until the feed was opened again. Seen on Eli Bendersky's website: a connect
timeout at 15:13, a clean forced check at 15:28, and the timeout still on the page.

Only the stat line and the error banner are redrawn: the whole page would close the open item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:37:19 +00:00
0696583547 Reduced motion, touch hover, overscroll and artwork edges (#310 #311 #312 #313 #314 #315)
Fixes from the UI-polish review of the main window (2026-10-19).

Reduced motion took every animation away, and a turning icon was the only sign a check was
running. The refresh buttons now carry aria-busy and a "Checking for new items" label while
checking, and under reduced motion spinners pulse and toasts and sheets fade.

Every :hover rule is behind @media (hover:hover), since Safari keeps :hover on the last thing
tapped; the two phone resets that undid it for .btn and .ep had nothing left to do and are gone.
An item row's actions still show on hover without the query, so a tablet with no pointer can
reach them by tapping the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:16:41 +00:00
6ec2c35291 Keyboard, screen reader and contrast fixes from the interface review (#298 #299 #300 #301 #302 #303 #304 #305 #306 #307 #308 #309)
Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes,
light and dark), in Playwright against the test daemon.

- #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's
  rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named
  by what they show, the open one aria-current. Not role=button, which would flatten a folder's
  triangle inside it.
- #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0.
- #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers.
- #301: the seek slider is named Position, its value read as "12:30 of 45:00".
- #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search
  and filter boxes have names that do not depend on their placeholder.
- #303: #toasts is role=status; an error toast stays until dismissed, at most four at once.
- #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the
  daemon's internals, already in its log); play() failures are put in words and reported, an
  AbortError ignored; a failed download names its item.
- #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80%
  for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks,
  phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on
  bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0,
  so they take the same focus ring as everything else.
- #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it,
  and makes the rest of the body inert until closeModal, which gives focus back to what opened
  it. Not <dialog>: its top layer would cover the toasts and their Dismiss.
- #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes.
- #309: Settings shows iPX's and the page's versions at its foot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:12:34 +00:00
c8afcec73d Back to -dev after 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:46:58 +00:00
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
47ad68c7da Release ipx 1.3.0, ipx-web 1.1.0 and ipx-directory 1.2.0
Signals (#254) in ipx and the page, and the compression and tracing
work of #239-#253 in all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:37:33 +00:00
ae58e4653a Signals in the theme's accent tint again (#268)
Neither orange tile nor orange icon on the tint worked, so each
Signal's tile is back in the theme's accent tint, as it was before
#268. The Signals folder icon and the larger DeadAir tile stay. The
tile rules go above the initials' comment, which they had split from
its rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:24:18 +00:00
dc3ee016db Signals back on the accent tint, their icon or initials in orange (#268)
A solid orange tile was too loud. Each Signal's tile is the folder's
accent tint again, and its icon or initials are drawn in the orange of
the Signals icon's point: #ff6a1a in dark. In light it is the light
palette's needle, #c43e00, since signals.svg's #ff5500 on the tint is
about 2:1, too faint for initials (about 3.4:1 instead).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:21:44 +00:00
f916ca972a Signals solid in the orange of the Signals icon's point (#268)
--accent2 mixed a quarter into the ground came out salmon, and is pink
or blue in other themes. Each Signal's tile is now solid #ff6a1a, or
#ff5500 in light, the point of signals.svg and signals-dark.svg, in
every theme as the icon is, with the icon or initials in the logo's
navy (#0c2238, 5:1 or better on either).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:16:47 +00:00
7627af5bc8 Signals in the needle's orange, and a larger DeadAir tile in the play bar (#268)
Each Signal's tile, icon or initials, is tinted --accent2 in place of
--accent: the orange point of the Signals folder's own icon, so the
folder and what is in it read as one. The player's DeadAir tile goes
from 22px to 26px in its 30px button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:11:25 +00:00
fbe7629420 App-icon tiles for Signals and DeadAir, drawn as the logo is (#268)
web/signals.svg and web/deadair-{on,off}.svg, each with a -dark twin,
follow logo.svg's rules: square and opaque, the logo's gradient ground,
flat layers in its scale blues, the needle's orange, nothing thin
enough to vanish at 32px. Signals is the page's own glyph, waves going
out from a point, the point in orange. DeadAir on is the waveform
closed up with the needle at the cut; off has the silence still in its
middle, an orange flat line, and its bars muted, so the state is in
the shape as the old glyphs had it.

The Signals folder row and its page header show the Signals tile in
place of the glyph on an accent tint; the player's DeadAir switch shows
the on or off tile. tile() puts both variants on the page, and the mode
rule that picked the logo's now picks any .onlight/.ondark. The three
glyphs nothing uses any more are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:07:18 +00:00
e882904c11 Signals read an image's alt and title text, not the title alone (#267)
xkcd's posts are an <img> whose alt and title are the comic's words. text_of dropped tags whole,
attributes and all, so Jev was sent "Feed: xkcd.com / Title: Ground Effect", ground effect being
an F1 aerodynamics term, and the comic went into the Formula 1 Signal at 0.60. With the alt text
it scores 0.19. Alt and title are read once each, and once when they say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:47:47 +00:00
f1a156c974 A Signal with no key links to typesafe.ai and to the key's field in Settings (#266)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:32:57 +00:00
f34423735a Signals' page copy says what happens, not that Jev does it; Auto spins while it picks (#263)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:28:52 +00:00
15bdce486d The icon picker's Jev button reads Auto (#263)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:23:13 +00:00
0c2c3a379e No + on the Signals folder's row, so it is like any folder's (#265)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:22:29 +00:00
3e4d67fd2f Jev is offered a brand's icon only when its name is one of the Signal's words (#264)
Font Awesome's Mandalorian icon has "disney plus" among its search terms, the only icon with
"disney" in them, so a search for Disney Parks' words offered it and Jev picked a Star Wars helmet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:20:34 +00:00
2ed8781604 Jev picks a Signal's icon, on the server's key, and each Signal counts its items (#263)
Jev chooses among options and cannot name one, so the page searches Font Awesome's index for each
word of the Signal's name, words and sentence, the best six each, and offers the likeliest 40.
POST /api/signals/icon asks a choice question on the server's TYPESAFE_KEY and answers with one
of those offered, never another. "Let Jev pick" in the icon picker, and a Signal made without an
icon gets one a moment after. Tried against TypeSafe: for AI Development, laptop-code 0.45 just
ahead of robot 0.40.

Each Signal's row says "12 items · 3 unread", as a feed's row counts its own, in place of its
words; /api/signals gives `items` beside `unread`, by the same rules as its list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:09:26 +00:00
d0ed953e84 Every Signal's tile in the folder's accent tint, not one by a hash of its name (#262)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:03:40 +00:00
4f1e7e23f6 The Signals folder's + before its count, so the counts line up (#261)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:56:12 +00:00
b18c92d1f5 A Signal's own icon, from all of Font Awesome Free, searchable (#261)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:36:08 +00:00
9e5dcbb8c4 Signals go straight through their queue, not 25 items every 5 minutes (#260)
unjudged() limits rows, one for each item and Signal, to 50, and the pass took the queue for
empty whenever the items numbered fewer than 50. With two Signals every batch is 25 items, so the
re-judge after #259 crawled at 25 items every 5 minutes. A batch is now full by its rows. A pass
TypeSafe stopped as malformed waits for the next wake however full it was, rather than asking again
at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:15:36 +00:00
ce485a82ac Signals judge one subject, with a sentence of your own, not any one word (#259)
Jev was asked "Is this item about any of these: ai, development, programming?", so any one broad
word was enough and the Signal's name, which says what is meant, was never sent. AI Development
held 54 items: Sega pulling a game remaster (0.90), a Surface Laptop review (0.95), a C++ tutorial
(0.99). It is now asked about one subject, called by the Signal's name, described by its words
and, when those are not enough, its owner's sentence. Scored against TypeSafe over those 54 with
"AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.":
5 stay, Linux kernel AI code review highest at 0.94; the remaster 0.06, the C++ tutorial 0.02.

signals.description is new, and its first start deletes every verdict, which answered the old
question, so everything in the window is asked again. Any change to a Signal's name, words or
sentence drops its verdicts, since each is part of the question. The queue query names the
Signal's sentence apart from the item's description, which share a row; the test caught the
item's winning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:12:29 +00:00
f8371f255d docs/signals.md: how Signals work, with TypeSafe's requests and answers and the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:57:05 +00:00
1ae91170fd Signal counts kept current on the page: after a read, and when Jev picks something out (#258)
The page fetched the Signals' unread counts only with the feed list, so reading an item, here or
in its own feed, left its Signal's badge as it was, and so did Jev adding one. The Apple Signal said
2 unread over a page with none. The page now fetches them again after a read or unread lands, and
on a new signal_hits event, which the judge sends once a pass for each person it found items for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:54:42 +00:00
20f53183ac Signals: Jev's request in the shape TypeSafe takes, and its reason kept when it refuses (#257)
Every call was refused with 422: a noul question's `criteria` is an object of what true and false
mean, and it was sent a sentence. The error kept only the status, from error_for_status, so the log
and the trace said 422 and not why; TypeSafe's body said "Input should be a valid dictionary"
at questions.3.noul.criteria. The body is now in the error and on the jev span, with the status.

A request refused as malformed (400, 422) ends the pass, since every other item's would be refused
the same way: the 50 warnings a pass were the same failure 50 times. The state is text now, as
TypeSafe's docs show it, rather than an object that sent null for a feed with no title, and the
questions are keyed signal_<id>. Checked against TypeSafe with the corrected request: 0.96 for an
item about the keywords, 0.02 for one that was not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:48:06 +00:00
8d93b58deb Signals folder under All Subscriptions, with its + on the folder's row (#256)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:39:04 +00:00
0fdd609791 Signals in a folder of their own in the feed list, as an OPML's feeds are (#255)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:14:45 +00:00
13cad18857 Signals' words: what matters out of the noise of your feeds (#254)
The dialog asks what to listen for, the page says what it picked out of the noise of your feeds,
and a new Signal says nothing has come through yet instead of showing an empty list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:58:27 +00:00
70990c70c7 Signals: feeds made from your subscriptions, picked by Jev (#254)
A Signal is a name and keywords. A task beside DeadAir's asks TypeSafe's Jev, on the owner's own
key, about each item of their subscribed feeds from the last 30 days and every new one: one call
an item, one noul question for each of their Signals still to hear about it, 0.5 or more a fit.
Every verdict is kept, fitting or not, in signal_verdicts, so nothing is asked twice, and new
keywords drop a Signal's verdicts to be judged afresh. A key TypeSafe refuses (401/402/403) is
passed over until its owner saves another, so a bad key is not sent the whole queue.

A Signal's items come through entries_where with an EXISTS on its hits, so filters, search, sort
and read state are an ordinary list's, and reading an item in a Signal reads it in its feed.

The page shows Signals under the places, with New signal, and Settings takes the TypeSafe key,
which is never sent back. The browser suite stubs Jev in the fixture server (IPX_TYPESAFE_URL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:54:50 +00:00
30622fd3e1 CLAUDE.md: ipx reaches the directory at http://ipx-directory:8100
Set 2026-10-16 in Admin, Directory, where it was https://directory.ipxreader.app: every Directory
page and snapshot went out through Cloudflare and back down the tunnel, 170-340 ms a call, and a
Directory page took 150-200 ms; 29-54 ms now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:51:00 +00:00
1949f3e493 Compression across the board: directory answers, uploads, metrics, traces, the page (#253)
Measured 2026-10-16, uncompressed until now: the directory's answers (the feed list ipx reads,
603 KB of JSON, ~134 ms of a 159 ms Directory request), ipx's snapshot upload (~100 KB each half
hour), both /metrics (4.7 and 0.9 KB every 15 s) and every trace export to Tempo. Already
compressed: ipx-web's answers (zstd, #239), the home page and the directory's public pages
through Cloudflare (brotli, zstd).

- The directory compresses with brotli, zstd or gzip as asked, and unpacks a gzipped request.
- ipx sends its snapshot gzipped; the directory is deployed first.
- Both /metrics answer gzipped, as Prometheus asks.
- opentelemetry-otlp's gzip-http: OTEL_EXPORTER_OTLP_COMPRESSION=gzip compresses the export, set
  for all three in production's compose file; Caddy's exporter reads the same variable.
- web/build.mjs writes .br (quality 11) and .gz (level 9) beside each text file, and Caddy serves
  them in place (file_server precompressed): app.js 30 KB where zstd on the fly gave 37, app.css
  11 where 14. A .br or .gz asked for by name is refused, or /admin.js.br would be the admin
  page's script without the check that keeps it to admins; in a handle of its own, since a bare
  respond runs after the handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:02:39 +00:00
3d94bce4f5 Directory requests traced whole: the call, its answer's size, the matching (#250)
A Directory page took 983 ms during a scan with 19 ms under spans: the directory's own span ends
when it has answered, and ipx's reading of the 458 KB answer, its subscriptions and catalogue,
and the matching of 1,471 listings had none. call() is a span now, directory_call, with the
answer's size; rows_for has subscriptions, catalogue and match steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:50:30 +00:00
cd7a9b8530 Apple Podcasts' top charts into the directory weekly, and its categories checked (#252)
With apple_country set, the directory fetches Apple's top 100 podcasts and the top 50 of each of
its 19 categories for that country (itunes.apple.com/{cc}/rss/toppodcasts), looks up each
show's RSS address (/lookup, 100 ids a call, three seconds apart, as Apple asks for no more than
about twenty a minute), and stores them as the snapshot apple-podcasts with no subscribers. The
refresher fetches them like any feed; the most-subscribed chart is unchanged. Apple's primary
genre goes in as the snapshot's category, used only while a feed names none.

The same weekly run reads Apple's genre tree (ws/genres?id=26) and warns when it differs from
ipx_feed's CATEGORIES; on 2026-10-15 they were the same, all 19 and their subcategories.

A syncs table keeps when it last ran: at start if a week has passed, again weekly, an hour after
a failure. keep_instances spares apple-podcasts, which no token holds, and no token may take
the name. A trace a sync, a span each answer.

CLAUDE.md: builds one cargo command at a time, the load looked at before and after, after a
build chained with a test compile took Tower past 200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:21:48 +00:00
ca73b8136f Grafana: the connections panel shows in use and ready, not a pool that looks full (#251)
Since #241 the daemon keeps all 20 connections open, so the panel's open line sat on the pool's
maximum and read as exhausted; in use has been at most 4 in a week. It draws in use, ready
(idle) and the pool now, and the prod check reads idle the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:25:50 +00:00
6fde4b73cd Currently Listening and Pinned start from the person's own rows (#249)
coalesce(s.position, 0) > 5 and coalesce(s.flagged, false) hid from Postgres that a match needs
a row of entry_state, so it kept the outer join and scanned every item: Currently Listening read
all 574,000 (74,733 blocks from disk), 970 ms of its 1.3 s. s.position > 5 and s.flagged mean the
same, a missing row being neither, and let it start from the person's own rows: 2.2 ms, and
Pinned 32 ms to 2.3 (EXPLAIN ANALYZE, BUFFERS on production, read only). Unread keeps its
coalesce: no row is unread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:27:25 +00:00
cdf2c98f9f Currently Listening and the Directory open at once, or with a spinner (#248)
Both fetched on every visit and drew only when the answer came: Currently Listening took 1.3 s.
fetchPage keeps their answers by address, as the item lists are kept, sharing a request already
on its way; both draw from what is kept at once and again only if the refresh changed them, and
show the spinner with nothing kept. They are fetched ahead with the lists when the page loads,
and when the pointer or a finger reaches their row. Removing an episode from Currently Listening
forgets its kept copy, which would otherwise show it again until the refresh. The preload loop
takes a failed fetch as done instead of leaving an unhandled rejection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:24:25 +00:00
6640ca9612 Lists open at once from what the page keeps and fetches ahead, or with a spinner (#247)
A click on a feed, All Subscriptions or a filter fetched the list and only then drew it, so
nothing answered the click until the server did. The page now keeps the first page of each list
(the newest 40, by view, filter, sort and search), draws a kept one at once and refreshes it
behind, drawing again only if it changed. It fetches ahead: All Subscriptions and the twenty
feeds with the most unread once the page is idle, two at a time, and a feed or filter when the
pointer or a finger reaches it. A feed_row or a read forgets that feed's lists and All
Subscriptions'; the forgotten are fetched again behind. A list with nothing kept shows a
spinner if its answer takes more than 120 ms.

list_shown (#246) is reported when the list first shows, so a kept one reports its few ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:19 +00:00
62a3241139 Database connections kept open; a click's wait is measured (#241, #245, #246)
#241: on production, All Subscriptions' query runs in 31 ms, unread oldest-first in 30, the
feed list's count in 5, generic plans included (EXPLAIN ANALYZE, BUFFERS, read only), against
0.3-1.6 s in ipx. A new Postgres connection takes 80-330 ms to open, and a page load asks for
more than are idle: four of six slow loads came as the pool grew (3 to 6, 2 to 6, 3 to 5, 6 to
8), and 20 requests at once took a median 414 ms while the pool grew from 8 to 20. The daemon
and the directory's server now keep their whole pool open (min = max); a command such as `ipx
status`, and the tests, which share the server's 100 connections, open as they need.

#246: the page reports list_shown, the ms from asking for a list to drawing it, with the view
and filter, under the click's trace; background refreshes are not timed. Timings skip report()'s
once-a-minute limit, which is for errors in a loop. A dashboard panel and table, and the prod
check, read it.

#245: the load test's browse scenario subscribes by OPML and leaves the Directory, which is
ipx-directory's since #204, to it.

CLAUDE.md records the owner's leave to EXPLAIN (ANALYZE, BUFFERS) any query on production,
read only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:52:14 +00:00
8f19d88fc9 Compressed answers, a directory twice as patient, fewer whole re-reads (#239, #242, #243, #244)
Measured on 2026-10-15: the feed list and item lists went out uncompressed at over 100 KB; ipx
read 2.6 GB of feed bodies a day and the directory 3.9 GB.

- ipx-web compresses with zstd or gzip, leaving out /api/events, which a compressor would hold
  back, and /media (#239).
- A body identical to the last one (SHA-256 in feeds.body_hash) is taken as a 304, unless
  something is missing a read would fill or the check is forced; and a feed without a schedule
  of its own whose body has been the same waits a twelfth of that, up to four hours, beside the
  failure backoff (#242). 35 Patreon show feeds sent ~300 KB each hourly, about 250 MB a day.
- The directory checks each feed every twelve hours, not six (#243).
- reqwest accepts brotli and zstd too (#244).

#240 (cache the feed list's counts) is left open: at production's size on the test Postgres
that count takes 12 ms and All Subscriptions 32 ms, against 366 and 300 ms in production, so
the cause is production's database, not the query (#241).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:54:00 +00:00
dbfe7918ac ipx 1.2.1-dev, ipx-directory 1.1.2-dev, ipx-web 1.0.3-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:33:45 +00:00
c957db7f61 ipx 1.2.0, ipx-directory 1.1.1, ipx-web 1.0.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:25:18 +00:00
4e0b5eed5d Failures and events go out without a private feed's key (#238)
A fetch that failed logged reqwest's words, which name the whole address: Patreon's auth key
went to Loki on 2026-10-15, and, since scan events go to every open page, to everyone's
browser too. #225 had taken queries out of the traces' address fields only.

Every event is redacted where it is emitted, for the log, the live stream and a terminal
alike: address fields lose their query and password, and so does any address in an error's
words (feed::redact_text). The artwork proxy's warnings and the directory's failed checks
the same. The error kept on the feed, shown to its own subscribers, keeps the detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:18:08 +00:00
a9991545f9 Tests: two people on one file, each with their own DeadAir version, place and playlist
The DeadAir tests named for each person's clock used one account. Now three play one file at
once (DeadAir at two pauses, and the original) and each keeps their own place; and two fetch
the same /media/{id}/deadair.m3u8 at once and each gets their own playlist, no-store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:04:47 +00:00
60007d4e6a Page type-check: strict throughout, a typed $, null checks (#236)
$<T> returns the element type it is given and assumes the element is there: 122 lookups take
their type from the tag their id is on in the markup. A handler reads its target through a
cast, since a typed element will not take a handler whose event names a narrower target.
strictNullChecks found no bug the page had; its 94 places were values the code already handles
in ways the type-check could not follow (data- attributes the page writes, a map entry set the
line before, Share drawn only for an item with a link), each now said so.

isPlayable stays a plain boolean: as a type guard its false would have meant "no file", and it
is also false for a file that is not audio or video.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:50:33 +00:00
1471b9b9d0 ipx-web without curl, which CVE-2026-92392 affects in caddy:2's 8.22.0 (#237)
Nothing in the image ran it: Caddy is Go, and there has been no healthcheck since #218.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:45:19 +00:00
ec2b9e87b8 Page type-check: noImplicitAny, every parameter and variable typed (#235)
290 places the type-check could not see into, mostly parameters, now typed with web/src/types.ts
(feeds, items, files, accounts, log lines, the directory's rows and items). Two changes run
differently in form only: the seek and volume inputs divide Number(value) where they divided
the string, and esc's table has a name. Null checks are #236.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:57:05 +00:00
af6eae9baf Page type-check: types for what the API sends, and for S (#235)
web/src/types.ts mirrors FeedRow, EntryRow, EncRow, /api/me and PopularRow; types only, so
swc strips it and it is not in build.mjs's list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:52:22 +00:00
78704e40f2 Page type-check: strict's flags that cost nothing, and catch variables as unknown (#235)
why(e) gives a caught error's message; each catch read e.message as any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:51:41 +00:00
f20660a7d5 Unit tests for the page's own functions (#234)
tests/page-unit.js loads the built index.html script into the stub DOM page-smoke.js uses and
calls its helpers directly with node:test: escaping, times and sizes, file kinds, an untitled
item's name, the directory's sorts, filter words, DeadAir's playlist address. Top-level consts
are not properties of the vm context, so each check evaluates its expression in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:47:15 +00:00
a5e64e520d Refresh buttons turn from the press to the end of their scan; quick checks keep the count (#232, #233)
The buttons followed the feeds being checked, so they stopped between one feed and the next;
7ecbe82 held them for 600 ms over each gap. Now pressing one starts it, and scan_done stops
it only when it is the end of the scan asked for: scan_done carries `only` and `forced`, so a
scheduled scan finishing first does not stop a button whose scan is queued behind it. An
error event, a job that failed without a scan_done, stops them all.

A row's spinner stood in for its unread count from the moment its feed was checked; most
checks take milliseconds, so each count blinked as the scan went by. It now waits 300 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:00:50 +00:00
7ecbe8293f The refresh buttons keep turning between one feed and the next (#231)
A scan checks its feeds one at a time; in the gap between two the body class came off, the
icon snapped back upright and the spin restarted, many times a second with fetches ahead.
The class now comes off only after 600 ms with nothing being checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:49:55 +00:00
8d7a30d1f9 Faster scans and directory rounds, and fetches named by feed (#227, #228, #229, #230)
From the slow traces of 2026-10-13: the directory checked a round's 25 feeds one after
another, so two libsyn feeds timing out at 30 s made a round 70 s; a forced scan looked up
62 site icons in turn, 22 s of 62; mjg59.dreamwidth.org takes the connection and never
answers, so it ran the full 30 s in every scan it was in; and since #225 strips queries,
every YouTube fetch span read the same address, outside any span naming its feed.

The directory now checks five at a time (SQLite stays on one connection, so its writes
queue). A forced scan looks up the icon in the fetch-ahead task, which already runs six
deep, and hands it to scan_one with the body. Both feed clients give up after 15 s of
silence (no successful fetch took over 8 s in a day of traces); FEED_TIMEOUT stays 30 s
for a feed still sending. The directory's client had no connect timeout; it has ipx's 10 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:27:19 +00:00
cece114d98 Grafana: a panel querying Loki and Prometheus both is Mixed, so Downloads works (#226)
Each panel took its first query's data source, Loki for Downloads, whose at-once and torrent
lines are Prometheus's; Grafana runs another data source's query only on a Mixed panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:09:32 +00:00
f00adf5da5 ipx and ipx-directory 1.1.1-dev, ipx-web 1.0.2-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:25:30 +00:00
99 changed files with 9728 additions and 2033 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,8 +26,11 @@ 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`
$Q flame '<TraceQL>' [since] [out.svg] # where the matching spans' time went, summed over up to
# 100 traces: a flame graph, and out.svg.folded for speedscope
```
`since` is `30m`, `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
@@ -63,7 +66,7 @@ lines had no trace id, the access log's time was `ms`, and each event was logged
| `ipx::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures |
| `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving (`-> {...}`) and the healthcheck's answer |
| `ipx` | message, sometimes fields | start-up, shutdown, account and config messages |
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection` and `player_error` as warnings; `play_start` (ms from play to sound) and `stall` (ms waiting mid-play) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection` and `player_error` as warnings; `play_start` (ms from play to sound), `stall` (ms waiting mid-play) and `list_shown` (ms from a click to its list drawn, with `feed` the view and `filter`, #246) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
Events (`ev`): `feed_start`, `feed_done` (new, downloaded, failed, torrents), `feed_skip` (not due,
routine), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address
@@ -109,6 +112,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 +162,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,10 +215,12 @@ 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. Connections in use at the pool's max (20 on Postgres) means something is waiting for
one: find the slow requests or scan of that time in check 3 and 5. A worker queue that only
unset. The daemon keeps all 20 connections open on Postgres (#241), so `idle` is the pool's 20 less
those in use; in use at 20, idle at 0, means something is waiting for one: find the slow requests or scan of that time in check 3 and 5. A worker queue that only
grows means downloads are not keeping up, or the worker is stuck on one job. A runtime queue
above 0 for more than a scrape or two means every thread is busy: something is blocking a
thread or burning one. Memory, open files or tasks only climbing across a day with no restart
@@ -210,16 +233,20 @@ 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.
```
$Q metric 'sum by (ev, msg) (count_over_time({container="iPX"} |= "\"target\":\"ipx::client\"" | json | __error__="" | level="WARN" [$range]))' 7d
$Q logs '{container="iPX"} |= "\"target\":\"ipx::client\"" | json | ev=~"play_start|stall" | line_format "{{.timestamp}} {{.user}} {{.ev}} {{.ms}}ms deadair={{.deadair}} {{.feed}}"' 24h
$Q metric 'quantile_over_time(0.95, {container="iPX"} |= "\"ev\":\"list_shown\"" | json | __error__="" | unwrap ms [$range]) by (feed, filter)' 24h
```
A `player_error` is playback that failed in front of someone: `hls.js: ...` is DeadAir's
playlist (its trace has the playlist and audio requests), `media error 4` a file the browser
cannot play. A `page_error` or `rejection` is a bug in the page; `src` and `line` point into
the minified `app.js`, so match the message to `web/src`. `play_start` over 2000 ms, or a
the minified `app.js`, so match the message to `web/src`. A `list_shown` over 1000 ms is a list someone waited for: its trace says whether the API or the network held it. `play_start` over 2000 ms, or a
`stall`, is worth a trace: was it the server, Cloudflare, or the network.
`docker logs --since 10m iPX 2>&1 | tail -5` shows the newest lines when Loki is behind.
@@ -252,7 +279,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}'
```
@@ -302,11 +329,15 @@ then; a steady stream is worth an issue, since Cloudflare can rate-limit it. A s
`refused_private` is not 0 means an ipx server tried to send a feed that looks private: find which
in that server's log, since the directory refuses it but the sender should not have sent it. Many
`feed check failed` lines for one feed are the publisher's, not the directory's. Each feed is
checked every six hours, 25 at a time, so `feeds checked` over a day should be about four times
the feeds listed; none for over six hours means the refresher is stuck. `could not store a feed
checked every twelve hours (#243), 25 at a time, so `feeds checked` over a day should be about
twice the feeds listed; none for over twelve hours means the refresher is stuck. `could not store a feed
check` or `could not read which feeds are due` is its database (`ipx_directory` on the
`databases` project's Postgres). A config that does not load (`config changed but does not
load`) keeps the old tokens: worth fixing before someone's new token is needed.
load`) keeps the old tokens: worth fixing before someone's new token is needed. Weekly, `Apple
Podcasts synced` (`shows`, `accepted`) brings Apple's charts in as the `apple-podcasts` snapshot
(#252); `Apple Podcasts' sync failed` retries in an hour, worth an issue when it keeps failing, and
`Apple's podcast categories are not ipx's` (`added`, `gone`) is an issue to update `CATEGORIES` in
`crates/feed`.
## Tower
@@ -349,8 +380,9 @@ container after a deploy.
## Rules
`docker logs` and `docker exec iPX ipx ...` are fine. Do not query production's Postgres
directly: ask the user if a question needs the database.
`docker logs` and `docker exec iPX ipx ...` are fine. On production's Postgres, `EXPLAIN (ANALYZE,
BUFFERS)` of any query is allowed, read only, as CLAUDE.md's "Production's database" shows; any
other query, ask the user first.
## What to do with what you find

View File

@@ -4,13 +4,17 @@
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`
query.py flame '<TraceQL query>' [since] [out.svg] where the matching spans' time
went, summed over up to 100 traces, as a
flame graph (and out.svg.folded)
`since` is 1h, 24h, 7d and the like (default 24h). Loki and Tempo publish no query port on the
host, so each call runs a throwaway alpine container on the monitoring project's network.
"""
import json, subprocess, sys, time, urllib.parse
import base64, html, json, subprocess, sys, time, urllib.parse, zlib
NET = "monitoring_default"
@@ -23,11 +27,108 @@ def fetch(url):
return json.loads(out.stdout)
def fetch_many(urls):
# One container for them all: a docker run apiece is about a second, a hundred traces a minute and a half.
out = subprocess.run(["docker", "run", "--rm", "--network", NET, "alpine", "sh", "-c",
'for u; do wget -qO- "$u"; echo; echo @@; done', "--", *urls],
capture_output=True, text=True, timeout=600)
return [json.loads(part) for part in out.stdout.split("\n@@\n") if part.strip()]
def trace_spans(r):
return [sp for b in r.get("batches", r.get("resourceSpans", []))
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
def span_hex(sid):
# A trace's span ids are base64 and the search's are hex.
return sid if len(sid) == 16 and all(c in "0123456789abcdef" for c in sid) else base64.b64decode(sid).hex()
def label(sp):
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])}
return f"step:{attrs['of']}" if sp["name"] == "step" and attrs.get("of") else sp["name"]
def fold(spans, roots):
"""Self time in µs for each stack under the spans in `roots` (hex ids), identical stacks summed.
shortcut: only as fine as the spans; a route's time outside its `step`s is its own frame. Add
steps in ipx where that is large. Children run at once (try_join) can add up past their
parent, so a frame's self time is floored at 0.
"""
kids, stacks = {}, {}
for sp in spans:
kids.setdefault(span_hex(sp.get("parentSpanId") or "0" * 16), []).append(sp)
def walk(sp, path):
path = f"{path};{label(sp)}" if path else label(sp)
dur = lambda s: (int(s["endTimeUnixNano"]) - int(s["startTimeUnixNano"])) // 1000
cs = kids.get(span_hex(sp["spanId"]), [])
stacks[path] = stacks.get(path, 0) + max(0, dur(sp) - sum(dur(c) for c in cs))
for c in cs:
walk(c, path)
for sp in spans:
if span_hex(sp["spanId"]) in roots:
walk(sp, "")
return stacks
def check_fold():
def sp(sid, parent, name, start, end, of=None):
attrs = [{"key": "of", "value": {"stringValue": of}}] if of else []
return {"spanId": sid, "parentSpanId": parent, "name": name, "attributes": attrs,
"startTimeUnixNano": str(start * 1000), "endTimeUnixNano": str(end * 1000)}
a, b, c, d = (f"{i:016x}" for i in range(1, 5))
spans = [sp(a, "", "GET /x", 0, 100), sp(b, a, "step", 0, 60, "q"), sp(c, a, "step", 0, 60, "q"),
sp(d, b, "inner", 0, 10)]
assert fold(spans, {a}) == {"GET /x": 0, "GET /x;step:q": 110, "GET /x;step:q;inner": 10}, fold(spans, {a})
assert fold(spans, {b}) == {"step:q": 50, "step:q;inner": 10}
# base64 ids, as a trace sends them, match the search's hex.
assert span_hex(base64.b64encode(bytes.fromhex(a)).decode()) == a
print("fold: ok")
def flame_svg(stacks, n, title):
tree = {} # name -> [total, children]
for path, us in stacks.items():
node = tree
for part in path.split(";"):
node = node.setdefault(part, [0, {}])
node[0] += us
node = node[1]
total = sum(v[0] for v in tree.values()) or 1
width, row, rects = 1200, 18, []
def draw(node, x, depth, trail):
for name, (us, sub) in sorted(node.items()):
w = us / total * width
stack = f"{trail};{name}" if trail else name
hue = 10 + zlib.crc32(name.encode()) % 45
tip = f"{stack}\n{us / 1000:.0f} ms in all, {100 * us / total:.1f}%, {us / 1000 / n:.1f} ms a request"
text = html.escape(name[: int(w / 7)]) if w > 30 else ""
rects.append(f'<g><title>{html.escape(tip)}</title><rect x="{x:.1f}" y="{30 + depth * row}" '
f'width="{max(w - 0.5, 0.1):.1f}" height="{row - 1}" fill="hsl({hue},85%,62%)"/>'
f'<text x="{x + 3:.1f}" y="{30 + depth * row + 13}">{text}</text></g>')
draw(sub, x, depth + 1, stack)
x += w
draw(tree, 0, 0, "")
depth = max(p.count(";") for p in stacks) + 1 if stacks else 1
head = html.escape(f"{title}: {n} request(s), {total / 1000 / n:.1f} ms each on average")
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{40 + depth * row}" '
f'font-family="monospace" font-size="11"><rect width="100%" height="100%" fill="#fff"/>'
f'<text x="4" y="18" font-size="13">{head}</text>{"".join(rects)}</svg>')
def seconds(since):
return int(since[:-1]) * {"m": 60, "h": 3600, "d": 86400}[since[-1]]
def main():
if sys.argv[1:] == ["flame", "--check"]:
return check_fold()
if len(sys.argv) < 3:
sys.exit(__doc__)
kind, q = sys.argv[1], sys.argv[2]
@@ -58,6 +159,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}")
@@ -66,10 +182,24 @@ def main():
labels = {k: v for k, v in s["metric"].items() if k not in ("job", "instance")}
print(f"{float(s['value'][1]):>14g} {json.dumps(labels) if labels else ''}")
print(f"-- {len(rows)} series", file=sys.stderr)
elif kind == "flame":
out = sys.argv[4] if len(sys.argv) > 4 else "flame.svg"
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=100&spss=20")
found = r.get("traces", [])
roots = {sp["spanID"] for t in found for ss in t.get("spanSets") or [t.get("spanSet") or {}]
for sp in ss.get("spans", [])}
stacks = {}
for tr in fetch_many([f"http://tempo:3200/api/traces/{t['traceID']}" for t in found]):
for path, us in fold(trace_spans(tr), roots).items():
stacks[path] = stacks.get(path, 0) + us
n = len(roots) or 1
with open(out + ".folded", "w") as f:
f.writelines(f"{path} {us}\n" for path, us in sorted(stacks.items()))
with open(out, "w") as f:
f.write(flame_svg(stacks, n, q))
print(f"-- {len(roots)} span(s) in {len(found)} trace(s): {out}, {out}.folded", file=sys.stderr)
elif kind == "trace":
r = fetch(f"http://tempo:3200/api/traces/{q}")
spans = [sp for b in r.get("batches", r.get("resourceSpans", []))
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
spans = trace_spans(fetch(f"http://tempo:3200/api/traces/{q}"))
kids = {}
for sp in spans:
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)

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

@@ -19,7 +19,7 @@ service of the `content` project, beside audiobookshelf, immich and Plex (#208).
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/ipx/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
| Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
| Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
| Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel with no Access in front: its pages are anyone's to browse, read only (#215), and its API (`/v1`) takes a token for everything. Until 2026-10-12 it was `ipx-dir.sdf1.net` (#214). Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` |
| Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel with no Access in front: its pages are anyone's to browse, read only (#215), and its API (`/v1`) takes a token for everything. Until 2026-10-12 it was `ipx-dir.sdf1.net` (#214). Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`, with the address ipx reaches it at: `http://ipx-directory:8100`, inside Docker, not the public address, which went out through Cloudflare and the tunnel, 170-340 ms a call where the directory answers in 16 ms), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` |
| Home page | `ipxreader.app` (and `www`): a static page from `site/`, on the Cloudflare Pages project `ipxreader`, not on Tower. See below | |
| Web UI | `ipx-web` (Caddy) in the same project, at `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel. It serves the page and passes `/api` and `/media` to ipx, whose own port is published nowhere; its address on `ipx_default` is pinned to `192.168.64.10` (#203) | ipx `0.0.0.0:8099`, Caddy `:8080` |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | Caddy passes `Cf-Access-*` only from `192.168.64.1`, the `ipx_default` gateway cloudflared arrives from (`TUNNEL_PEER`; the subnet is pinned in the compose file); ipx trusts them only from Caddy, `192.168.64.10`, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
@@ -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.
@@ -151,13 +156,19 @@ After any edit to it:
```sh
npx tsc -p .
node tests/page-smoke.js
node --test tests/page-unit.js
```
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second
The first type-checks `web/src` in TypeScript's `strict` mode: every parameter and variable has a
type, what the API sends is typed in `web/src/types.ts`, which mirrors the Rust structs and changes
with them, and a value that may be null has to be checked before use. `$<T>` returns the element
type it is given (`$<HTMLInputElement>('#nurl')`) and assumes the element is there; one that may
not be is written `$(...)?.`, and the page check below proves the ones wired at load exist. The second
builds the page as shipped and runs its script against a stub DOM, checking every selector it
wires at load actually exists. That check exists because a patch once anchored on a deleted
function, `String.replace` silently matched nothing, and the whole UI died with a
`ReferenceError` while every server-side test passed.
`ReferenceError` while every server-side test passed. The third loads the same built script and
calls its helpers directly (#234): a pure function added to the page gets its check there.
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
@@ -165,15 +176,57 @@ 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
```
**Builds share Tower with everything else, production included.** On 2026-10-16 a build of the
directory followed in the same command by its tests' compile took Tower's load past 200 and the
session was stopped. So:
* One cargo command at a time: a build, then the tests, as separate commands, never chained or
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.
* 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:
* It starts **its own daemon and database** under `/tmp/ipx-ui-test`, wiped once per run. Playwright
@@ -215,6 +268,25 @@ seeking through files, a flood of wrong passwords. Things about them:
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
`matches_keywords`, `parse_interval`) are the easiest place to put it.
## Production's database
The owner allows `EXPLAIN (ANALYZE, BUFFERS)` of any query against production's Postgres, to see
where a query's time goes; nothing else without asking. Run it read only, so nothing can change:
```sh
U=$(grep -E '^IPX_DATABASE_URL=' /mnt/fast/arcane/projects/ipx/ipx.env | cut -d= -f2- | tr -d '"')
psql "$U" -X -v ON_ERROR_STOP=1 <<'SQL'
BEGIN READ ONLY;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
ROLLBACK;
SQL
```
Never print the URL: it carries the password. Write the query as ipx sends it (`$1` parameters
become `PREPARE`/`EXECUTE`, or values), for a real account. A query fast here and slow in ipx's
trace is time outside the query: on 2026-10-15 the feed list's count took 5 ms in Postgres and 366
ms in ipx, the difference being new connections, 80-330 ms each to open (#241).
## Things that are true and easy to get wrong
* **`enclosures.url` is globally UNIQUE.** It is the dedupe key and the reason one file serves every
@@ -324,6 +396,10 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
issue left open.
## Styleguide
* Best effort to adhere to the rust style guide found at /src/styleguide/rustguide.md
<!-- rtk-instructions v2 -->
# Command output

167
Cargo.lock generated
View File

@@ -17,6 +17,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -25,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",
@@ -445,6 +460,27 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "brotli"
version = "8.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bs58"
version = "0.5.1"
@@ -620,9 +656,12 @@ version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
"zstd",
"zstd-safe",
]
[[package]]
@@ -750,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",
@@ -761,9 +800,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.2"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
dependencies = [
"cmov",
]
@@ -1398,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",
@@ -1516,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]]
@@ -1820,15 +1860,15 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "ipx"
version = "1.1.0"
version = "1.5.0"
dependencies = [
"ammonia",
"anyhow",
"argon2",
"atom_syndication",
"axum",
"chrono",
"clap",
"flate2",
"futures-util",
"ipx-directory",
"ipx-feed",
@@ -1837,9 +1877,7 @@ dependencies = [
"opentelemetry",
"opml",
"percent-encoding",
"quick-xml 0.42.0",
"reqwest",
"rss",
"sea-orm",
"serde",
"serde_json",
@@ -1856,12 +1894,14 @@ dependencies = [
[[package]]
name = "ipx-directory"
version = "1.1.0"
version = "1.4.0"
dependencies = [
"ammonia",
"anyhow",
"axum",
"clap",
"flate2",
"futures-util",
"ipx-feed",
"mimalloc",
"opentelemetry",
@@ -1876,6 +1916,7 @@ dependencies = [
"tokio",
"toml",
"tower",
"tower-http 0.7.1",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
@@ -1889,12 +1930,16 @@ dependencies = [
"anyhow",
"atom_syndication",
"chrono",
"http",
"hyper-util",
"opml",
"pulldown-cmark",
"quick-xml 0.42.0",
"reqwest",
"rss",
"serde_json",
"tokio",
"tower",
"tracing",
"url",
]
@@ -2483,9 +2528,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",
@@ -2810,6 +2855,7 @@ version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
dependencies = [
"flate2",
"http",
"httpdate",
"opentelemetry",
@@ -2941,9 +2987,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",
@@ -2951,9 +2997,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",
@@ -2961,9 +3007,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",
@@ -2971,9 +3017,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",
]
@@ -3086,6 +3132,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"
@@ -3500,9 +3564,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",
@@ -3564,9 +3628,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"aws-lc-rs",
"ring",
@@ -4235,9 +4299,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",
@@ -4247,9 +4311,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",
@@ -4650,6 +4714,7 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
dependencies = [
"async-compression",
"bitflags 2.13.1",
"bytes",
"futures-core",
@@ -5025,9 +5090,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",
@@ -5046,9 +5111,9 @@ dependencies = [
[[package]]
name = "webpki-roots"
version = "1.0.9"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [
"rustls-pki-types",
]
@@ -5457,3 +5522,31 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zstd"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.1.1+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078"
dependencies = [
"cc",
"pkg-config",
]

View File

@@ -15,19 +15,23 @@ anyhow = "1.0.104"
argon2 = "0.6.0"
atom_syndication = "0.12.10"
axum = "0.8.9"
flate2 = "1.1.10"
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
clap = { version = "4.6.6", features = ["derive"] }
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
opentelemetry = { version = "0.33", default-features = false, features = ["trace"] }
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client"] }
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"
http = "1.5.0"
hyper-util = { version = "0.1.20", features = ["client-legacy"] }
percent-encoding = "2.3.2"
quick-xml = { version = "0.42.0", features = ["escape-html"] }
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "brotli", "zstd", "stream", "json", "charset", "system-proxy"] }
rss = "2.1.1"
sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "sqlite-use-returning-for-3_35"] }
serde = { version = "1.0.229", features = ["derive"] }
@@ -36,8 +40,17 @@ sha2 = "0.10.9"
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal", "process"] }
toml = "1.1.5"
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.7.1", features = ["fs"] }
tower-http = { version = "0.7.1", features = ["fs", "compression-br", "compression-gzip", "compression-zstd", "decompression-gzip"] }
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

@@ -8,6 +8,9 @@ COPY web ./web
RUN node web/build.mjs /dist
FROM caddy:2
# caddy:2's Alpine ships curl, which nothing here runs (no healthcheck since #218): out, so a
# curl flaw such as CVE-2026-92392 has nothing to reach in a container facing the internet (#237).
RUN apk del --no-cache curl
COPY web/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /dist /srv
EXPOSE 8080

View File

@@ -47,7 +47,9 @@ cargo build --release
./target/release/ipx daemon
```
The first start creates **admin / ipodderx**. Sign in at `/login`, then change it:
The first start creates **admin** with a random password, written to `admin-password` beside
config.toml (readable by ipx's user alone; the log says where, never what it is). Set
`IPX_ADMIN_PASSWORD` to choose it instead. Sign in at `/login`, then change it:
```sh
echo -n 'a good password' | ipx user passwd admin

View File

@@ -10,6 +10,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.4.0] - 2026-10-23
### Fixed
- A WordPress.com site that answers its feed with a browser check (HTTP 403) is read from WordPress.com's API instead of failing (#317).
### Security
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
- The public pages read the list from the database at most once a minute, however many people ask. Each page asked for the whole catalogue, so a flood of requests with query strings of their own reached the database every time (#341).
- The directory no longer fetches feeds at internal addresses (loopback, the LAN, Docker's networks), named in a snapshot or reached by a redirect, unless directory.toml's `fetch_internal` lists them. It fetched them, and stored what answered as the feed's title and description (#329).
- A server's count of a feed's subscribers is taken up to a million. Two servers sending the largest number there is made the sum overflow, and every listing and page failed until one was dropped (#342).
## [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
- Answers and pages go out compressed, brotli, zstd or gzip as the caller takes them: the feed list ipx asks for was 603 KB of JSON. A snapshot may come gzipped, `/metrics` answers gzipped, and with `OTEL_EXPORTER_OTLP_COMPRESSION=gzip` traces go to the collector compressed (#253).
- With `apple_country` set, Apple Podcasts' top charts for that country (the top 100 and the top 50 of each category) come into the directory every week, beside what servers share, without counting as subscribers; and a warning says when Apple's categories no longer match ipx's (#252).
### Changed
- Each feed is checked every twelve hours instead of six (#243).
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
- On Postgres the server keeps all ten of its database connections open, so a request does not wait for a new one (#241).
## [1.1.1] - 2026-10-15
### Changed
- The directory checks five feeds at a time, so a publisher that does not answer no longer holds up the rest of its round (#227).
- A feed's server that takes 10 s to connect, or sends nothing for 15 s, is given up on (#230).
### Security
- A failed feed check is logged without its address's query or password (#238).
## [1.1.0] - 2026-10-13
### Added
@@ -34,6 +85,10 @@ 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.1.0...main
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.4.0...main
[1.4.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.3.0...directory-v1.4.0
[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
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0

View File

@@ -1,7 +1,8 @@
[package]
name = "ipx-directory"
version = "1.1.0"
version = "1.4.0"
edition.workspace = true
publish = false
[[bin]]
name = "ipx-directory"
@@ -12,6 +13,7 @@ ammonia.workspace = true
anyhow.workspace = true
axum.workspace = true
clap.workspace = true
futures-util.workspace = true
ipx-feed.workspace = true
opentelemetry.workspace = true
opentelemetry-otlp.workspace = true
@@ -25,10 +27,12 @@ serde_json.workspace = true
sha2.workspace = true
tokio.workspace = true
toml.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-opentelemetry.workspace = true
tracing-subscriber.workspace = true
url.workspace = true
[dev-dependencies]
flate2.workspace = true
tower.workspace = true

View File

@@ -0,0 +1,281 @@
//! Apple Podcasts, weekly (#252): its top charts for a country into the directory, beside what
//! ipx servers share, and its categories checked against the copy ipx keeps (`ipx_feed`).
//!
//! The charts are a snapshot of their own, under `INSTANCE`, with no subscribers: Apple's shows
//! are fetched, categorised and listed like any other feed, and the most subscribed stay what this
//! directory's people subscribe to. Each show's RSS address comes from Apple's lookup API.
use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Context, Result};
use serde::Deserialize;
use tracing::Instrument;
use crate::{Shared, SnapshotEntry};
/// What the charts are filed under, as a server's snapshot is under its token's name.
pub const INSTANCE: &str = "apple-podcasts";
/// How often, and how long to wait after a sync fails before trying again.
const EVERY: i64 = 7 * 86_400;
const RETRY: Duration = Duration::from_secs(3600);
/// The top this many overall, and of each category: about a thousand shows once they overlap.
const TOP: u32 = 100;
const TOP_OF_EACH: u32 = 50;
/// Apple asks for no more than about twenty calls a minute.
const PAUSE: Duration = Duration::from_secs(3);
/// Syncs when a week has passed since the last, forever.
pub async fn run(dir: Shared, country: String) {
let client = match reqwest::Client::builder()
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.build()
{
Ok(c) => c,
Err(e) => return tracing::error!(error = %e, "Apple Podcasts' sync could not start"),
};
loop {
let last = dir.store.synced_at(INSTANCE).await.unwrap_or(None).unwrap_or(0);
let wait = last + EVERY - crate::store::now();
if wait > 0 {
// Woken hourly at most, so a database restored from before the last sync is noticed.
tokio::time::sleep(Duration::from_secs((wait as u64).min(3600))).await;
continue;
}
let span = tracing::info_span!("apple_sync", country = %country);
match sync(&dir, &client, &country).instrument(span).await {
Ok(()) => {
let _ = dir.store.set_synced(INSTANCE, crate::store::now()).await;
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "Apple Podcasts' sync failed; trying again in an hour");
tokio::time::sleep(RETRY).await;
}
}
}
}
async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<()> {
let genres: Genres = get(client, "https://itunes.apple.com/WebObjects/MZStoreServices.woa/ws/genres?id=26").await?;
let theirs = categories(&genres);
let (added, gone) = differ(&theirs, ipx_feed::categories());
if !added.is_empty() || !gone.is_empty() {
tracing::warn!(
added = %added.join(", "), gone = %gone.join(", "),
"Apple's podcast categories are not ipx's: the copy in crates/feed (CATEGORIES) wants updating"
);
}
// The top overall, then the top of each category, each show once.
let mut ids: Vec<String> = Vec::new();
let charts = std::iter::once((None, TOP)).chain(genre_ids(&genres).into_iter().map(|g| (Some(g), TOP_OF_EACH)));
for (genre, n) in charts {
let url = match &genre {
Some(g) => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/genre={g}/json"),
None => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/json"),
};
match get::<Chart>(client, &url).await {
Ok(chart) => ids.extend(chart_ids(&chart)),
// One category's chart missing is no reason to keep last week's whole list.
Err(e) => tracing::info!(genre = ?genre, error = %format!("{e:#}"), "an Apple chart did not load"),
}
tokio::time::sleep(PAUSE).await;
}
let mut seen = std::collections::HashSet::new();
ids.retain(|id| seen.insert(id.clone()));
anyhow::ensure!(!ids.is_empty(), "no chart had a show in it");
let mut entries = Vec::new();
for batch in ids.chunks(100) {
let url = format!("https://itunes.apple.com/lookup?entity=podcast&id={}", batch.join(","));
let found: Lookup = get(client, &url).await?;
entries.extend(lookup_entries(&found));
tokio::time::sleep(PAUSE).await;
}
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,
"Apple Podcasts synced"
);
// Fetched now, not when the refresher next looks.
dir.wake.notify_one();
Ok(())
}
async fn get<T: serde::de::DeserializeOwned>(client: &reqwest::Client, url: &str) -> Result<T> {
let span = tracing::info_span!("apple_get", url = %url);
async {
let resp = client.get(url).send().await.context("asking Apple")?.error_for_status()?;
resp.json::<T>().await.context("reading Apple's answer")
}
.instrument(span)
.await
}
/// Apple's genres under Podcasts (26): each category's id and name, and its subcategories.
#[derive(Deserialize)]
struct Genres(BTreeMap<String, Genre>);
#[derive(Deserialize)]
struct Genre {
name: String,
#[serde(default)]
subgenres: BTreeMap<String, 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()
})
.unwrap_or_default()
}
fn genre_ids(g: &Genres) -> Vec<String> {
g.0.get("26").map(|p| p.subgenres.keys().cloned().collect()).unwrap_or_default()
}
/// What Apple has that ipx's list lacks, and what ipx's has that Apple no longer does, as
/// "Category" or "Category / Subcategory".
fn differ(theirs: &BTreeMap<String, Vec<String>>, ours: &[(&str, &[&str])]) -> (Vec<String>, Vec<String>) {
let flat = |c: &str, subs: &mut dyn Iterator<Item = String>| {
std::iter::once(c.to_string()).chain(subs.map(|s| format!("{c} / {s}"))).collect::<Vec<_>>()
};
let theirs: std::collections::BTreeSet<String> =
theirs.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().cloned())).collect();
let ours: std::collections::BTreeSet<String> =
ours.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().map(|s| s.to_string()))).collect();
(theirs.difference(&ours).cloned().collect(), ours.difference(&theirs).cloned().collect())
}
/// A chart, as Apple's iTunes RSS gives it: the shows' Apple ids, in order.
#[derive(Deserialize)]
struct Chart {
feed: ChartFeed,
}
#[derive(Deserialize)]
struct ChartFeed {
// A chart of one show is that show, not a list of one.
#[serde(default, deserialize_with = "one_or_many")]
entry: Vec<ChartEntry>,
}
#[derive(Deserialize)]
struct ChartEntry {
id: ChartId,
}
#[derive(Deserialize)]
struct ChartId {
attributes: ChartIdAttributes,
}
#[derive(Deserialize)]
struct ChartIdAttributes {
#[serde(rename = "im:id")]
id: String,
}
fn one_or_many<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<ChartEntry>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(ChartEntry),
Many(Vec<ChartEntry>),
}
Ok(match OneOrMany::deserialize(d)? {
OneOrMany::One(e) => vec![e],
OneOrMany::Many(v) => v,
})
}
fn chart_ids(c: &Chart) -> Vec<String> {
c.feed.entry.iter().map(|e| e.id.attributes.id.clone()).collect()
}
/// Apple's lookup answer: each show's RSS address and the category Apple files it under.
#[derive(Deserialize)]
struct Lookup {
#[serde(default)]
results: Vec<LookupResult>,
}
#[derive(Deserialize)]
struct LookupResult {
#[serde(rename = "feedUrl")]
feed_url: Option<String>,
#[serde(rename = "primaryGenreName")]
genre: Option<String>,
}
fn lookup_entries(l: &Lookup) -> Vec<SnapshotEntry> {
l.results
.iter()
.filter_map(|r| {
let url = r.feed_url.as_deref()?.trim();
(!url.is_empty()).then(|| SnapshotEntry {
url: url.to_owned(),
subscribers: 0,
// "Podcasts" is the root Apple files a show under when it gives no category.
category: r.genre.clone().filter(|g| g != "Podcasts"),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apples_categories_are_read_and_compared_with_ipxs() {
let genres: Genres = serde_json::from_str(
r#"{"26": {"name": "Podcasts", "subgenres": {
"1301": {"name": "Arts", "subgenres": {"1482": {"name": "Books"}, "1999": {"name": "Murals"}}},
"1310": {"name": "Music"}}}}"#,
)
.unwrap();
assert_eq!(genre_ids(&genres), ["1301", "1310"]);
let ours: &[(&str, &[&str])] = &[("Arts", &["Books", "Design"]), ("Music", &[]), ("Hobbies", &[])];
let (added, gone) = differ(&categories(&genres), ours);
assert_eq!(added, ["Arts / Murals"]);
assert_eq!(gone, ["Arts / Design", "Hobbies"]);
// ipx's own copy is Apple's whole list, each category once.
let mine = ipx_feed::categories();
assert_eq!(mine.len(), 19);
assert!(mine.iter().any(|(c, subs)| *c == "News" && subs.contains(&"Daily News")));
}
#[test]
fn a_chart_gives_its_shows_in_order_and_a_lookup_their_addresses() {
let chart: Chart = serde_json::from_str(
r#"{"feed": {"entry": [
{"id": {"label": "https://x", "attributes": {"im:id": "1200361736"}}},
{"id": {"label": "https://y", "attributes": {"im:id": "1028908750"}}}]}}"#,
)
.unwrap();
assert_eq!(chart_ids(&chart), ["1200361736", "1028908750"]);
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());
let found: Lookup = serde_json::from_str(
r#"{"resultCount": 3, "results": [
{"trackName": "The Daily", "feedUrl": "https://feeds.simplecast.com/Sl5CSM3S", "primaryGenreName": "Daily News"},
{"trackName": "No feed"},
{"trackName": "Rooted", "feedUrl": "https://example.com/rss", "primaryGenreName": "Podcasts"}]}"#,
)
.unwrap();
let entries = lookup_entries(&found);
assert_eq!(entries.len(), 2, "a show Apple has no feed for is left out");
assert_eq!((entries[0].subscribers, entries[0].category.as_deref()), (0, Some("Daily News")));
assert_eq!(entries[1].category, None, "Podcasts is no category");
}
}

View File

@@ -18,6 +18,15 @@ pub struct Config {
/// publishes: `0.0.0.0:9188`. Empty serves none.
#[serde(default)]
pub metrics_bind: String,
/// Apple Podcasts' top charts for this country (its two letters: `ca`, `us`) come into the
/// directory each week, and Apple's categories are checked against ipx's (#252). Empty, or
/// left out, does neither.
#[serde(default)]
pub apple_country: String,
/// Internal addresses and networks the refresher may fetch feeds from all the same, as ipx's
/// `fetch_internal` (#329). Empty, the default, fetches nothing off the public internet.
#[serde(default)]
pub fetch_internal: Vec<String>,
#[serde(default)]
pub tokens: Vec<TokenCfg>,
}
@@ -81,6 +90,14 @@ impl Config {
if !seen.insert(t.name.as_str()) {
bail!("two tokens are named {:?}", t.name);
}
// Apple's charts are filed under it; a server of that name would replace them.
if t.name == crate::apple::INSTANCE {
bail!("{:?} is the name Apple Podcasts' charts are kept under; give the token another", t.name);
}
}
let c = cfg.apple_country.trim();
if !c.is_empty() && !(c.len() == 2 && c.chars().all(|c| c.is_ascii_alphabetic())) {
bail!("apple_country is a country's two letters, such as \"ca\" or \"us\", not {c:?}");
}
Ok(cfg)
}
@@ -149,7 +166,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);
}
@@ -158,7 +178,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

@@ -7,6 +7,7 @@
//! The types here are the wire format, shared with ipx, which asks the directory on its
//! people's behalf.
pub mod apple;
pub mod config;
pub mod metrics;
pub mod otel;
@@ -117,8 +118,8 @@ pub struct Blocked {
pub const MAX_SNAPSHOT: usize = 20_000;
/// How often each feed is fetched again. Metadata changes slowly, and every server subscribed
/// to a feed fetches it itself anyway.
pub const REFRESH_EVERY: i64 = 6 * 3600;
/// to a feed fetches it itself anyway: every six hours was 3.9 GB of feeds a day (#243).
pub const REFRESH_EVERY: i64 = 12 * 3600;
pub struct Directory {
pub store: Store,
@@ -126,18 +127,54 @@ pub struct Directory {
client: reqwest::Client,
/// Wakes the refresher, so a feed a snapshot brings in is fetched now, not in a minute.
wake: tokio::sync::Notify,
/// The public pages' copy of the list, and when it was read.
pages_list: tokio::sync::Mutex<Option<(Instant, Arc<Vec<Listing>>)>>,
}
/// How long the public pages show the same copy of the list.
const PAGES_LIST_FOR: Duration = Duration::from_secs(60);
pub type Shared = Arc<Directory>;
impl Directory {
pub fn new(store: Store, tokens: Vec<Token>) -> Result<Shared, reqwest::Error> {
pub fn new(
store: Store,
tokens: Vec<Token>,
fetch_internal: ipx_feed::guard::Allowed,
) -> Result<Shared, reqwest::Error> {
// No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.read_timeout(ipx_feed::FEED_SILENCE)
.redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
// The feeds are named by whoever holds a write token, and redirect where their
// publishers like: nothing on this network is fetched unless listed (#329).
.connector_layer(ipx_feed::guard::Guard::new(fetch_internal))
.build()?;
Ok(Arc::new(Self { store, tokens: RwLock::new(tokens), client, wake: tokio::sync::Notify::new() }))
Ok(Arc::new(Self {
store,
tokens: RwLock::new(tokens),
client,
wake: tokio::sync::Notify::new(),
pages_list: Default::default(),
}))
}
/// The whole list for the public pages, read from the database at most once a minute. Anyone
/// may ask for a page, and each asked the database for the whole catalogue on the pool the
/// refresher and every server's /v1 calls share; a query string of its own got past any
/// cache in front (#341). The lock is held through the read, so a burst waits on one query.
pub async fn pages_list(&self) -> anyhow::Result<Arc<Vec<Listing>>> {
let mut held = self.pages_list.lock().await;
if let Some((at, list)) = held.as_ref()
&& at.elapsed() < PAGES_LIST_FOR
{
return Ok(list.clone());
}
let list = Arc::new(self.store.list().await?);
*held = Some((Instant::now(), list.clone()));
Ok(list)
}
/// Takes the tokens of a config file read again, and drops the snapshots of any that went.
@@ -150,16 +187,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) {
@@ -175,7 +212,7 @@ impl Directory {
Err(e) => Refreshed::Failed(format!("{e:#}")),
};
if let Refreshed::Failed(e) = &got {
tracing::info!(feed = %due.id, error = %e, "feed check failed");
tracing::info!(feed = %due.id, error = %ipx_feed::redact_text(e), "feed check failed");
}
// ponytail: a feed that moved for good is still asked for at its old address, which
// redirects; follow the move when a feed's id can change without losing its counts.
@@ -193,12 +230,17 @@ pub async fn refresher(dir: Shared) {
let round = tracing::info_span!("refresh", feeds = due.len());
tracing::Instrument::instrument(
async {
for d in &due {
// A few at a time: in turn, two publishers timing out held a round
// for a minute and more (#227).
futures_util::StreamExt::for_each_concurrent(futures_util::stream::iter(&due), 5, |d| {
let check = tracing::info_span!("feed", feed = %d.id);
async {
if let Err(e) = tracing::Instrument::instrument(dir.refresh(d), check).await {
tracing::warn!(feed = %d.id, error = %format!("{e:#}"), "could not store a feed check");
}
}
})
.await;
// One line a round, for the dashboard's refresher panel (#210); failures have their own.
tracing::info!(checked = due.len(), "feeds checked");
},
@@ -234,10 +276,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")
}
}
}
}
@@ -260,6 +306,12 @@ pub fn router(dir: Shared) -> Router {
.merge(v1)
// Anyone may browse, no token, read only (#215).
.merge(site::router())
// Brotli, zstd or gzip as the caller takes them: the feed list went to ipx as 603 KB of
// JSON, most of a Directory page's time (#253). Inside the access log, which records what
// was sent.
.layer(tower_http::compression::CompressionLayer::new().br(true).zstd(true).gzip(true))
// And takes a request's body gzipped: ipx sends its snapshot so, about 100 KB as JSON.
.layer(tower_http::decompression::RequestDecompressionLayer::new().gzip(true))
.layer(axum::middleware::from_fn(access_log))
.with_state(dir)
}
@@ -328,7 +380,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);
@@ -373,7 +426,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(
@@ -394,7 +451,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");
@@ -406,7 +467,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")));
@@ -420,16 +485,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
}
@@ -449,11 +526,17 @@ mod tests {
sha256: config::hash(&format!("secret-{n}")),
})
.collect();
let dir = Directory::new(Store::scratch().await, tokens).unwrap();
let dir = Directory::new(Store::scratch().await, tokens, Default::default()).unwrap();
(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}"));
@@ -469,6 +552,63 @@ mod tests {
(status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
}
#[tokio::test]
async fn a_snapshot_may_come_gzipped() {
use std::io::Write as _;
let (app, _) = app().await;
let json = serde_json::json!([{"url": "https://example.com/feed.xml", "subscribers": 2}]).to_string();
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(json.as_bytes()).unwrap();
// As ipx sends it (#253).
let req = axum::http::Request::builder()
.method("POST")
.uri("/v1/snapshot")
.header("authorization", "Bearer secret-rw")
.header("content-type", "application/json")
.header("content-encoding", "gzip")
.body(Body::from(gz.finish().unwrap()))
.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();
assert_eq!(got["accepted"], 1);
}
#[tokio::test]
async fn the_refresher_fetches_nothing_on_this_network() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { while listener.accept().await.is_ok() {} });
let (_, dir) = app().await;
let err = dir.client.get(format!("http://{addr}/feed.xml")).send().await.unwrap_err();
assert!(format!("{err:?}").contains("internal address"), "{err:?}");
}
#[tokio::test]
async fn the_public_pages_read_the_list_once_a_minute() {
let (_, dir) = app().await;
let first = dir.pages_list().await.unwrap();
assert!(Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()), "a second page asks the database nothing");
// Once the copy is a minute old, the next page reads the list again.
dir.pages_list.lock().await.as_mut().unwrap().0 -= PAGES_LIST_FOR;
assert!(!Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()));
}
#[tokio::test]
async fn answers_come_compressed_as_the_caller_asks() {
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 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());
assert_eq!(got.as_deref(), sent, "asked for {asked} (#253)");
}
}
async fn get_page(app: &Router, path: &str) -> (u16, String, Option<String>) {
let req = axum::http::Request::builder().uri(path).body(Body::empty()).unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
@@ -530,7 +670,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);
@@ -545,7 +689,7 @@ mod tests {
#[tokio::test]
async fn a_revoked_token_loses_its_access_and_its_counts() {
let dir = Directory::new(Store::scratch().await, vec![]).unwrap();
let dir = Directory::new(Store::scratch().await, vec![], Default::default()).unwrap();
let t = |n: &str| Token { name: n.into(), access: Access::Rw, sha256: config::hash(&format!("secret-{n}")) };
dir.set_tokens(vec![t("a"), t("b")]).await.unwrap();
let app = router(dir.clone());

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();
@@ -92,22 +101,31 @@ async fn serve(path: &std::path::Path) -> Result<()> {
.ok()
.filter(|u| !u.trim().is_empty())
.with_context(|| format!("{} is not set: the directory needs a database URL", cfg.database_url_env))?;
let store = Store::open(&url).await.context("opening the database")?;
let dir = Directory::new(store, vec![])?;
let store = Store::open(&url, true).await.context("opening the database")?;
let dir = Directory::new(store, vec![], ipx_feed::guard::Allowed::parse(&cfg.fetch_internal)?)?;
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));
}
tokio::spawn(ipx_directory::refresher(dir.clone()));
let country = cfg.apple_country.trim().to_ascii_lowercase();
if !country.is_empty() {
tracing::info!(country = %country, "Apple Podcasts' charts are synced weekly");
tokio::spawn(ipx_directory::apple::run(dir.clone(), country));
}
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,12 +71,15 @@ 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))
}),
);
)
// Prometheus asks for gzip (#253).
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
if let Err(e) = axum::serve(listener, app).await {
tracing::error!(error = %e, "metrics stopped");
}
@@ -62,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};
@@ -58,15 +58,17 @@ impl Filter {
}
async fn home(State(dir): State<Shared>) -> Response {
let all = match dir.store.list().await {
let all = match dir.pages_list().await {
Ok(a) => a,
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())));
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.truncate(10);
let mut cats: BTreeMap<&str, Vec<&Listing>> = BTreeMap::new();
for l in &all {
for l in all.iter() {
cats.entry(l.category.as_deref().unwrap_or("Other")).or_default().push(l);
}
let mut b = String::new();
@@ -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());
}
@@ -108,14 +123,15 @@ async fn category(State(dir): State<Shared>, Path(c): Path<String>, Query(f): Qu
/// A page of tiles: those `pick` takes, of the kind and subcategory asked for, A to Z.
async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn(&Listing) -> bool) -> Response {
let mut ls: Vec<Listing> = match dir.store.list().await {
Ok(a) => a.into_iter().filter(|l| pick(l)).collect(),
let mut ls: Vec<Listing> = match dir.pages_list().await {
Ok(a) => a.iter().filter(|l| pick(l)).cloned().collect(),
Err(e) => return failed(e),
};
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

@@ -13,6 +13,9 @@ pub const ITEMS: usize = 20;
/// How long a feed's failure goes unexplained: a site down for an hour is not called gone (ipx#143).
const FLAG_AFTER_SECS: i64 = 24 * 3600;
/// The most subscribers one instance may say a feed has.
pub const MAX_SUBSCRIBERS: i64 = 1_000_000;
pub struct Store {
db: DatabaseConnection,
}
@@ -28,11 +31,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),
}
@@ -84,6 +83,11 @@ const SCHEMA: &[&str] = &[
PRIMARY KEY (instance, feed_id)
)",
"CREATE INDEX IF NOT EXISTS snapshots_feed ON snapshots (feed_id)",
// When each periodic job last finished: Apple's weekly sync (#252).
"CREATE TABLE IF NOT EXISTS syncs (
name TEXT PRIMARY KEY,
at BIGINT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS blocked (
id TEXT PRIMARY KEY,
url TEXT,
@@ -99,7 +103,7 @@ impl Store {
pub async fn scratch() -> Self {
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let Some(url) = std::env::var("IPX_TEST_DATABASE_URL").ok().filter(|u| !u.is_empty()) else {
return Self::open("sqlite::memory:").await.unwrap();
return Self::open("sqlite::memory:", false).await.unwrap();
};
let (pid, n) = (std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
// A bare connection: Store::open would make the tables in the public schema, and the
@@ -117,10 +121,16 @@ 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")).await.unwrap()
Self::open(
&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"),
false,
)
.await
.unwrap()
}
pub async fn open(url: &str) -> Result<Self> {
/// `keep_open` for the server, which keeps its whole pool connected.
pub async fn open(url: &str, keep_open: bool) -> Result<Self> {
let mut url = url.to_owned();
if url.starts_with("postgres") && !url.contains("options=") {
// Postgres sends a notice for every CREATE ... IF NOT EXISTS on something that exists;
@@ -134,6 +144,11 @@ impl Store {
if url.contains(":memory:") {
opts.max_connections(1);
}
// The server keeps every connection open, as ipx's daemon does: a Postgres connection takes
// 80-330 ms to open (#241). Not a test, which would hold ten for one question.
if keep_open && url.starts_with("postgres") {
opts.max_connections(10).min_connections(10);
}
let db = sea_orm::Database::connect(opts).await?;
for sql in SCHEMA {
db.execute_unprepared(sql).await?;
@@ -158,6 +173,23 @@ impl Store {
Ok(self.db.query_all_raw(self.stmt(sql, values)).await?)
}
/// When the job `name` last finished, Unix seconds.
pub async fn synced_at(&self, name: &str) -> Result<Option<i64>> {
Ok(match self.rows("SELECT at FROM syncs WHERE name = $1", vec![name.into()]).await?.first() {
Some(r) => Some(r.try_get("", "at")?),
None => None,
})
}
pub async fn set_synced(&self, name: &str, at: i64) -> Result<()> {
self.exec(
"INSERT INTO syncs (name, at) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET at = excluded.at",
vec![name.into(), at.into()],
)
.await?;
Ok(())
}
pub async fn ping(&self) -> Result<()> {
self.rows("SELECT 1", vec![]).await?;
Ok(())
@@ -167,8 +199,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 {
@@ -188,7 +224,9 @@ impl Store {
continue;
}
// The same feed twice in one snapshot counts once, at its larger number.
let n = e.subscribers.max(0);
// Bounded above as well: two instances' i64::MAX summed past BIGINT, and every listing
// and page failed until the instance was dropped (#342). No server has a million.
let n = e.subscribers.clamp(0, MAX_SUBSCRIBERS);
let path = e.category.as_deref().map(str::trim).filter(|c| !c.is_empty()).map(ipx_feed::category_path);
let slot = keep.entry(id).or_insert((url, n, path.clone()));
slot.1 = slot.1.max(n);
@@ -235,7 +273,8 @@ impl Store {
.iter()
.map(|r| r.try_get("", "instance"))
.collect::<Result<_, _>>()?;
for name in present.iter().filter(|n| !names.contains(n)) {
// Apple's charts are no token's, and stay until the next sync replaces them.
for name in present.iter().filter(|n| !names.contains(n) && n.as_str() != crate::apple::INSTANCE) {
dropped += self.drop_instance(name).await?;
}
Ok(dropped)
@@ -343,7 +382,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()
}
@@ -351,7 +396,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()
}
@@ -377,8 +429,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> {
@@ -389,14 +440,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(
@@ -407,16 +462,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,
@@ -447,8 +511,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())
}
@@ -489,6 +553,17 @@ pub(crate) mod tests {
assert!(normalise("not a url").is_none());
}
#[tokio::test]
async fn no_count_an_instance_sends_breaks_the_listing() {
let s = Store::scratch().await;
s.snapshot("a", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
s.snapshot("b", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
fetch_all(&s).await;
assert_eq!(s.list().await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS, "summed, not overflowed (#342)");
assert_eq!(s.popular(10).await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS);
assert!(s.instances().await.is_ok());
}
#[tokio::test]
async fn snapshots_replace_what_an_instance_sent_and_add_up_across_instances() {
let s = Store::scratch().await;
@@ -512,7 +587,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();
@@ -554,7 +632,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

@@ -8,11 +8,15 @@ edition.workspace = true
anyhow.workspace = true
atom_syndication.workspace = true
chrono.workspace = true
http.workspace = true
hyper-util.workspace = true
opml.workspace = true
pulldown-cmark.workspace = true
quick-xml.workspace = true
reqwest.workspace = true
rss.workspace = true
serde_json.workspace = true
tower.workspace = true
tracing.workspace = true
url.workspace = true

269
crates/feed/src/guard.rs Normal file
View File

@@ -0,0 +1,269 @@
//! Keeps fetches off internal addresses (#320). A feed, an episode, its artwork and a .torrent are
//! all at addresses a publisher or any signed-in person names, and redirects go anywhere, so
//! without this the server fetched from its own loopback, its Docker network and the LAN, and
//! handed back what answered.
//!
//! The check is on the connection, after the name is resolved and before a byte of the request
//! is sent, so it covers every request, every redirect, an address written as an IP, and a name
//! that resolves to an internal address only on the second lookup.
use std::future::Future;
use std::net::{IpAddr, Ipv4Addr};
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use anyhow::{Context as _, Result};
use hyper_util::client::legacy::connect::{Connection, HttpInfo};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// Internal addresses and networks a fetch may reach all the same: config.toml's `fetch_internal`,
/// as `192.168.1.20` or `192.168.1.0/24`. Names are not taken: the check sees only the address.
#[derive(Clone, Debug, Default)]
pub struct Allowed(Arc<Vec<(IpAddr, u8)>>);
impl Allowed {
pub fn parse(list: &[String]) -> Result<Self> {
list.iter().map(|s| parse_net(s)).collect::<Result<Vec<_>>>().map(|v| Self(Arc::new(v)))
}
pub fn permits(&self, ip: IpAddr) -> bool {
let ip = canonical(ip);
!is_internal(ip) || self.0.iter().any(|&(net, bits)| contains(net, bits, ip))
}
}
fn parse_net(s: &str) -> Result<(IpAddr, u8)> {
let bad = || format!("fetch_internal: {s:?} is not an address or a network such as 192.168.1.0/24");
let (addr, bits) = s.trim().split_once('/').map_or((s.trim(), None), |(a, b)| (a, Some(b)));
let ip = canonical(addr.parse::<IpAddr>().with_context(bad)?);
let max = if ip.is_ipv4() { 32 } else { 128 };
let bits = match bits {
Some(b) => b.parse::<u8>().ok().filter(|n| *n <= max).with_context(bad)?,
None => max,
};
Ok((ip, bits))
}
/// An IPv4 address written as IPv6 (`::ffff:127.0.0.1`) is the IPv4 one.
fn canonical(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
v4 => v4,
}
}
fn contains(net: IpAddr, bits: u8, ip: IpAddr) -> bool {
match (net, ip) {
(IpAddr::V4(n), IpAddr::V4(i)) => {
let mask = u32::MAX.checked_shl(32 - u32::from(bits)).unwrap_or(0);
u32::from(n) & mask == u32::from(i) & mask
}
(IpAddr::V6(n), IpAddr::V6(i)) => {
let mask = u128::MAX.checked_shl(128 - u32::from(bits)).unwrap_or(0);
u128::from(n) & mask == u128::from(i) & mask
}
_ => false,
}
}
/// Not on the public internet: loopback, private, link-local, shared (CGNAT), multicast and
/// reserved ranges, and their IPv6 counterparts. `IpAddr::is_global` would say this, but is
/// unstable.
// shortcut: 6to4 (2002::/16) and Teredo addresses that wrap an internal IPv4 one pass; check the
// embedded address if an IPv6 route to them ever exists here.
pub fn is_internal(ip: IpAddr) -> bool {
match canonical(ip) {
IpAddr::V4(a) => {
let [o0, o1, o2, _] = a.octets();
a.is_private()
|| a.is_loopback()
|| a.is_link_local()
|| a.is_unspecified()
|| a.is_broadcast()
|| a.is_multicast()
|| o0 == 0
|| o0 >= 240
|| (o0 == 100 && o1 & 0xc0 == 64)
|| (o0 == 192 && o1 == 0 && o2 == 0)
}
IpAddr::V6(a) => {
let s = a.segments();
a.is_loopback()
|| a.is_unspecified()
|| a.is_multicast()
|| s[0] & 0xfe00 == 0xfc00
|| s[0] & 0xffc0 == 0xfe80
// NAT64 carries an IPv4 address in its last 32 bits.
|| (s[0] == 0x64 && s[1] == 0xff9b && is_internal(IpAddr::V4(Ipv4Addr::from(u128::from(a) as u32))))
}
}
}
/// A connector layer for `reqwest::ClientBuilder::connector_layer`: a connection to an address
/// `Allowed` does not permit fails before the request is sent.
#[derive(Clone)]
pub struct Guard(Allowed);
impl Guard {
pub fn new(allowed: Allowed) -> Self {
Self(allowed)
}
}
impl<S> tower::Layer<S> for Guard {
type Service = Guarded<S>;
fn layer(&self, inner: S) -> Guarded<S> {
Guarded { inner, allowed: self.0.clone() }
}
}
#[derive(Clone)]
pub struct Guarded<S> {
inner: S,
allowed: Allowed,
}
impl<S, R> tower::Service<R> for Guarded<S>
where
S: tower::Service<R>,
S::Response: Connection,
S::Error: Into<BoxError>,
S::Future: Send + 'static,
{
type Response = S::Response;
type Error = BoxError;
type Future = Pin<Box<dyn Future<Output = Result<S::Response, BoxError>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), BoxError>> {
self.inner.poll_ready(cx).map_err(Into::into)
}
fn call(&mut self, req: R) -> Self::Future {
let connecting = self.inner.call(req);
let allowed = self.allowed.clone();
Box::pin(async move {
let conn = connecting.await.map_err(Into::into)?;
let mut extras = http::Extensions::new();
conn.connected().get_extras(&mut extras);
// Refused when the peer is unknown too: a connection this cannot check is one it
// cannot vouch for.
match extras.get::<HttpInfo>().map(|i| i.remote_addr().ip()) {
Some(ip) if allowed.permits(ip) => Ok(conn),
Some(ip) => {
Err(format!("{ip} is an internal address, not fetched (fetch_internal in config.toml)").into())
}
None => Err("the connection's address is unknown, so it is not fetched".into()),
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn internal_addresses_are_known() {
for a in [
"127.0.0.1",
"10.1.2.3",
"172.16.0.1",
"192.168.1.130",
"169.254.169.254",
"100.64.0.1",
"0.0.0.0",
"224.0.0.1",
"255.255.255.255",
"::1",
"::",
"fd00::1",
"fe80::1",
"::ffff:127.0.0.1",
"64:ff9b::a00:1",
] {
assert!(is_internal(ip(a)), "{a}");
}
for a in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111", "64:ff9b::808:808", "100.128.0.1"] {
assert!(!is_internal(ip(a)), "{a}");
}
}
#[test]
fn the_allowlist_lets_through_what_it_names_and_no_more() {
let a = Allowed::parse(&["192.168.1.0/24".into(), "127.0.0.1".into(), "fd00::/8".into()]).unwrap();
assert!(a.permits(ip("192.168.1.20")));
assert!(a.permits(ip("127.0.0.1")));
assert!(a.permits(ip("::ffff:127.0.0.1")), "the same address written as IPv6");
assert!(a.permits(ip("fd12::1")));
assert!(!a.permits(ip("192.168.2.1")));
assert!(!a.permits(ip("127.0.0.2")));
assert!(a.permits(ip("1.1.1.1")), "public addresses need no listing");
assert!(Allowed::default().permits(ip("1.1.1.1")));
assert!(!Allowed::default().permits(ip("10.0.0.1")));
for bad in ["nas.lan", "10.0.0.0/33", "10.0.0.0/x", ""] {
assert!(Allowed::parse(&[bad.into()]).is_err(), "{bad}");
}
}
async fn answers() -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let _ = sock.read(&mut [0u8; 1024]).await;
let _ = sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nsecret").await;
}
});
format!("http://{addr}/")
}
fn client(allowed: &[&str]) -> reqwest::Client {
let allowed = Allowed::parse(&allowed.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap();
reqwest::Client::builder().connector_layer(Guard::new(allowed)).build().unwrap()
}
#[tokio::test]
async fn a_fetch_from_loopback_is_refused_unless_listed() {
let url = answers().await;
let err = client(&[]).get(&url).send().await.unwrap_err();
assert!(format!("{err:?}").contains("internal address"), "{err:?}");
let body = client(&["127.0.0.1"]).get(&url).send().await.unwrap().text().await.unwrap();
assert_eq!(body, "secret");
}
#[tokio::test]
async fn a_redirect_to_loopback_is_refused() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let inner = answers().await;
let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let _ = sock.read(&mut [0u8; 1024]).await;
let r = format!("HTTP/1.1 302 Found\r\nLocation: {inner}\r\nContent-Length: 0\r\n\r\n");
let _ = sock.write_all(r.as_bytes()).await;
}
});
// 127.0.0.2 stands for a public address: listed, it is let through, and the redirect it
// sends to 127.0.0.1 is not.
let err = client(&["127.0.0.2"]).get(format!("http://{addr}/")).send().await.unwrap_err();
assert!(format!("{err:?}").contains("127.0.0.1 is an internal address"), "{err:?}");
}
/// Run by hand: `cargo test -p ipx-feed guard -- --ignored`. HTTPS gives the peer's address
/// too; were it lost under TLS, every https fetch would be refused.
#[tokio::test]
#[ignore = "reaches the internet"]
async fn https_is_checked_and_let_through() {
let r = client(&[]).get("https://example.com/").send().await.unwrap();
assert!(r.status().is_success());
}
}

View File

@@ -1,6 +1,8 @@
//! Feed fetching and parsing, shared by ipx and the directory (#202). Replaces
//! FeedData.__getFeed / __getEntries.
pub mod guard;
use anyhow::{Context, Result, anyhow};
use reqwest::StatusCode;
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
@@ -27,6 +29,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.
@@ -61,6 +66,12 @@ pub enum Fetched {
/// downloads, which are not, have no such limit.
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// The longest a feed's server may send nothing, for the clients that fetch feeds to set: one
/// that took the connection and never answered ran the whole 30 s, every scan it was in (#230).
/// No feed that answered took over 8 s in a day of traces; one that keeps sending, however
/// slowly, still has until FEED_TIMEOUT.
pub const FEED_SILENCE: std::time::Duration = std::time::Duration::from_secs(15);
/// Feeds fetched and the bytes of their bodies, since the process started, for /metrics to set
/// beside memory (#224): a body is read whole, so a night of large feeds is a night of large
/// allocations.
@@ -90,6 +101,38 @@ pub fn redact(url: &str) -> String {
}
}
/// Text with each address in it redacted, for what is logged and sent to every open page: an
/// error from reqwest names the whole address it fetched, a private feed's key with it (#238).
pub fn redact_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(i) = rest.find("http://").into_iter().chain(rest.find("https://")).min() {
out.push_str(&rest[..i]);
let end = rest[i..].find(|c: char| c.is_whitespace() || "()<>\"'".contains(c)).map_or(rest.len(), |n| i + n);
out.push_str(&redact(&rest[i..end]));
rest = &rest[end..];
}
out.push_str(rest);
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,
@@ -134,15 +177,23 @@ pub async fn fetch(
return Ok((Fetched::NotModified, moved));
}
if !status.is_success() {
// WordPress.com answers some readers' feed requests with a browser check that only
// JavaScript passes, for hours at a time and whatever the User-Agent; its API has the
// same posts and no check (#317).
if status == StatusCode::FORBIDDEN
&& let Some(host) = url.host_str().map(str::to_owned)
&& read_capped(resp).await.is_ok_and(|b| is_wpcom_challenge(&b))
{
return Ok((wpcom_posts(client, &host).await?, moved));
}
// 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));
@@ -151,6 +202,57 @@ pub async fn fetch(
Err(anyhow!("error following redirect for url ({url}): too many redirects"))
}
/// WordPress.com's browser check, in place of the feed it was asked for.
fn is_wpcom_challenge(body: &[u8]) -> bool {
String::from_utf8_lossy(&body[..body.len().min(4096)]).contains("/__challenge/")
}
/// A WordPress.com site's latest posts, from WordPress.com's API, which `parse` reads as a feed.
/// shortcut: the API's default page of ten posts, as many as WordPress puts in a feed; a feed
/// set to carry more gets ten here.
async fn wpcom_posts(client: &reqwest::Client, host: &str) -> Result<Fetched> {
let api = format!("https://public-api.wordpress.com/wp/v2/sites/{host}/posts");
let resp = client
.get(&api)
.timeout(FEED_TIMEOUT)
.send()
.await
.context("HTTP 403 Forbidden, a browser check; connecting to WordPress.com's API in its place")?;
anyhow::ensure!(
resp.status().is_success(),
"HTTP 403 Forbidden, a browser check, and WordPress.com's API in its place answered HTTP {}",
resp.status()
);
let bytes = read_capped(resp).await.context("reading WordPress.com's API")?;
FETCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
FETCHED_BYTES.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
Ok(Fetched::Body { bytes, etag: None, last_modified: None })
}
/// WordPress's posts as its REST API sends them (`wpcom_posts`). The guid is the one the site's
/// own feed gives each post, so they are the items already stored. No title for the feed: the
/// stored one stays.
fn from_wp_posts(posts: &[serde_json::Value]) -> ParsedFeed {
let entries = posts
.iter()
.filter_map(|p| {
Some(Entry {
guid: non_empty(p["guid"]["rendered"].as_str()).or_else(|| non_empty(p["link"].as_str()))?,
title: title_text(p["title"]["rendered"].as_str()),
link: non_empty(p["link"].as_str()),
published: p["date_gmt"]
.as_str()
.and_then(|d| chrono::NaiveDateTime::parse_from_str(d, "%Y-%m-%dT%H:%M:%S").ok())
.map(|d| d.and_utc().timestamp()),
description: non_empty(p["content"]["rendered"].as_str()),
image: non_empty(p["jetpack_featured_media_url"].as_str()),
..Default::default()
})
})
.collect();
ParsedFeed { entries, ..Default::default() }
}
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
/// it is, and whether there is a new address to switch to.
pub struct Failure {
@@ -167,7 +269,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"
@@ -201,14 +306,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") {
@@ -226,7 +331,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 });
}
@@ -252,11 +359,37 @@ pub fn is_opml(bytes: &[u8]) -> bool {
text.contains("<opml")
}
/// How deep an OPML document's elements may nest. Real ones are a folder or two deep.
const MAX_OPML_DEPTH: usize = 100;
/// Parses an OPML document, refusing one nested deeper than `MAX_OPML_DEPTH`. The opml crate's
/// reader recurses once per level with no limit, and a few MB of `<outline>` overflowed the
/// stack, which aborts the whole daemon, and again on each restart for a subscribed feed (#322).
pub fn read_opml(text: &str) -> Result<opml::OPML> {
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_str(text);
let mut depth = 0usize;
loop {
match reader.read_event() {
Ok(Event::Start(_)) => {
depth += 1;
if depth > MAX_OPML_DEPTH {
anyhow::bail!("that OPML nests more than {MAX_OPML_DEPTH} levels deep");
}
}
Ok(Event::End(_)) => depth = depth.saturating_sub(1),
// Malformed XML is the opml crate's to describe.
Ok(Event::Eof) | Err(_) => break,
Ok(_) => {}
}
}
opml::OPML::from_str(text).map_err(|e| anyhow!("{e}"))
}
/// 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 = read_opml(&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)
@@ -276,11 +409,8 @@ pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, Strin
/// The <head><title> of an OPML document.
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())
let doc = read_opml(&text).ok()?;
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.
@@ -322,7 +452,11 @@ pub fn expand_input(input: &str) -> String {
/// creator's name, by number, or with no creator at all -- and the token and show are what
/// identify it.
pub fn same_feed(a: &str, b: &str) -> bool {
a == b || patreon_parts(a).is_some_and(|p| Some(p) == patreon_parts(b))
// Parsing both is the slow part, and only Patreon's addresses can differ and still match.
a == b
|| (a.contains("patreon.com")
&& b.contains("patreon.com")
&& patreon_parts(a).is_some_and(|p| Some(p) == patreon_parts(b)))
}
/// A Patreon creator's name and shows, each show as (title, feed URL).
@@ -330,10 +464,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")?;
@@ -345,8 +476,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}\
@@ -369,8 +499,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.
@@ -392,6 +525,11 @@ fn parse_patreon_shows(json: &[u8]) -> Result<(Option<String>, Vec<(String, Stri
/// RSS first, then Atom -- the same split the original made on `parsedFeed.version`.
#[tracing::instrument(skip_all, fields(bytes = bytes.len()))]
pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
if bytes.trim_ascii_start().starts_with(b"[")
&& let Ok(posts) = serde_json::from_slice::<Vec<serde_json::Value>>(bytes)
{
return Ok(from_wp_posts(&posts));
}
match rss::Channel::read_from(bytes) {
Ok(ch) => Ok(from_rss(ch, bytes)),
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
@@ -469,7 +607,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);
@@ -484,15 +623,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}"))?;
@@ -506,7 +640,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())
@@ -524,7 +659,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
@@ -535,6 +670,9 @@ pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
is_image(client, ico.as_str()).await.then(|| ico.into())
}
/// How many hosts one scan of a feed asks whether its artwork is on https.
const MAX_ART_HOSTS: usize = 8;
/// Artwork's address on https when its host serves it there, else as it was. The page is https
/// and must not load http; the host is asked once per `known` (one feed's read), and an http
/// address it does not serve on https stays, for /api/art to fetch (#90). Four of the five
@@ -550,6 +688,10 @@ pub async fn prefer_https(
let secure = format!("https://{rest}");
let ok = match known.get(&host) {
Some(ok) => *ok,
// Each new host is a probe of up to 20 s, one after another on the scan, and the feed's
// author decides how many hosts there are: ports and user names make as many as they like
// of one server (#339). Real feeds put their artwork on a few.
None if known.len() >= MAX_ART_HOSTS => false,
None => {
let ok = is_image(client, &secure).await;
known.insert(host, ok);
@@ -661,9 +803,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()),
@@ -715,10 +855,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()
@@ -747,10 +884,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 {
@@ -758,8 +892,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()
@@ -818,25 +955,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,
@@ -867,18 +997,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 {
@@ -925,9 +1045,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())
@@ -947,6 +1065,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
@@ -993,7 +1148,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),
_ => {}
}
@@ -1030,13 +1188,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())
})
}
@@ -1078,13 +1231,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"]),
@@ -1099,6 +1301,11 @@ const ALIASES: &[(&str, &str)] =
/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a
/// category and then its subcategories as Apple's does (#118). A podcast's own is stored as its
/// subcategory, and one an admin typed that is not Apple's stands as a category of its own.
/// Apple's categories as ipx has them, for the directory to check against Apple's own (#252).
pub fn categories() -> &'static [(&'static str, &'static [&'static str])] {
CATEGORIES
}
pub fn category_path(name: &str) -> (String, Option<String>) {
// A feed's own text, which can end in a stray comma: "Soccer," was a category of its own.
let name = name.trim().trim_end_matches([',', ';', '.']).trim_end();
@@ -1114,6 +1321,9 @@ pub fn category_path(name: &str) -> (String, Option<String>) {
(name.to_string(), None)
}
/// TypeSafe's System One endpoint, where Jev answers questions about a piece of state.
pub const JEV_URL: &str = "https://api.typesafe.ai/v1/systemone";
/// A Directory category for a feed that names none of its own, which most blogs do not (#117):
/// TypeSafe's Jev picks one of Apple's categories or subcategories from the feed's title and
/// latest items, the subcategory being what a podcast's own category is stored as. The likeliest
@@ -1138,7 +1348,7 @@ pub async fn classify(client: &reqwest::Client, key: &str, feed: &ParsedFeed) ->
}},
});
let answer: serde_json::Value = client
.post("https://api.typesafe.ai/v1/systemone")
.post(JEV_URL)
.bearer_auth(key)
.json(&body)
.timeout(FEED_TIMEOUT)
@@ -1178,24 +1388,53 @@ 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 {
#[test]
fn reads_wordpress_posts_as_a_feed() {
// As WordPress.com's API sends them, trimmed (#317).
let json = br#"[{"id":90248260,"date_gmt":"2026-10-09T13:34:00",
"guid":{"rendered":"https:\/\/pelgranepress.com\/?p=90248260"},
"link":"https:\/\/pelgranepress.com\/2026\/10\/09\/whisky\/",
"title":{"rendered":"Baby&#8217;s First Whisky"},"content":{"rendered":"<p>Notes<\/p>"},
"jetpack_featured_media_url":""}]"#;
let feed = super::parse(json).expect("WordPress posts parse as a feed");
assert_eq!(feed.title, None);
let e = &feed.entries[0];
assert_eq!(e.guid, "https://pelgranepress.com/?p=90248260");
assert_eq!(e.title.as_deref(), Some("Baby\u{2019}s First Whisky"));
assert_eq!(e.link.as_deref(), Some("https://pelgranepress.com/2026/10/09/whisky/"));
assert_eq!(e.published, Some(1_791_552_840));
assert_eq!(e.description.as_deref(), Some("<p>Notes</p>"));
assert_eq!(e.image, None);
assert!(super::is_wpcom_challenge(
b"<!DOCTYPE html><title>Checking your browser...</title><script src=\"/__challenge/probes.js\">"
));
assert!(!super::is_wpcom_challenge(b"<html>Forbidden</html>"));
}
#[test]
fn redact_drops_what_carries_a_credential() {
assert_eq!(super::redact("https://www.patreon.com/rss/x?auth=SECRET&show=1"), "https://www.patreon.com/rss/x");
assert_eq!(super::redact("https://me:pw@example.com/feed.xml#top"), "https://example.com/feed.xml");
assert_eq!(super::redact("https://example.com/feed.xml"), "https://example.com/feed.xml");
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"
),
"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");
assert_eq!(super::redact_text("HTTP 404 Not Found"), "HTTP 404 Not Found");
}
#[test]
@@ -1230,10 +1469,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;
@@ -1256,6 +1503,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();
@@ -1291,10 +1540,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]
@@ -1308,6 +1554,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>
@@ -1316,9 +1596,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");
}
@@ -1345,8 +1628,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"));
}
@@ -1365,14 +1655,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]
@@ -1385,10 +1680,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]
@@ -1418,13 +1710,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());
@@ -1553,6 +1849,32 @@ mod tests {
assert_eq!((p1.season, p1.episode), (Some(8), Some(1)));
}
#[tokio::test]
async fn artwork_on_more_hosts_than_the_limit_is_not_asked_about() {
let mut known: std::collections::HashMap<String, bool> =
(0..MAX_ART_HOSTS).map(|n| (format!("h{n}:80"), false)).collect();
// No server answers here: were it asked, this would wait on a connection, not return.
let url = "http://192.0.2.1:9/a.png";
assert_eq!(prefer_https(&reqwest::Client::new(), url, &mut known).await, url);
assert_eq!(known.len(), MAX_ART_HOSTS);
}
#[test]
fn deeply_nested_opml_is_refused_before_it_is_parsed() {
let nest = |n: usize| {
format!(
r#"<opml version="2.0"><head><title>T</title></head><body>{}<outline text="f" xmlUrl="https://e.com/f"/>{}</body></opml>"#,
r#"<outline text="a">"#.repeat(n),
"</outline>".repeat(n)
)
};
assert_eq!(parse_opml(nest(5).as_bytes()).unwrap().len(), 1, "a few folders deep is fine");
// Deep enough to overflow a 2 MiB stack in the opml crate's reader without the check.
let deep = nest(100_000);
assert!(parse_opml(deep.as_bytes()).unwrap_err().to_string().contains("levels deep"));
assert_eq!(opml_title(deep.as_bytes()), None);
}
#[test]
fn opml_is_recognised_and_its_feeds_listed() {
let xml = br#"<opml version="2.0"><head><title>My Subscriptions</title></head><body>
@@ -1635,10 +1957,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"));
@@ -1741,4 +2060,3 @@ mod tests {
assert_eq!(category_path("Software How-To"), ("Technology".into(), None));
}
}

View File

@@ -12,6 +12,113 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.5.0] - 2026-10-23
### Changed
- Traces show more of where a request's time goes: a page of items' query and its files apart, artwork's cache, check and fetch, the status counts, and each part of changing or leaving a feed (#350).
- A page of items finds its files by feed as well as item: it read every file's row in the database, about a quarter of a second a page (#344).
- A page of items asks for its count beside the items, and the feed list asks its five questions at once, where each waited for the one before (#346).
- A Signal's counts of items and of unread ones are worked out in one pass, not two (#348).
- Artwork kept on disk is read and written off the threads that answer requests, and two requests for the same missing artwork fetch it once (#345, #349).
- Unread lists and counts start from each person's oldest unread item in each feed, which the daily cleanup works out, instead of reading the whole back catalogue and everything you have read (#347, #352).
### Fixed
- A WordPress.com site that answers its feed with a browser check (HTTP 403, "Checking your browser...") is read from WordPress.com's API instead, so Pelgrane Press's feed no longer fails for hours at a time. The items are the ones the feed gave: same ids (#317).
### Security
- An episode that comes as a torrent downloads into a folder of its own, `torrent-<id>` in its podcast's folder. A torrent could name a path anywhere on the server for Delete, the cleanup and playback to act on, and could overwrite other episodes' files of the same name (#318, #331).
- A downloaded file opens in the browser only when it is audio, video, an image other than SVG, a PDF or plain text, and is saved otherwise. An SVG or web page a podcast sent ran its script as iPX, signed in as whoever opened it (#319).
- An OPML nested more than 100 levels deep is refused, uploaded or fetched. A deep enough one stopped iPX, and a subscribed one stopped it again each time it restarted (#322).
- A `.torrent` file larger than 10 MB, or slower than the torrent stall limit, is refused. iPX read one into memory however large it was (#330).
- Feeds, episodes, artwork and `.torrent` files are no longer fetched from internal addresses (loopback, the LAN, Docker's networks), redirects included, unless config.toml's `fetch_internal` lists them. Adding a feed could read what a service inside the network answered (#320).
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
- A new install's `admin` gets a random password, written to `admin-password` beside config.toml and readable by iPX's user alone, or the one in `IPX_ADMIN_PASSWORD`. Every install began as admin / ipodderx, a password published in the README, and the log printed it (#321).
- A show kept from a private OPML or Patreon list stays private, with the list's username and password, once the list is removed or left. It became public and was shared to the directory (#332).
- A feed's username and password go to its own host alone: not to its episodes on other hosts, nor to the feeds an OPML lists on other hosts, which took the OPML's password on every check (#327, #328).
- A check of one feed asks at most eight hosts whether its artwork is on https. A feed could name artwork on as many hosts as it liked, each asked in turn for up to 20 seconds, and hold up every other feed's check for hours (#339).
- An OPML or Patreon list may bring in up to 2,000 feeds; one with more shows an error and adds none. A list of tens of thousands held up checking for everyone, and adding its feeds read the whole feeds table once for each (#337).
- A request that changes something is refused when a browser sent it from another site. A page on another site, a sibling subdomain included, could mark everything read, start downloads, subscribe you to a Directory feed or sign you out (#336).
- `ipx user passwd` signs the account out of every browser, and says how many of its API tokens still work. A session signed in with the old password went on working (#334).
- Live updates about a feed go only to the people who read it, and admins. Everyone signed in had every feed's, errors included, which carried a paid feed's address with its key (#325).
- An open page stops getting live updates within half a minute of its account being removed. They went on until the page was closed (#335).
- Only someone subscribed to a feed, or an admin, may mark it private. Anyone signed in could keep anyone's feed, or a whole OPML's, out of the directory, and only an admin could undo it (#333).
- An OPML upload may add up to 2,000 feeds; one with more is refused and adds none. Importing tens of thousands held up every change to the feed list for minutes and left them all in it (#338).
- Password checks stay a few at a time when the person signing in gives up before the answer. Each sign-in that hung up let another start, so a flood of them used every core (#324).
- Jev picks at most 20 icons an hour for each person, on the server's key; past that the page says to choose one by hand. Anyone signed in could have it pick without end, on the server's bill, holding back everyone's Signals (#340).
- Each person's Signals on an item are judged in a call of their own, on the server's key too. One person's description was in the question that decided another's verdicts (#343).
## [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
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key, each with an icon of its own if you like, which Jev can pick on the server's TypeSafe key (`POST /api/signals/icon`) (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
- With `OTEL_EXPORTER_OTLP_COMPRESSION=gzip`, traces go to the collector compressed; `/metrics` answers gzipped, and a snapshot goes to the directory gzipped (#253).
- A Directory request's trace shows the call to the directory whole, with the size of its answer, and the steps that match it against your subscriptions (#250).
- The page's report of how long a list took to show after a click (`list_shown`, with the view and filter) is logged under that click's trace (#246).
### Changed
- A feed that sends the same body again, as Patreon and Substack do whatever they are asked, is taken as unchanged instead of read again, and, without a schedule of its own, checked less often the longer it stays the same, up to every four hours, back on schedule when it changes (#242).
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
- On Postgres the daemon keeps all twenty of its database connections open, so a page load no longer waits for new ones, 80-330 ms each: the feed list and item lists took 0.3-1.6 s now and then, where their queries take 5-30 ms (#241).
- Currently Listening and the Pinned filter answer in a few milliseconds: they read every item on the server, 1.3 s for Currently Listening (#249).
## [1.2.0] - 2026-10-15
### Added
- `scan_done` on the event stream says which feed or folder the scan was limited to and whether someone asked for it (`only`, `forced`), so a page can tell the end of its own scan from a scheduled one's (#233).
### Changed
- Check every feed looks up the site icons of feeds without artwork a few at a time, alongside the fetches, instead of one after another (#228).
- A feed's server that sends nothing for 15 s is given up on, instead of holding a scan for the full 30 s; a feed that keeps sending still has 30 s (#230).
- A scan's trace names the feed each fetch is for (#229).
### Security
- A failure's words, and the addresses in an event, go to the log and to every open page without their query or password, where a private feed keeps its key: a failed fetch of a Patreon feed logged its `auth` key (#238).
## [1.1.0] - 2026-10-13
### Added
@@ -816,7 +923,11 @@ 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.1.0...main
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.5.0...main
[1.5.0]: https://git.sdf1.net/rays/ipx/compare/v1.4.0...v1.5.0
[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
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
[0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0

View File

@@ -1,7 +1,8 @@
[package]
name = "ipx"
version = "1.1.0"
version = "1.5.0"
edition.workspace = true
publish = false
[dependencies]
ipx-feed.workspace = true
@@ -9,7 +10,7 @@ ipx-directory.workspace = true
ammonia.workspace = true
anyhow.workspace = true
argon2.workspace = true
atom_syndication.workspace = true
flate2.workspace = true
axum.workspace = true
chrono.workspace = true
clap.workspace = true
@@ -19,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,16 +25,16 @@ 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
/// passwords at a time, run on the async workers, made every other request wait 4s (#137).
/// Half the cores, so a flood of sign-ins waits on itself and the rest of the server has the rest.
static CHECKS: std::sync::LazyLock<tokio::sync::Semaphore> = std::sync::LazyLock::new(|| {
tokio::sync::Semaphore::new(std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)))
static CHECKS: std::sync::LazyLock<std::sync::Arc<tokio::sync::Semaphore>> = std::sync::LazyLock::new(|| {
std::sync::Arc::new(tokio::sync::Semaphore::new(
std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)),
))
});
/// What a name that is not an account is checked against, so that refusing it takes as long as
@@ -47,13 +47,19 @@ static DECOY: std::sync::LazyLock<String> =
/// or it has no password: false either way, in the same time. Off the async workers, and a few at
/// a time; see `CHECKS`.
pub async fn check_password(password: String, stored: Option<String>) -> bool {
let Ok(_turn) = CHECKS.acquire().await else { return false };
tokio::task::spawn_blocking(move || match stored {
let Ok(turn) = CHECKS.clone().acquire_owned().await else { return false };
// The turn goes with the check, not with this future: a client that hung up dropped the
// future, freeing the turn while the check ran on, so disconnecting sign-ins ran any number
// at once (#324).
tokio::task::spawn_blocking(move || {
let _turn = turn;
match stored {
Some(h) => verify_password(&password, &h),
None => {
verify_password(&password, &DECOY);
false
}
}
})
.await
.unwrap_or(false)

View File

@@ -50,6 +50,16 @@ 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,
/// Internal addresses and networks feeds, episodes and artwork may still be fetched from, as
/// `["192.168.1.20", "10.0.0.0/8"]`: everything off the public internet is refused otherwise,
/// since a feed or anyone signed in names what is fetched (#320). Addresses, not names.
pub fetch_internal: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
@@ -207,19 +217,16 @@ impl Default for General {
directory_url: String::new(),
directory_token: String::new(),
directory_share: false,
signals_on_server: false,
signal_days: 7,
fetch_internal: vec![],
}
}
}
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 }
}
}
@@ -293,6 +300,27 @@ impl Feed {
pub fn auth(&self) -> Option<(&str, Option<String>)> {
self.username.as_deref().map(|u| (u, self.password()))
}
/// `auth()`, for a URL on the feed's own host only. The credential is for whoever issued it,
/// and an enclosure, or a feed an OPML lists, can be on any host its author names (#327, #328).
pub fn auth_for(&self, url: &str) -> Option<(&str, Option<String>)> {
same_host(&self.url, url).then(|| self.auth()).flatten()
}
/// The username, password and password_env a feed at `url` takes from this one, its OPML or
/// Patreon list: all of them on the list's own host, none elsewhere.
pub fn credentials_for(&self, url: &str) -> (Option<String>, Option<String>, Option<String>) {
if same_host(&self.url, url) {
(self.username.clone(), self.password.clone(), self.password_env.clone())
} else {
(None, None, None)
}
}
}
fn same_host(a: &str, b: &str) -> bool {
let host = |u: &str| url::Url::parse(u).ok().and_then(|u| u.host_str().map(str::to_ascii_lowercase));
host(a).is_some_and(|h| host(b).as_ref() == Some(&h))
}
impl Config {
@@ -301,14 +329,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 +356,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 +371,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 +381,8 @@ const STORED_KEYS: [&str; 9] = [
"directory_url",
"directory_token",
"directory_share",
"signals_on_server",
"signal_days",
];
impl Stored {
@@ -363,6 +398,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 +414,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;
}
}
@@ -483,20 +522,17 @@ pub fn slug(text: &str) -> String {
}
/// `slug`, with a numeric suffix if that id is taken.
pub fn unique_slug(text: &str, taken: &BTreeMap<String, Feed>) -> String {
pub fn unique_slug(text: &str, taken: impl Fn(&str) -> bool) -> String {
let base = slug(text);
if !taken.contains_key(&base) {
if !taken(&base) {
return base;
}
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken.contains_key(s)).unwrap()
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken(s)).unwrap()
}
/// `$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 {
@@ -514,6 +550,22 @@ fn expand_tilde(p: &Path) -> PathBuf {
mod tests {
use super::*;
#[test]
fn a_feeds_password_goes_to_its_own_host_alone() {
let paid = Feed {
url: "https://Members.example.com/feed.xml".into(),
username: Some("me".into()),
password: Some("pw".into()),
..toml::from_str("url = 'x'").unwrap()
};
assert_eq!(paid.auth_for("https://members.example.com/ep1.mp3"), Some(("me", Some("pw".into()))));
assert_eq!(paid.auth_for("https://cdn.example.net/ep1.mp3"), None, "an enclosure elsewhere (#328)");
assert_eq!(paid.auth_for("https://members.example.com.evil.net/x"), None);
assert_eq!(paid.auth_for("not a url"), None);
assert_eq!(paid.credentials_for("https://members.example.com/other.xml").0.as_deref(), Some("me"));
assert_eq!(paid.credentials_for("https://elsewhere.org/feed.xml"), (None, None, None), "an OPML's feed (#327)");
}
#[test]
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
let cfg: Config = toml::from_str(
@@ -575,10 +627,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,12 +712,27 @@ 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,
});
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
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", |s| taken.contains_key(s)), "the-daily-2");
}
#[test]

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}"),
@@ -55,25 +59,53 @@ pub fn configured(cfg: &config::Config) -> bool {
target(cfg).is_some()
}
async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &str, body: Option<&[SnapshotEntry]>) -> Result<T, DirError> {
/// 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> {
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
.local_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);
}
// A snapshot is about 100 KB of JSON, sent gzipped; the directory unpacks it (#253).
if let Some(b) = body {
req = req.json(b);
use std::io::Write as _;
let json = serde_json::to_vec(b).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(&json).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
let packed = gz.finish().map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
req = req
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::CONTENT_ENCODING, "gzip")
.body(packed);
}
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:#}")))?;
serde_json::from_slice(if body.is_empty() { b"null" } else { &body }).map_err(|e| DirError::Unreachable(format!("its answer did not read: {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:#}")))
}
fn seg(s: &str) -> percent_encoding::PercentEncode<'_> {
@@ -85,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.
@@ -198,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()));
@@ -212,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 {
@@ -241,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(';') {
@@ -145,8 +133,8 @@ pub async fn download(
mut on_progress: impl FnMut(u64, Option<u64>),
) -> Result<Downloaded> {
let mut req = client.get(url);
if let Some(user) = &feed_cfg.username {
req = req.basic_auth(user, feed_cfg.password());
if let Some((user, password)) = feed_cfg.auth_for(url) {
req = req.basic_auth(user, password);
}
let resp = req.send().await.context("connecting")?;
if !resp.status().is_success() {
@@ -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

@@ -46,6 +46,12 @@ pub mod feeds {
/// are re-derived instead. Customising one promotes it to config.
#[sea_orm(default_value = false)]
pub managed: bool,
/// SHA-256 of the body last read, in hex: the same body again is read as "not modified"
/// (#242). Added after the table was, so `create_missing` adds it, and `same_since`.
#[sea_orm(column_type = "Text", nullable)]
pub body_hash: Option<String>,
/// When the body last changed: the longer ago, the less often the feed is checked (#242).
pub same_since: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -72,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>,
@@ -151,6 +160,12 @@ pub mod users {
pub theme: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub theme_mode: Option<String>,
/// Their own TypeSafe key, which Jev judges their Signals' items on (#254). Never sent
/// back to the page.
// shortcut: kept as it is, since every call needs it whole; encrypt it with a key of the
// server's if the database is ever shared with anything else.
#[sea_orm(column_type = "Text", nullable)]
pub typesafe_key: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -204,6 +219,21 @@ 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,
/// Every item of the feed first seen before this is read by this person (#347): unread
/// lists start here instead of reading the whole back catalogue. NULL knows nothing, and
/// the daily reap moves it up (`Db::advance_unread_from`).
pub unread_from: Option<i64>,
}
owned_by_user!();
@@ -276,6 +306,71 @@ pub mod hidden {
owned_by_user!();
}
/// A feed someone makes from their own subscriptions (#254): the items Jev says fit its
/// keywords, whichever feed they came from.
pub mod signals {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "signals")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
#[sea_orm(column_type = "Text")]
pub name: String,
/// JSON array of strings.
#[sea_orm(column_type = "Text")]
pub keywords: String,
/// What its owner means by it, in a sentence of their own, when the name and the words
/// are not enough (#259).
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
/// Its icon, a Font Awesome Free one as `style/name` (`solid/robot`), in place of its
/// initials (#261).
#[sea_orm(column_type = "Text", nullable)]
pub icon: Option<String>,
pub created: i64,
}
owned_by_user!();
}
/// What Jev said of each item a Signal has been asked about, fitting or not: an item with no row
/// here is still to be asked.
pub mod signal_verdicts {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "signal_verdicts")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub signal_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
#[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)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::signals::Entity",
from = "Column::SignalId",
to = "super::signals::Column::Id",
on_delete = "Cascade"
)]
Signal,
}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod sessions {
use sea_orm::entity::prelude::*;

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,20 +47,95 @@ 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 },
Reaped { path: String, bytes: u64 },
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,
},
/// Terminal: a client that asked for work stops reading here.
ScanDone { feeds: usize },
ReapDone { files: usize, bytes: u64 },
Status { feeds: usize, pending: i64, downloaded: i64 },
Error { msg: String },
ScanDone {
feeds: usize,
/// The feed or folder the scan was limited to, and whether someone asked for it: a page
/// stops the button they pressed at the end of their scan, not at a scheduled one's (#233).
#[serde(default)]
only: Option<String>,
#[serde(default)]
forced: bool,
},
ReapDone {
files: usize,
bytes: u64,
},
Status {
feeds: usize,
pending: i64,
downloaded: i64,
},
Error {
msg: String,
},
}
impl Event {
/// The feed this is about, for sending it only to those who read that feed.
pub fn feed(&self) -> Option<&str> {
match self {
Event::FeedStart { feed }
| Event::FeedSkip { feed, .. }
| Event::FeedDone { feed, .. }
| Event::FeedError { feed, .. }
| Event::FeedMoved { feed, .. }
| Event::Progress { feed, .. }
| Event::DownloadDone { feed, .. }
| Event::DownloadError { feed, .. }
| Event::TorrentDeferred { feed, .. }
| Event::DeadairDone { feed, .. } => Some(feed),
_ => None,
}
}
/// Whether a page signed in as this person is sent it. Everyone had every event, so a key in
/// a paid feed's path, which `redacted` keeps, reached people who do not read the feed (#325).
/// An admin has them all, for the log.
pub fn is_for(&self, user: &crate::db::User, mine: &std::collections::HashSet<String>) -> bool {
if user.is_admin {
return true;
}
match self {
Event::SignalHits { user: u, .. } => *u == user.id,
// Paths of files on disk, everyone's.
Event::Reaped { .. } => false,
ev => ev.feed().is_none_or(|f| mine.contains(f)),
}
}
pub fn is_terminal(&self) -> bool {
matches!(self, Event::ScanDone { .. } | Event::ReapDone { .. } | Event::Status { .. })
}
@@ -70,10 +163,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")
}
@@ -81,8 +173,11 @@ 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::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"),
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)"),
})
}
}
@@ -136,6 +231,7 @@ impl Emitter {
}
pub fn emit(&self, e: Event) {
let e = e.redacted();
// Also log it. Scans and downloads travel as events, not tracing calls, so
// without this the log view shows only startup and HTTP lines and none of the
// work the daemon is actually doing. Progress goes to debug: it fires on every
@@ -153,12 +249,40 @@ 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}");
}
}
}
impl Event {
/// The event as it may go out: to the log, to every open page whoever's feed it is, to a
/// terminal. Addresses lose their query and password, where a private feed keeps its key, and
/// so do the ones in an error's words (#238).
fn redacted(mut self) -> Self {
use crate::feed::{redact, redact_text};
match &mut self {
Event::DownloadError { url, msg, .. } => {
*url = redact(url);
*msg = redact_text(msg);
}
// 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);
}
Event::FeedError { msg, .. } | Event::Error { msg } => *msg = redact_text(msg),
_ => {}
}
self
}
}
/// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as
/// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and,
/// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex.
@@ -227,9 +351,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(
@@ -241,14 +364,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 {
@@ -335,7 +456,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.
@@ -367,6 +490,31 @@ pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
mod tests {
use super::*;
#[test]
fn a_feeds_events_go_to_those_who_read_it() {
let person = |id, is_admin| crate::db::User {
id,
name: "x".into(),
pass_hash: None,
is_admin,
created: None,
last_login: None,
};
let (me, admin) = (person(1, false), person(2, true));
let mine: std::collections::HashSet<String> = ["mine".to_string()].into();
let error = |feed: &str| Event::FeedError {
feed: feed.into(),
msg: "error sending request for url (https://feeds.supercast.com/feeds/KEY)".into(),
};
assert!(error("mine").is_for(&me, &mine));
assert!(!error("theirs").is_for(&me, &mine), "a paid feed's key in its path (#325)");
assert!(error("theirs").is_for(&admin, &mine), "an admin's log has everything");
assert!(Event::ScanDone { feeds: 3, only: None, forced: false }.is_for(&me, &mine));
assert!(Event::SignalHits { user: 1, hits: 2 }.is_for(&me, &mine));
assert!(!Event::SignalHits { user: 3, hits: 2 }.is_for(&me, &mine));
assert!(!Event::Reaped { path: "/downloads/Theirs/ep.mp3".into(), bytes: 1 }.is_for(&me, &mine));
}
#[test]
fn a_feed_not_due_stays_off_the_live_updates() {
let (tx, mut rx) = broadcast::channel(4);
@@ -378,11 +526,57 @@ mod tests {
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
}
#[test]
fn what_goes_out_names_no_private_feeds_key() {
let (tx, mut rx) = broadcast::channel(8);
let out = Emitter::socket(tx, false);
let key = "auth=SECRET";
out.emit(Event::FeedError {
feed: "f".into(),
msg: format!("connecting: error sending request for url (https://x/rss?{key}&show=1): operation timed out"),
});
out.emit(Event::DownloadError {
feed: "f".into(),
enclosure: 1,
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}"),
});
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}");
}
}
#[test]
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");
}
@@ -421,29 +615,16 @@ 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"));
}
#[test]
fn only_completion_events_end_a_client_session() {
assert!(Event::ScanDone { feeds: 1 }.is_terminal());
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);

File diff suppressed because it is too large Load Diff

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,13 +160,16 @@ 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();
async move { ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(&s)) }
}),
);
)
// Prometheus asks for gzip, every 15 s (#253).
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
match tokio::net::TcpListener::from_std(listener) {
Ok(l) => {
if let Err(e) = axum::serve(l, app).await {
@@ -153,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)]);
@@ -176,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
@@ -191,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),
@@ -231,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);
@@ -276,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");

680
crates/ipx/src/signals.rs Normal file
View File

@@ -0,0 +1,680 @@
//! Signals (#254): feeds someone makes from their own subscriptions, holding the items TypeSafe's
//! Jev says fit the keywords they gave. Jev is asked on their own key, once for each item, with a
//! question for each of their Signals that has not had it yet, and what it says is kept in
//! `signal_verdicts`, fitting or not, so nothing is asked twice.
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, 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. 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 {
let done = tokio::select! {
_ = 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) => {}
Err(e) => tracing::warn!(error = %format!("{e:#}"), "Signals could not be judged"),
}
tokio::select! {
_ = stop.changed() => return,
_ = ctx.signals.notified() => {}
_ = tokio::time::sleep(IDLE) => {}
}
}
}
/// 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 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(),
calls = tracing::field::Empty,
judged = tracing::field::Empty,
hits = tracing::field::Empty
);
async {
let groups = calls(&items);
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 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;
}
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).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) => {
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) => {
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 = 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.into_inner() && (full(&items) || some_refused))
}
.instrument(span)
.await
}
/// The calls a batch makes: one an item and person, with all that person's Signals on it. On the
/// server's key everyone's Signals on an item went in one call, its text sent once (#275), and
/// then one person's description was in the question that decided another's verdicts (#343).
fn calls(items: &[Unjudged]) -> Vec<Vec<&Unjudged>> {
items.iter().map(|item| vec![item]).collect()
}
/// 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)]
struct Refused {
status: u16,
body: String,
}
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "TypeSafe answered {}: {}", self.status, self.body)
}
}
impl std::error::Error for Refused {}
fn status(e: &anyhow::Error) -> Option<u16> {
e.downcast_ref::<Refused>().map(|r| r.status)
}
/// Whether the batch was as long as `unjudged` gives, so more may be waiting. Its limit is on
/// rows, one for each item and Signal: with two Signals, 50 is 25 items, and comparing the items
/// with 50 took every batch for the last, 25 items every 5 minutes (#260).
fn full(items: &[Unjudged]) -> bool {
items.iter().map(|i| i.signals.len()).sum::<usize>() as i64 >= BATCH
}
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))
}
/// 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 = 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 = 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]);
}
// A question Jev left unanswered stays unjudged and is asked again.
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, 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> = asks.iter().map(|a| (key(a.id), question(a))).collect();
serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
}
fn key(id: i64) -> String {
format!("signal_{id}")
}
/// One subject, named by the Signal and described by its words and its owner's sentence. Asked
/// whether an item was about any one of its words, AI Development (ai, development, programming)
/// took a game's remaster at 0.90 and a C++ tutorial at 0.99; asked about the subject, 0.06 and
/// 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(", ")
);
if let Some(d) = &a.description {
said.push_str(&format!(" In their own words: {d}"));
}
serde_json::json!({
"type": "noul",
"instructions": format!("{said} Is this item about that subject?"),
"criteria": {
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
"false": "It is about something else, or touches the subject only in passing.",
},
})
}
/// Whether Jev says the item fits Signal `id`; None when the answer does not say.
fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
answer["answers"][key(id)]["noul"].as_f64().map(|p| p >= THRESHOLD)
}
/// An icon for a Signal, picked by Jev from the ones the page found for its words (#263): Jev
/// 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,
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 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?;
// 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);
}
}
Ok(answer)
}
/// 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}"));
}
let criteria: serde_json::Map<String, serde_json::Value> =
candidates.iter().map(|(id, label)| (id.clone(), label.clone().into())).collect();
serde_json::json!({
"model": "jev-latest",
"state": state,
"questions": { "icon": {
"type": "choice",
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
"criteria": criteria,
}},
})
}
/// Jev's choice, only if it is one it was offered.
fn chosen(answer: &serde_json::Value, candidates: &[(String, String)]) -> Option<String> {
let c = answer["answers"]["icon"]["choice"].as_str()?;
candidates.iter().find(|(id, _)| id == c).map(|(id, _)| id.clone())
}
/// Show notes are HTML: Jev is given their words, and an image's alt and title text, which is all
/// the words some posts have. xkcd's is a comic and its hover text; dropping the tag whole sent Jev
/// "Ground Effect" alone, an F1 aerodynamics term, and the comic went into Formula 1 (#267).
fn text_of(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut tag: Option<String> = None;
for c in html.chars() {
match (&mut tag, c) {
(None, '<') => tag = Some(String::new()),
(Some(t), '>') => {
let mut said: Vec<&str> = Vec::new();
for name in ["alt", "title"] {
if let Some(v) = attr(t, name).filter(|v| !v.trim().is_empty() && !said.contains(v)) {
said.push(v);
}
}
for v in said {
out.push(' ');
out.push_str(v);
}
out.push(' ');
tag = None;
}
(Some(t), c) => t.push(c),
(None, c) => out.push(c),
}
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// A quoted attribute's value in a tag's text, `alt="..."` or `alt='...'`.
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
let lower = tag.to_ascii_lowercase();
for q in ['"', '\''] {
let key = format!(" {name}={q}");
if let Some(at) = lower.find(&key) {
let rest = &tag[at + key.len()..];
return rest.find(q).map(|end| &rest[..end]);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_noul_of_a_half_or_more_is_a_fit() {
// As TypeSafe answered on 2026-10-16.
let answer = serde_json::json!({ "model": "jev-1.13.0", "answers": {
"signal_3": { "type": "noul", "noul": 0.5 }, "signal_4": { "type": "noul", "noul": 0.49 } } });
assert_eq!(hit(&answer, 3), Some(true));
assert_eq!(hit(&answer, 4), Some(false));
assert_eq!(hit(&answer, 5), None, "a question not answered is asked again");
}
#[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 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");
assert!(r["state"].as_str().unwrap().contains("In their words: AI for writing software."));
// As TypeSafe answered on 2026-10-16.
let answer = serde_json::json!({ "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34 } } });
assert_eq!(chosen(&answer, &offered).as_deref(), Some("solid/laptop-code"));
let stray = serde_json::json!({ "answers": { "icon": { "choice": "solid/skull" } } });
assert_eq!(chosen(&stray, &offered), None, "never one it was not offered");
}
#[test]
fn an_images_words_are_read_with_the_text() {
let xkcd = r#"<img src="https://imgs.xkcd.com/comics/ground_effect.png" title="Runners wear sneakers." alt="Runners wear sneakers." referrerpolicy="no-referrer">"#;
assert_eq!(text_of(xkcd), "Runners wear sneakers.", "alt and title once, when they say the same");
assert_eq!(text_of("<p>Qualifying <b>report</b></p><img alt='A grid'>"), "Qualifying report A grid");
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 one_persons_signals_never_share_a_call_with_anothers() {
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: Vec<Vec<i64>> = calls(&items).iter().map(|g| g.iter().map(|i| i.user_id).collect()).collect();
assert_eq!(shape, [vec![1], vec![2], vec![1]], "x is asked for each of them apart (#343)");
let mine: Vec<&Ask> = calls(&items)[0].iter().flat_map(|i| &i.signals).collect();
let r = request(&items[0], &mine);
assert!(r["questions"]["signal_10"].is_object() && r["questions"].get("signal_20").is_none());
}
#[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,
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..24).map(|_| item(2)).collect::<Vec<_>>()));
}
#[test]
fn each_signal_is_its_own_question_on_the_items_words() {
let item = Unjudged {
user_id: 1,
user_name: "a".into(),
key: Some("k".into()),
feed_id: "f".into(),
guid: "g".into(),
feed_title: Some("Pit Lane".into()),
title: Some("Monaco".into()),
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()),
},
],
};
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("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.")
);
}
}

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()
};
@@ -42,13 +39,15 @@ impl Torrents {
}
/// Downloads a torrent (a .torrent URL or a magnet link) straight into `dest_dir`,
/// seeds it up to the configured limit, then stops.
/// seeds it up to the configured limit, then stops. `dest_dir` is the torrent's own
/// folder (`own_folder`), and it is the path returned: a file of several or one.
///
/// Downloading in place rather than into a staging dir is deliberate: seeding serves
/// the files it downloaded, so moving them first would break it.
pub async fn fetch(
&self,
cfg: &Config,
client: &reqwest::Client,
url: &str,
dest_dir: &Path,
mut on_progress: impl FnMut(u64, u64),
@@ -56,6 +55,15 @@ impl Torrents {
tokio::fs::create_dir_all(dest_dir).await?;
let limit = stall_limit(cfg);
let source = if url.starts_with("http://") || url.starts_with("https://") {
// Under the stall budget as well: a body sent a byte a minute stays inside the cap.
let file = tokio::time::timeout(limit, torrent_file(client, url))
.await
.map_err(|_| anyhow::anyhow!("no .torrent after {} minutes, gave up", limit.as_secs() / 60))??;
AddTorrent::from_bytes(file)
} else {
AddTorrent::from_url(url)
};
// Resolving a magnet's metadata happens inside add_torrent, and against a dead
// swarm it never returns. The stall budget has to cover this phase too, or a
@@ -63,7 +71,7 @@ impl Torrents {
let added = tokio::time::timeout(
limit,
self.session.add_torrent(
AddTorrent::from_url(url),
source,
Some(AddTorrentOptions {
output_folder: Some(dest_dir.to_string_lossy().into_owned()),
overwrite: true,
@@ -72,12 +80,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 {
@@ -111,15 +114,14 @@ impl Torrents {
}
}
let name = handle.name().unwrap_or_else(|| "torrent".to_owned());
let path = handle.output_folder().join(&name);
// The torrent's own name is the publisher's to choose, and librqbit checks it only when it
// picks the folder itself: joined on, "/config" or ".." pointed Delete, the reaper and
// /media at anything the daemon can reach (#318). The folder is ours, so it is the path.
let path = dest_dir.to_owned();
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))
}
@@ -145,6 +147,30 @@ impl Torrents {
}
}
/// A .torrent file is metadata: a few hundred KB even for a large torrent.
const MAX_TORRENT_FILE: usize = 10 << 20;
/// Fetches a .torrent file, up to `MAX_TORRENT_FILE`. Handed the URL, librqbit read the whole body
/// into memory with no limit and no timeout, so a feed could serve one without end (#330).
async fn torrent_file(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
let mut resp = client.get(url).send().await.context("fetching the .torrent")?.error_for_status()?;
let mut out = Vec::new();
while let Some(c) = resp.chunk().await.context("reading the .torrent")? {
out.extend_from_slice(&c);
if out.len() > MAX_TORRENT_FILE {
bail!("the .torrent is larger than {} MB, too large to be one", MAX_TORRENT_FILE >> 20);
}
}
Ok(out)
}
/// The folder an enclosure's torrent downloads into, one per enclosure inside the feed's folder.
/// With `overwrite` on, a torrent written straight into the feed's folder could replace another
/// enclosure's files of the same name, another feed's too when they share a folder (#331).
pub fn own_folder(feed_dir: &Path, enclosure: i64) -> PathBuf {
feed_dir.join(format!("torrent-{enclosure}"))
}
/// Uploaded over total. A zero-byte torrent counts as fully seeded rather than dividing by zero.
pub fn ratio(uploaded: u64, total: u64) -> f64 {
if total == 0 {
@@ -165,6 +191,32 @@ mod tests {
assert!(ratio(0, 0).is_infinite(), "must not divide by zero or seed forever");
}
#[tokio::test]
async fn an_endless_torrent_file_stops_at_the_cap() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let Ok((mut sock, _)) = listener.accept().await else { return };
let _ = sock.read(&mut [0u8; 2048]).await;
// No length, no end: a body that goes on until the reader stops.
let _ = sock.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n").await;
let chunk = vec![b'd'; 64 << 10];
while sock.write_all(&chunk).await.is_ok() {}
});
let err = torrent_file(&reqwest::Client::new(), &format!("http://{addr}/ep.torrent")).await.unwrap_err();
assert!(err.to_string().contains("too large"), "{err:#}");
}
#[test]
fn own_folder_is_one_folder_inside_the_feeds() {
let feed = Path::new("/downloads/Show");
let dir = own_folder(feed, 42);
assert_eq!(dir.parent(), Some(feed));
assert!(matches!(dir.components().next_back(), Some(std::path::Component::Normal(_))));
assert_ne!(own_folder(feed, 43), dir, "two enclosures never share a folder");
}
#[test]
fn stall_limit_is_never_zero() {
let mut cfg = Config::default();

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

@@ -1,6 +1,7 @@
# A sample: ipx with its database in SQLite, and ipx-web, the page, in front of it. Build both
# images first (`docker build -t ipx .` and `docker build -f Dockerfile.web -t ipx-web .`), then
# `docker compose up -d`, and sign in at http://<host>:8099 as admin / ipodderx.
# `docker compose up -d`, and sign in at http://<host>:8099 as admin, with the password the first
# start writes to the config volume: `docker exec ipx cat /config/admin-password`.
# docs/configuration.md has the rest, Postgres included.
services:
ipx:

View File

@@ -41,6 +41,7 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web]
| `crates/ipx/src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
| `crates/ipx/src/directory.rs` | Asking the directory service, and sharing this server's public feeds with it | — |
| `crates/ipx/src/metrics.rs` | `/metrics` for Prometheus | — |
| `crates/ipx/src/signals.rs` | Signals: asks Jev, on each person's TypeSafe key, which of their items fit their Signals | — |
| `crates/directory/src/lib.rs` | The directory's HTTP API, its token check, the refresher | — |
| `crates/directory/src/store.rs` | The directory's database: feeds, snapshots, blocked | — |
| `crates/directory/src/config.rs` | The directory's config file: its tokens and their access | — |
@@ -60,7 +61,10 @@ and the files, and no page.
## A scan
1. Skip the feed unless `last_checked + max(schedule, ttl)` has passed (`--force` ignores this).
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there.
Without a schedule of its own, a feed whose body has been the same for a while waits longer:
a twelfth of how long it has been the same, up to four hours (#242).
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there, and so does a
body identical to the last one (`body_hash`), from a server that ignores the validators.
3. Sniff the body: RSS, then Atom, then OPML. An OPML is a live subscription — its feeds are
re-derived into the database each scan, never written to config.toml. A Patreon creator link
(a token, no `show=`) with more than one show is treated the same way, before any fetch: its
@@ -81,7 +85,8 @@ and the files, and no page.
```
feeds id, url, title, image, category, description, etag, last_modified,
last_checked, ttl_mins, last_error, orphaned, group_id, managed
last_checked, ttl_mins, last_error, orphaned, group_id, managed,
body_hash, same_since
entries feed_id, guid, title, link, published, description, first_seen,
image, duration, episode, season PK (feed_id, guid)
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
@@ -92,8 +97,17 @@ subscriptions user_id, feed_id, keywords, auto_download, allow_explicit,
max_new_per_check PK (user_id, feed_id)
entry_state user_id, feed_id, guid, read, flagged, position
PK (user_id, feed_id, guid)
signals id, user_id, name, keywords
signal_verdicts signal_id, feed_id, guid, hit PK (signal_id, feed_id, guid)
```
A Signal (#254, [signals.md](signals.md)) is a feed someone makes from their own subscriptions. `signals::run`, a task of its
own beside DeadAir's, woken by a scan with something new or a Signal made or changed, asks Jev
about each item of the last 30 days that has no verdict yet for one of its owner's Signals: one
call an item, a noul question a Signal, on the owner's `users.typesafe_key`. Every answer is kept,
fitting or not, so nothing is asked twice; new keywords drop the Signal's verdicts. A Signal's
items are `entries_where` with an `EXISTS` on its hits, so read state is the item's own.
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.

View File

@@ -31,13 +31,14 @@ Passwords are read from **stdin**, so they miss the shell history and any `ps` l
```sh
echo -n 'a good password' | ipx user add ray # local account
ipx user add ray@example.com --no-password # signs in through the proxy only
echo -n 'a good password' | ipx user passwd admin # change a password
echo -n 'a good password' | ipx user passwd admin # change a password; signs the account out everywhere
ipx user list # who exists, and how each signs in
ipx user rm sam # account, subscriptions and read state
```
The first account created is an admin; later ones are ordinary users. A database with no accounts
at all gets **admin / ipodderx** on the next daemon start, announced in the log — change it.
at all gets **admin** on the next daemon start, with a random password in `admin-password` beside
config.toml (or `IPX_ADMIN_PASSWORD`), announced in the log — change it.
To avoid even the command line, read it interactively:

View File

@@ -46,13 +46,17 @@ max_age_days = 30 # 0 = keep forever
max_new_per_check = 3 # per feed, per scan. 0 = unlimited
media_types = ["audio", "video"]
art_cache_mb = 500 # artwork kept on disk. 0 = none
fetch_internal = [] # internal addresses fetches may reach, e.g. ["192.168.1.0/24"]
```
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`, `media_types` and `art_cache_mb` move into the
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
database as described above; `download_dir`, `socket`, `organize` and `fetch_internal` stay in config.toml.
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
be polled *less* often, and a per-feed `schedule` overrides both. A feed that keeps failing
be polled *less* often, and a per-feed `schedule` overrides both. Without a schedule of its own,
a feed that sends the same body check after check (Patreon, Substack, which ignore "not
modified") is checked less often the longer it stays the same, up to every four hours, and is
back on schedule once it changes. A feed that keeps failing
waits as long as it has been failing before the next try, up to a day, and is back on schedule
after its first success. A moment's failure to look its host up does not count: the network, not
the feed, failed, and it is tried again in ten minutes. Admin-only from the UI.
@@ -74,6 +78,12 @@ database as described above; `download_dir`, `socket` and `organize` stay in con
* **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
without this the disk fills with artwork. Empty takes everything.
* **`fetch_internal`** — internal addresses and networks that feeds, episodes, artwork and
`.torrent` files may still be fetched from, as `["192.168.1.20", "10.0.0.0/8"]`. Anything off
the public internet (loopback, the LAN, Docker's networks, link-local) is refused otherwise,
redirects included, because a feed's publisher or anyone signed in chooses what is fetched. List
addresses, not names: a feed on `nas.lan` needs `nas.lan`'s address here. The directory is not
affected. Empty by default; read at start.
## `[torrent]`
@@ -150,6 +160,9 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
and are the same for everyone. See [users.md](users.md).
`username` and its password go to the feed's own host alone: not to episodes on another host, nor to
the feeds an OPML lists on other hosts, which would hand the password to whoever runs them.
**The Directory** is the directory service's ([directory.md](directory.md)), which an admin sets
up in Admin, Directory: its address, this server's token and whether to share. They are server
settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.
@@ -162,13 +175,18 @@ 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
| Variable | Effect |
|---|---|
| `IPX_CONFIG` | Config file path |
| `IPX_ADMIN_PASSWORD` | The first admin's password, for a database with no accounts; random, in `admin-password` beside config.toml, without it |
| `IPX_DATA_DIR` | Directory holding `state.db` |
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
@@ -188,6 +206,7 @@ The page's container reads only its environment (`web/Caddyfile`):
| `WEB_BIND` | Where to listen; `:8080` by default |
| `WEB_ROOT` | The built page; `/srv` in the image |
| `TUNNEL_PEER` | The one address whose `Cf-Access-*` headers are passed on to ipx: where cloudflared's requests come from. Unset passes none. See [sso.md](sso.md) |
| `TRUSTED_HEADER` | ipx's `[web] trusted_header`, when it is not Cloudflare's: it is passed on from `TUNNEL_PEER` alone too, and dropped from anyone else. Set it to the same name, or anyone could sign in as anyone |
| `WEB_TRACING` | `on` sends a span for each request to the collector the `OTEL_*` variables name (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`, `OTEL_SERVICE_NAME`), continuing the page's trace into ipx. Off by default |
ipx-directory's settings are in [directory.md](directory.md).

View File

@@ -2,7 +2,7 @@
`ipx-directory` is a catalogue of public podcasts and blogs that ipx servers share (#204). An ipx
server sends it the public feeds its people subscribe to, and how many of them do. The directory
fetches each feed's title, artwork, category, description and latest twenty items every six hours,
fetches each feed's title, artwork, category, description and latest twenty items every twelve hours,
and any server holding a token can browse it. It never downloads a file, and it never stores
one's address: every ipx server still fetches its own feeds.
@@ -81,6 +81,11 @@ bind = "0.0.0.0:8100"
# Prometheus metrics, on a port of their own and not the one the tunnel publishes. Empty, or
# left out, serves none.
metrics_bind = "0.0.0.0:9188"
# Apple Podcasts' top charts for this country, weekly (see below). Empty, or left out: none.
apple_country = "ca"
# Internal addresses and networks feeds may be fetched from, as ["10.0.0.5", "192.168.1.0/24"].
# Empty, or left out: nothing off the public internet is fetched (#329).
fetch_internal = []
# The environment variable holding the database URL: postgres://... or sqlite://...
database_url_env = "DIRECTORY_DATABASE_URL"
@@ -100,6 +105,23 @@ production does with `deployment.environment.name=production`. Its version is in
`crates/directory/Cargo.toml`, logged at start and answered by `ipx-directory --version`; its
changelog is `crates/directory/CHANGELOG.md`.
## Apple Podcasts
With `apple_country` set, the directory takes Apple Podcasts' top charts for that country once a
week (#252): the top 100 overall and the top 50 of each of Apple's categories, about a thousand
shows. Apple's lookup API gives each one's RSS address, and they go in as a snapshot of their own,
`apple-podcasts`, with no subscribers: fetched, sorted into categories and listed like any other
feed, while the most subscribed stay what the servers sharing here subscribe to. A show Apple drops
from its charts leaves the directory at the next sync, unless a server shares it. An admin can drop
the snapshot as any server's (`DELETE /v1/instances/apple-podcasts`); the next sync brings it back.
No token may be named `apple-podcasts`.
The same sync compares Apple's list of categories with the one ipx keeps (`CATEGORIES` in
`crates/feed`) and logs a warning naming what differs, for whoever checks the log to update it.
It runs at start when the last sync is a week old, and a failed one tries again an hour later. Each
sync is a trace (`apple_sync`, a span for each of Apple's answers), and a line `Apple Podcasts
synced` says how many shows came and went in.
## Running one
```yaml

415
docs/signals.md Normal file
View File

@@ -0,0 +1,415 @@
# Signals
A Signal is a feed someone makes from their own subscriptions (#254). They give it a name, the
words to listen for (`formula 1`, `f1`) and, when those are not enough, a sentence saying what they
mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and picks out the ones
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.
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.** 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.
- **An icon of its own** (#261), chosen in the Signal's dialog from all of Font Awesome Free 7.3.1,
the set the page's own icons come from: 2,883 icons, solid, regular and brands, searched by name
and Font Awesome's search terms ("ai" finds the robot). Without one, a Signal shows its initials.
**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, 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, 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, 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
```mermaid
sequenceDiagram
autonumber
participant W as Scan worker
participant J as Signals judge<br/>(signals::run)
participant DB as Database
participant T as TypeSafe<br/>api.typesafe.ai
participant P as The page
W->>DB: new entries stored
W->>J: ctx.signals.notify_one()
Note over J: also woken by a new Signal,<br/>new keywords, a key saved,<br/>or every 5 minutes
J->>DB: unjudged(skip refused keys, 50)
DB-->>J: items, each with the Signals<br/>that have not heard of it
loop each item
J->>T: POST /v1/systemone<br/>one noul question per Signal
T-->>J: answers: noul 0..1 per question
J->>DB: record_verdicts (hit = noul >= 0.5)
end
J-->>P: signal_hits event (SSE), once a pass per person
P->>DB: GET /api/signals (counts),<br/>and the open Signal's list
```
The judge is a task of its own beside DeadAir's (`crates/ipx/src/signals.rs`), not a job on the
scan worker, so a slow TypeSafe never holds up a scan.
### Which items are asked about
`Db::unjudged` finds, newest first, up to 50 items that are:
- in a feed the Signal's owner subscribes to,
- not hidden from them by their block lists,
- 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.
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. Two people's Signals never share a call, even on the server's key, where they did until
#343: one person's description was then in the question that decided the other's verdicts. 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 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
Jev is TypeSafe's System One model, already used to pick a feed's Directory category
(`feed::classify`). The address is `feed::JEV_URL`, `https://api.typesafe.ai/v1/systemone`.
Signals use the **noul** question type, which answers how likely a statement is true, from 0 to 1.
Questions in one call are judged independently and in parallel.
### A request
Sent with `Authorization: Bearer <the key>`, the server's or the person's, for an item and two of their Signals:
```json
{
"model": "jev-latest",
"state": "Feed: Pit Lane\nTitle: Monaco qualifying\n\nQualifying report from the Monaco Grand Prix.",
"questions": {
"signal_3": {
"type": "noul",
"instructions": "The reader follows a subject they call \"F1\", described by these words: formula 1, f1. Is this item about that subject?",
"criteria": {
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
"false": "It is about something else, or touches the subject only in passing."
}
},
"signal_4": {
"type": "noul",
"instructions": "The reader follows a subject they call \"Food\", described by these words: baking. In their own words: Bread and cakes, not restaurants. Is this item about that subject?",
"criteria": {
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
"false": "It is about something else, or touches the subject only in passing."
}
}
}
}
```
- `state` is text: the feed's title, the item's title, and its show notes with the HTML taken out,
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).
- **One subject, not any word.** The first question was "Is this item about any of these: ai,
development, programming?", so one broad word was enough: AI Development took Sega pulling a game
remaster (0.90), a Surface Laptop review (0.95) and a C++ tutorial (0.99), 54 items in all. Asked
about the subject, with the sentence "AI tools for writing software: coding agents, AI dev tools,
vibe coding, AI in code review.", those scored 0.06, 0.16 and 0.02, and 5 of the 54 stayed in,
AI code review in the Linux kernel highest at 0.94 (#259).
### Its answer
What TypeSafe answered to a request of this shape, on 2026-10-16:
```json
{
"model": "jev-1.13.0",
"answers": {
"signal_3": { "type": "noul", "noul": 0.96 },
"signal_4": { "type": "noul", "noul": 0.02 }
},
"usage": { "input_tokens": 389, "output_tokens": 40 }
}
```
0.5 or more is a fit (`THRESHOLD`): the item goes into Signal 3 and not Signal 4. A question
missing from the answer is left unjudged and asked again next pass.
### When TypeSafe says no
| TypeSafe answers | What the judge does |
|---|---|
| 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:
```
TypeSafe answered 422: {"detail":[{"type":"model_attributes_type","loc":["body","questions","3","noul","criteria"],"msg":"Input should be a valid dictionary or object to extract fields from","input":"Yes if the subject is one of them."}]}
```
## The API
Every route is the signed-in person's own. A Signal of someone else's is not found, as one that
does not exist.
### `GET /api/signals`
```json
[
{ "id": 1, "name": "AI Development", "keywords": ["ai", "development", "programming"],
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
"icon": "solid/laptop-code", "items": 48, "unread": 5 },
{ "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "description": null, "icon": null, "items": 272, "unread": 0 }
]
```
`items` counts the items Jev picked out that are still in a subscribed feed and not hidden, what
its list shows; `unread` those of them not read.
### `POST /api/signals`
```json
{ "name": "Formula 1", "keywords": ["formula 1", "f1"], "description": "Grand prix racing, not road cars." }
```
Answers `{ "id": 3 }` and wakes the judge. A name is 1 to 100 characters. Between 1 and 50 words
are allowed, each up to 200 characters, kept once whatever their case. `description` is optional,
up to 500 characters. Anything else is a `400` saying so.
### `PATCH /api/signals/{id}`
Any of the three: `{ "name": "F1" }`, `{ "keywords": ["f1", "grand prix"] }`,
`{ "description": null }` (null or empty clears it), `{ "icon": "solid/robot" }`. A change to the
name, words or sentence drops the Signal's verdicts and wakes the judge, since each is part of the
question; a new icon does not. The page sends only what changed. An icon is `solid/`, `regular/` or
`brands/` and a plain name, since the page puts it into an address: anything else is a `400`. Answers `204`, or `404` for a Signal that is not yours.
### `DELETE /api/signals/{id}`
Answers `204`. The Signal and its verdicts go; the items stay in their feeds.
### `GET /api/signals/{id}/entries`
The same query as a feed's list, `offset`, `limit`, `filter`, `q`, `sort`, `dir`:
```
GET /api/signals/3/entries?filter=unread&sort=published&dir=desc&limit=50
```
```json
{
"total": 1,
"entries": [
{
"guid": "sig-1", "feed_id": "pit-lane", "title": "Monaco qualifying",
"link": "https://pitlane.example/monaco", "published": 1792170000,
"description": "<p>Qualifying report…</p>", "read": false, "flagged": false,
"image": null, "duration": null, "episode": null, "season": null, "position": 0,
"enclosures": []
}
]
}
```
Reading or pinning one is the item's own route, `POST /api/entries/{feed_id}/{guid}/flags`, as
anywhere else.
### `PATCH /api/me`, the key
```json
{ "typesafe_key": "ts_…" }
```
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`
Jev's pick of an icon, on the **server's** `TYPESAFE_KEY`, not the person's: it is the page's own
doing, once a Signal. Jev chooses among options and cannot name one, so the page searches Font
Awesome's index for each word of the name, words and sentence, the best six each, and sends up to 40:
```json
{
"name": "AI Development",
"keywords": ["ai", "development", "programming"],
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
"candidates": [["solid/robot", "Robot: ai, android, bot, machine"], ["solid/laptop-code", "Laptop Code: computer, cpu, dell, demo"], ["solid/code", "Code: brackets, code, development, html"]]
}
```
ipx asks a **choice** question, each candidate a choice and its label and terms what it means:
```json
{
"model": "jev-latest",
"state": "Signal: AI Development\nListens for: ai, development, programming\nIn their words: AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
"questions": { "icon": {
"type": "choice",
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
"criteria": { "solid/robot": "Robot: ai, android, bot, machine", "solid/laptop-code": "Laptop Code: computer, cpu, dell, demo" }
} }
}
```
TypeSafe answered, on 2026-10-16, for six candidates:
```json
{ "model": "jev-1.13.0", "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34,
"probabilities": { "solid/robot": 0.4, "solid/code": 0.14, "solid/laptop-code": 0.45, "solid/brain": 0.01, "solid/microchip": 0.0, "solid/terminal": 0.0 } } } }
```
The answer is `{ "icon": "solid/laptop-code" }`, only ever one it was offered. Each candidate has to
be an icon name the API takes, 1 to 60 of them. `404` when the server has no key, `502` when Jev
fails (logged: "Jev could not pick an icon"). The call has a `jev_icon` span.
### The event
Once a pass, for each person it found items for, the judge sends this on the event stream
(`/api/events`):
```json
{ "ev": "signal_hits", "user": 2, "hits": 3 }
```
A page with Signals fetches `/api/signals` again, and the open Signal's list.
## Data
```mermaid
erDiagram
users ||--o{ signals : owns
signals ||--o{ signal_verdicts : "has heard of"
entries ||--o{ signal_verdicts : "judged in"
users {
bigint id
text typesafe_key "NULL: no key"
}
signals {
bigint id
bigint user_id
text name
text keywords "JSON array"
text description "NULL: none"
text icon "solid/robot, NULL: initials"
bigint created
}
signal_verdicts {
bigint signal_id "PK"
text feed_id "PK"
text guid "PK"
bool hit
}
```
- `signals` and `signal_verdicts` are created from their entities like every table
(`crates/ipx/src/entity.rs`). `users.typesafe_key`, `signals.description` and `signals.icon`
were added to existing tables, so `create_missing` adds each column when it is missing.
- The icons are files of ipx-web's, not the database's: `web/build.mjs` copies Font Awesome Free's
SVGs to `/icons/<style>/<name>.svg`, kept a day, and writes `/icons/index.json`, names, labels
and search terms, about 90 KB compressed, named by its hash and fetched when the picker first
opens. A Signal's tile draws its one file as a CSS mask, so it takes the theme's colour.
- A Signal's list is `entries_where` with
`EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = $n AND v.feed_id = e.feed_id AND v.guid = e.guid AND v.hit)`
on top of the subscribed-feeds scope. So filters, search, sort, block lists and read state all
work as for All Subscriptions.
- A verdict for a Signal deleted while Jev was being asked is not stored. Postgres's foreign key
would refuse it, and SQLite would keep it for nothing.
## Watching it
| | |
|---|---|
| Traces | A `signals` span for each pass (`entries`), and a `jev` span for each call (`user`, `signal_count`, `http.response.status_code`, and TypeSafe's reply as `error` when it refuses). |
| Log, counted | `ev="signal"`, "an item fits a Signal", for each item with a fit: `user`, `feed`, `new` (how many of their Signals it fits). |
| Log, warnings | The three in the table above. |
| Events | `signal_hits` on the socket and `/api/events`. |
## Limits
- **The key is kept in plain text** in `users.typesafe_key`, because every call needs it whole. It
should be encrypted with a server key if the database is ever shared with anything else.
- **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.
- **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
- `cargo test -p ipx`: the request's shape and the threshold (`signals::tests`), and the queue,
verdicts, ownership, unread counts and new keywords (`db::tests::a_signal_shows_what_jev_said_fits_and_only_to_its_owner`),
on SQLite and Postgres both.
- `node --test tests/page-unit.js`: a Signal's place id and its list's address.
- `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 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

@@ -51,8 +51,9 @@ Three ways, tried in order of how specific the claim is:
3. **The shared `[web] token`**, which signs in as the admin — this is what the Docker healthcheck
and any scripts use.
A database with no accounts creates **admin / ipodderx** on the next daemon start and says so in
the log. Change it:
A database with no accounts creates **admin** on the next daemon start, with the password in
`IPX_ADMIN_PASSWORD` if it is set, or a random one written to `admin-password` beside config.toml,
readable by ipx's user alone. The log says where the password is, never what it is. Change it:
```sh
echo -n 'a good password' | ipx user passwd admin

View File

@@ -51,8 +51,12 @@ pid = 0
def panel(kind, title, w, h, x, targets, **extra):
global pid
pid += 1
sources = {t.get("datasource", LOKI)["uid"] for t in targets}
# A panel with queries on more than one data source must be Mixed, or Grafana runs them all on
# the panel's and Downloads, Loki's and Prometheus's, failed (#226).
ds = {"type": "datasource", "uid": "-- Mixed --"} if len(sources) > 1 else targets[0].get("datasource", LOKI)
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
"datasource": targets[0].get("datasource", LOKI), "targets": targets}
"datasource": ds, "targets": targets}
p.update(extra)
panels.append(p)
return p
@@ -181,6 +185,15 @@ ts("Page response time", [loki(f'quantile_over_time(0.95, {WEB} | unwrap duratio
loki(f'max_over_time({WEB} | unwrap duration [$__interval]) by ()', ref="B", legend="slowest")],
16, w=8, unit="s", desc="A page waits on ipx's /api/page-check before it is sent.")
y += 8
# What the person waits for, from the page itself (#246): a click to the list drawn.
LIST = SEL + ' |= "\\"ev\\":\\"list_shown\\"" | json | __error__=""'
ts("A click to the list shown", [loki(f'quantile_over_time(0.95, {LIST} | unwrap ms [$__interval]) by ()', legend="95th percentile"),
loki(f'quantile_over_time(0.5, {LIST} | unwrap ms [$__interval]) by ()', ref="B", legend="median"),
loki(f'max_over_time({LIST} | unwrap ms [$__interval]) by ()', ref="C", legend="slowest")],
0, w=12, unit="ms", desc="Measured in the browser, network and drawing included. The API's part is in the click's trace.")
table("Slowest lists by view", [loki(f'topk(10, max_over_time({LIST} | unwrap ms [$__range]) by (feed, filter))', instant=True)],
12, rename={"feed": "View", "filter": "Filter", "Value": "Slowest (ms)"}, sort=[{"displayName": "Slowest (ms)", "desc": True}])
y += 8
# ---- ipx: the API it answers behind ipx-web
row("ipx: API")
@@ -241,18 +254,77 @@ 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
# pressure; drawn as a line of its own it read as the pool maxing out (#199).
ts("Database connections", [prom('max(max_over_time(ipx_db_connections{job="ipx", state="in_use"}[$__interval]))', legend="in use"),
prom('sum(ipx_db_connections{job="ipx"})', ref="B", legend="open"),
prom('max(ipx_db_connections_max{job="ipx"})', ref="C", legend="most the pool opens")],
0, w=8, desc="In use reaching the most the pool opens means requests and scans are waiting for a connection. "
"Open connections stay open, idle, for ten minutes after a burst.")["fieldConfig"]["overrides"] = [
{"matcher": {"id": "byName", "options": "open"},
"properties": [{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [4, 4]}},
{"id": "custom.fillOpacity", "value": 0}, {"id": "color", "value": {"mode": "fixed", "fixedColor": "#8e8e8e"}}]}]
prom('min(min_over_time(ipx_db_connections{job="ipx", state="idle"}[$__interval]))', ref="B", legend="ready"),
prom('max(ipx_db_connections_max{job="ipx"})', ref="C", legend="the pool")],
0, w=8, desc="The daemon keeps the whole pool open (#241), so in use and ready add up to the pool. In use reaching "
"the pool, ready at 0, means requests and scans are waiting for a connection (#251).")
ts("Memory", [prom('ipx_process_resident_bytes{job="ipx"}', legend="resident")], 8, w=8, unit="bytes")
ts("Open files and sockets", [prom('ipx_process_open_fds{job="ipx"}', legend="open")], 16, w=8)
y += 8
@@ -275,7 +347,7 @@ row("ipx-directory")
stat("Snapshots", loki(f'sum(count_over_time({DIR} | message="snapshot" [$__range])) or vector(0)', instant=True), 0,
desc="ipx sends one when what it shares changes, and every half hour (#211).")
stat("Feed checks", loki(f'sum(sum_over_time({DIR} | message="feeds checked" | unwrap checked [$__range])) or vector(0)', instant=True), 4,
desc="Each feed every six hours, 25 at a time.")
desc="Each feed every twelve hours, five at a time.")
stat("Failed feed checks", loki(f'sum(count_over_time({DIR} | message="feed check failed" [$__range])) or vector(0)', instant=True), 8,
thresholds=GOOD_BAD, desc="Mostly the publishers': a feed gone or moved.")
stat("Requests", loki(f'sum(count_over_time({DIR_HTTP} [$__range])) or vector(0)', instant=True), 12)

12
package-lock.json generated
View File

@@ -1,11 +1,14 @@
{
"name": "ipx-web",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ipx-web",
"version": "1.3.0",
"dependencies": {
"@fortawesome/fontawesome-free": "7.3.1",
"@swc/core": "^1.16.2",
"@swc/html": "^1.16.2",
"hls.js": "1.6.19"
@@ -15,6 +18,15 @@
"typescript": "^7.0.2"
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "ipx-web",
"version": "1.0.1",
"version": "1.3.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": {
@@ -16,6 +16,7 @@
"typescript": "^7.0.2"
},
"dependencies": {
"@fortawesome/fontawesome-free": "7.3.1",
"@swc/core": "^1.16.2",
"@swc/html": "^1.16.2",
"hls.js": "1.6.19"

View File

@@ -60,6 +60,10 @@ 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

@@ -29,4 +29,4 @@
bind = "0.0.0.0:8100"
database_url_env = "DIRECTORY_DATABASE_URL"</code></pre>
<pre><code>docker exec ipx-directory ipx-directory token new my-server --access rw</code></pre>
<p>That prints the token, to give to the server, and the lines to add to <code>directory.toml</code>, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every six hours.</p>
<p>That prints the token, to give to the server, and the lines to add to <code>directory.toml</code>, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every twelve hours.</p>

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

@@ -11,6 +11,7 @@ const blocks = {};
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
const vars = {};
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
const mix = m[2].match(/--fieldmix:\s*(\d+)%/); if (mix) vars.fieldmix = +mix[1] / 100;
if (Object.keys(vars).length) blocks[m[1]] = vars;
}
@@ -49,6 +50,21 @@ for (const sel of Object.keys(blocks)) {
}
themes['modern'] = base;
// --field, a text box's and a switch's edge, is --fg mixed into --bg in OKLab, as color-mix() does.
const oklab = h => {
const [r, g, b] = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
const [l, m, s] = [.4122214708 * r + .5363325363 * g + .0514459929 * b, .2119034982 * r + .6806995451 * g + .1073969566 * b, .0883024619 * r + .2817188376 * g + .6299787005 * b].map(Math.cbrt);
return [.2104542553 * l + .793617785 * m - .0040720468 * s, 1.9779984951 * l - 2.428592205 * m + .4505937099 * s, .0259040371 * l + .7827717662 * m - .808675766 * s];
};
const fromOklab = ([L, a, b]) => {
const [l, m, s] = [L + .3963377774 * a + .2158037573 * b, L - .1055613458 * a - .0638541728 * b, L - .0894841775 * a - 1.291485548 * b].map(x => x ** 3);
return '#' + [4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, -.0041960863 * l - .7034186147 * m + 1.707614701 * s]
.map(c => Math.min(1, Math.max(0, c)) <= .0031308 ? 12.92 * Math.max(0, c) : 1.055 * Math.min(1, c) ** (1 / 2.4) - .055)
.map(c => Math.round(c * 255).toString(16).padStart(2, '0')).join('');
};
for (const p of Object.values(themes)) { const f = oklab(p.fg), g = oklab(p.bg), k = p.fieldmix; p.field = fromOklab(f.map((v, i) => v * k + g[i] * (1 - k))); }
RULES.push(['field', ['bg', 'panel', 'panel2', 'raise'], 3]); // a text box's or a switch's edge, wherever one sits
let bad = 0;
for (const [name, p] of Object.entries(themes)) {
for (const [fg, grounds, min] of RULES) for (const g of grounds) {

View File

@@ -16,7 +16,7 @@ function makeContext(html, script, { media = false } = {}) {
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
setAttribute(){}, getAttribute(){ return null; }, removeAttribute(){}, contains(){ return false; }, select(){}, setSelectionRange(){}, focus(){},
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });

View File

@@ -23,8 +23,6 @@ export const options = {
'http_req_duration{name:all-entries}': p95(50, 100),
'http_req_duration{name:feed-entries}': p95(50, 100),
'http_req_duration{name:search}': p95(60, 100),
'http_req_duration{name:directory}': p95(250, 200),
'http_req_duration{name:listed}': p95(300, 200),
},
};
@@ -44,11 +42,7 @@ export default function () {
const found = http.get(`${BASE}/api/entries?q=weather&limit=50`, as(u, 'search'));
check(found, { 'a search of everything you read': r => r.status === 200 && r.json().total === 30 * ITEMS });
const dir = http.get(`${BASE}/api/directory`, as(u, 'directory'));
check(dir, { 'the whole Directory': r => r.status === 200 && r.json().length === FEEDS });
const listed = http.get(`${BASE}/api/directory/${feedId(Math.floor(Math.random() * FEEDS))}`, as(u, 'listed'));
check(listed, { "a feed's page in the Directory": r => r.status === 200 && r.json().items.length === ITEMS });
// Not the Directory: since #204 it is ipx-directory's, which this harness does not start (#245).
sleep(1 + Math.random() * 2);
}

View File

@@ -134,6 +134,8 @@ download_dir = "${root}/downloads"
socket = "${root}/ipx.sock"
schedule = "every 60m"
max_new_per_check = 1
# The generated feeds are on loopback, which ipx refuses otherwise (#320).
fetch_internal = ["127.0.0.1"]
[torrent]
enabled = false
@@ -189,7 +191,8 @@ async function seed() {
}
// The media test's listener takes the feeds with files. They may be downloaded already: the
// first start subscribes the admin to the whole catalogue, so the first scan fetched them.
for (let n = 0; n < MEDIA; n++) await api('listener', `/api/popular/${feedId(n)}`, { method: 'POST' });
// By OPML as the others: /api/popular asks the directory, which this harness does not start (#245).
await api('listener', '/api/opml', { method: 'POST', body: JSON.stringify({ xml: opml(Array.from({ length: MEDIA }, (_, n) => n)) }) });
for (let t = Date.now(); ; await new Promise(r => setTimeout(r, 500))) {
const got = await api('listener', '/api/entries?filter=downloaded&limit=50');
if (got.entries.flatMap(e => e.enclosures).filter(x => x.path).length >= MEDIA) break;

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 {

230
tests/page-unit.js Normal file
View File

@@ -0,0 +1,230 @@
// Unit tests for the page's own functions (#234): the script as shipped, loaded into the stub DOM
// page-smoke.js uses, and its helpers called directly. The browser suite tests what people do;
// this tests the small pieces it is made of, in a second, with nothing running.
//
// node --test tests/page-unit.js
//
// The files in web/src are one script, not modules. Its top-level `function`s are properties of
// the context; its top-level `const`s are not, but a script run in the same context sees them, as
// a second <script> on a page would, so `run` evaluates an expression there.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { makeContext, vm } = require('./dom-stub.js');
const { buildPage } = require('../web/build.mjs');
const { html, js } = buildPage('index.html');
const { ctx } = makeContext(html, js);
vm.createContext(ctx);
vm.runInContext(js, ctx, { filename: 'index.html<script>', timeout: 5000 });
const run = expr => vm.runInContext(expr, ctx);
// Time as the page sees it, for the helpers that count from now.
const NOW = 1_800_000_000;
run(`Date.now = () => ${NOW * 1000}`);
test('esc makes text safe to put in markup and attributes', () => {
assert.equal(run(`esc('<a href="x">Tom & Jerry\\'s</a>')`), '&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&#39;s&lt;/a&gt;');
assert.equal(run('esc(null)'), '');
assert.equal(run('esc(undefined)'), '');
});
test('webUrl keeps only web addresses from a feed', () => {
assert.equal(run(`webUrl('https://example.com/a')`), 'https://example.com/a');
assert.equal(run(`webUrl('HTTP://example.com/')`), 'HTTP://example.com/');
for (const u of ['javascript:alert(1)', ' javascript:alert(1)', 'data:text/html,x', 'vbscript:x', '//example.com', ''])
assert.equal(run(`webUrl(${JSON.stringify(u)})`), null, u);
assert.equal(run('webUrl(null)'), null);
});
test('clock reads seconds as a player does', () => {
assert.equal(run('clock(0)'), '0:00');
assert.equal(run('clock(65)'), '1:05');
assert.equal(run('clock(3661)'), '1:01:01');
assert.equal(run('clock(59.9)'), '0:59');
assert.equal(run('clock(-5)'), '0:00');
assert.equal(run('clock(undefined)'), '0:00');
});
test('mb sizes a file in the unit that reads best', () => {
assert.equal(run('mb(0)'), '');
// An article's small image is not "0 KB" or "0 MB".
assert.equal(run('mb(100)'), '1 KB');
assert.equal(run('mb(2048)'), '2 KB');
assert.equal(run('mb(5 * 1048576)'), '5 MB');
assert.equal(run('mb(1.5 * 1073741824)'), '1.5 GB');
});
test('ago says how long since, and never for nothing', () => {
assert.equal(run('ago(0)'), 'never');
assert.equal(run(`ago(${NOW - 10})`), '1m ago');
assert.equal(run(`ago(${NOW - 45 * 60})`), '45m ago');
assert.equal(run(`ago(${NOW - 5 * 3600})`), '5h ago');
assert.equal(run(`ago(${NOW - 3 * 86400})`), '3d ago');
// Past a month, a date: whatever the locale, it names the year.
assert.match(run(`ago(${NOW - 90 * 86400})`), /2026/);
});
test('due says how long until a feed is checked', () => {
assert.equal(run(`due(${NOW - 1})`), 'due now');
assert.equal(run(`due(${NOW + 10})`), 'in 1m');
assert.equal(run(`due(${NOW + 90 * 60})`), 'in 2h');
assert.equal(run(`due(${NOW + 3 * 86400})`), 'in 3d');
});
test('DeadAir savings read as time', () => {
assert.equal(run('spanText(40)'), '40 s');
assert.equal(run('spanText(12 * 60 + 5)'), '12 min');
assert.equal(run('spanText(3 * 3600 + 12 * 60)'), '3 h 12 min');
assert.equal(run('shorter(45)'), '45 s shorter');
assert.equal(run('shorter(150)'), '3 min shorter');
});
test('initials and plural', () => {
assert.equal(run(`initials('The Verge')`), 'TV');
assert.equal(run(`initials('accidental tech podcast')`), 'AT');
assert.equal(run(`initials('')`), '?');
assert.equal(run(`initials('!!!')`), '?');
assert.equal(run(`plural(1, 'feed')`), '1 feed');
assert.equal(run(`plural(0, 'feed')`), '0 feeds');
assert.equal(run(`plural(2, 'feed')`), '2 feeds');
});
test("kindOf names a file by its type, or else its address's extension", () => {
const kind = enc => run(`kindOf(${JSON.stringify(enc)})`);
assert.equal(kind({ mime: 'image/png' }), 'image');
assert.equal(kind({ mime: 'Video/MP4' }), 'video');
assert.equal(kind({ mime: 'audio/mpeg' }), 'audio');
assert.equal(kind({ mime: 'application/pdf' }), 'pdf');
assert.equal(kind({ mime: 'application/x-bittorrent' }), 'torrent');
assert.equal(kind({ url: 'https://x/ep.MP3?token=1' }), 'mp3');
assert.equal(kind({ url: 'https://x/download' }), 'file');
assert.equal(kind({ url: 'https://x/a.toolongext' }), 'file');
assert.equal(kind({}), 'file');
});
test('an item without a title is named by its file', () => {
const name = e => run(`entryName(${JSON.stringify(e)})`);
assert.deepEqual({ ...name({ title: 'Episode 1' }) }, { text: 'Episode 1', derived: false });
assert.deepEqual({ ...name({ title: ' ', enclosures: [{ url: 'https://x/My%20Show%20101.mp3?x=1' }] }) },
{ text: 'My Show 101', derived: true });
// A name that does not decode is shown as it is, not dropped.
assert.deepEqual({ ...name({ enclosures: [{ url: 'https://x/100%.mp3' }] }) }, { text: '100%', derived: true });
});
test('the directory sorts by name as people read, and by subscribers', () => {
const sorted = (list, by) => run(`${JSON.stringify(list)}.sort(${by}).map(p => p.title)`);
assert.deepEqual([...sorted([{ title: 'ep 10' }, { title: 'Ep 2' }, { title: 'ep 1' }], 'byName')], ['ep 1', 'Ep 2', 'ep 10']);
assert.deepEqual([...sorted([{ title: 'B', subscribers: 1 }, { title: 'C', subscribers: 5 }, { title: 'A', subscribers: 1 }], 'bySubs')],
['C', 'A', 'B']);
});
test('feeds with something unread go first, in the order they came', () => {
const ids = run(`[{id:'a',unread:0},{id:'b',unread:3},{id:'c',unread:0},{id:'d',unread:1}].sort(unreadFirst).map(f => f.id)`);
assert.deepEqual([...ids], ['b', 'd', 'a', 'c']);
});
test('words for filters split on commas, blanks dropped', () => {
assert.deepEqual([...run(`splitWords(' one, two words ,, three ')`)], ['one', 'two words', 'three']);
assert.deepEqual([...run(`splitWords('')`)], []);
});
test("a file plays from DeadAir's playlist when it has one", () => {
assert.equal(run(`mediaSrc({ id: 7, deadair: null })`), '/media/7');
assert.equal(run(`mediaSrc({ id: 7 })`), '/media/7');
assert.equal(run(`mediaSrc({ id: 7, deadair: 0 })`), '/media/7/deadair.m3u8');
});
test('kept lists: the newest few dozen, and a changed feed forgets its own and All Subscriptions\'', () => {
run('lists.clear()');
for (let i = 0; i < 45; i++) run(`keepList(listKey('f${i}'), {total: 0, entries: []})`);
assert.equal(run('lists.size'), 40, 'never more than LISTS_KEPT');
assert.equal(run(`lists.has(listKey('f0'))`), false, 'the oldest go first');
run(`keepList(listKey('f10'), {total: 1, entries: []})`);
run(`keepList(listKey(':all'), {total: 0, entries: []}); keepList(listKey(':all', 'unread'), {total: 0, entries: []})`);
run(`forgetLists('f10')`);
assert.equal(run(`lists.has(listKey('f10'))`), false);
assert.equal(run(`lists.has(listKey(':all')) || lists.has(listKey(':all', 'unread'))`), false, 'All Subscriptions holds its items too');
assert.equal(run(`lists.has(listKey('f11'))`), true, 'the rest stay');
});
test('a Patreon feed is told by its address', () => {
assert.equal(run(`isPatreon({ url: 'https://www.patreon.com/rss/show?auth=x' })`), true);
assert.equal(run(`isPatreon({ url: 'https://example.com/feed' })`), false);
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);
assert.equal(run(`signalId(':all')`), null);
assert.equal(run(`signalId('signal-7')`), null, 'a feed id is never a Signal');
assert.equal(run(`signalOf(':signal:7').name`), 'F1');
assert.equal(run(`signalOf(':signal:8')`), undefined);
assert.match(run(`listUrl(':signal:7')`), /^\/api\/signals\/7\/entries\?/);
run(`S.signals = []`);
});
test('the icon picker finds icons by the start of their words, the one named it first', () => {
const all = JSON.stringify([
['solid/caret-down', 'Caret Down', 'arrow'], ['solid/scarf', 'Scarf', 'clothing'], ['solid/taxi', 'Taxi', 'cab car'],
['solid/car-side', 'Car Side', 'auto vehicle'], ['solid/car', 'Car', 'auto vehicle'], ['solid/robot', 'Robot', 'ai bot machine'],
['brands/apple', 'Apple', 'fruit ios mac'],
]);
const ids = q => JSON.parse(run(`JSON.stringify(iconMatches(${all}, ${JSON.stringify(q)}, 10).map(i => i[0]))`));
// Named car, then car as a word of the name, then as a search term, then caret; never scarf.
assert.deepEqual(ids('car'), ['solid/car', 'solid/car-side', 'solid/taxi', 'solid/caret-down']);
assert.deepEqual(ids('ai'), ['solid/robot'], 'search terms count');
assert.deepEqual(ids('mac fruit'), ['brands/apple'], 'every word');
assert.equal(run(`iconMatches(${all}, '', 2).length`), 2, 'nothing typed is the first few');
});
test('editing a Signal sends only what changed, so a new icon asks Jev nothing again', () => {
const sg = `{id: 1, name: 'F1', keywords: ['f1'], description: null, icon: null, unread: 0}`;
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1'], description: null, icon: 'solid/car'}))`)),
{ icon: 'solid/car' });
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1', 'gp'], description: null, icon: null}))`)),
{ keywords: ['f1', 'gp'] });
});
test('a Signal draws its icon only when it is a style and a plain name', () => {
assert.match(run(`signalTile({name: 'F1', icon: 'solid/flag-checkered'})`), /url\('\/icons\/solid\/flag-checkered\.svg'\)/);
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: "solid/x');background:url(evil"})`), /evil/);
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: null})`), /ficon/);
});
test('Jev is offered the icons found for each word of a Signal', () => {
const all = JSON.stringify([
['solid/robot', 'Robot', 'ai bot machine'], ['solid/code', 'Code', 'programming brackets'],
['solid/car', 'Car', 'auto vehicle'], ['solid/the-red-yeti', 'The Red Yeti', ''],
]);
const ids = JSON.parse(run(`JSON.stringify(iconCandidates(${all}, 'AI Development', ['programming'], 'The tools', 40).map(c => c[0]))`));
assert.deepEqual(ids, ['solid/robot', 'solid/code'], 'each word its icons, "the" none');
const brands = JSON.stringify([
['brands/mandalorian', 'Mandalorian', 'disney plus helmet star wars'], ['brands/apple', 'Apple', 'fruit ios mac'],
['solid/fort-awesome', 'Fort', 'castle'], ['solid/apple-whole', 'Apple Whole', 'fruit'],
]);
const offered = q => JSON.parse(run(`JSON.stringify(iconCandidates(${brands}, ${JSON.stringify(q)}, [], null, 40).map(c => c[0]))`));
assert.deepEqual(offered('Disney castle'), ['solid/fort-awesome'], 'a brand only by its own name, never its search terms (#264)');
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}`);
@@ -11,7 +43,8 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
// empty, with every handler below the error dead. Server-side checks all passed.
// Four top-level feeds in the fixture config; the OPML's children are inside a closed folder.
await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 });
// The Signals folder is above them, and no feed.
await expect(page.locator('.feed:not(.sigs)')).toHaveCount(5, { timeout: 15_000 });
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
@@ -403,7 +436,8 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
// Only a folder has a triangle, and it is a button that says whether the folder is open.
const chev = page.locator('.feed.group .chev');
await expect(chev).toBeVisible({ timeout: 20_000 });
await expect(page.locator('.feed:not(.group) .chev')).toHaveCount(0);
// The Signals folder is a folder too (#255).
await expect(page.locator('.feed:not(.group):not(.sigs) .chev')).toHaveCount(0);
await expect(chev).toHaveAttribute('aria-expanded', 'false');
// Closed by default: the children are not listed until the folder is opened.
@@ -413,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 }) => {
@@ -448,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 }) => {
@@ -597,6 +694,8 @@ test('a second person has their own feeds and their own read state', async ({ br
// Hiding the way in is not the guard; the server is.
expect((await page.request.get('/api/users')).status()).toBe(403);
expect((await page.request.get('/api/logs')).status()).toBe(403);
// Nor may Sam mark a feed they do not read private, keeping it out of the directory (#333).
expect((await page.request.patch('/api/feeds/test-show', { data: { private: true } })).status()).toBe(404);
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
// files, but Sam's own read state.
@@ -1141,6 +1240,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(),
// 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();
@@ -1668,7 +1769,8 @@ test.describe('an item with no files, on a phone', () => {
});
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
const rows = page.locator('#feedlist .feed');
// The Signals folder is first, under All Subscriptions (#256), and no feed.
const rows = page.locator('#feedlist .feed:not(.sigs)');
const group = page.locator('.feed.group').first();
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
const child = page.locator('.feed.child').first();
@@ -1697,15 +1799,30 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
await expect(row).toBeVisible();
await page.evaluate(() => setScanning('test-show', true));
// A check of a few milliseconds leaves the count where it is, or every count blinked as a scan
// went by; the spinner comes once the check has taken a moment (#232).
const atOnce = await page.evaluate(() => {
setScanning('test-show', true);
return document.querySelector('#feedlist .feed[data-id="test-show"]').classList.contains('scanning');
});
expect(atOnce, 'no spinner the moment a check starts').toBe(false);
await expect(row).toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
await page.evaluate(() => setScanning('test-show', false));
await expect(row).not.toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge')).toBeVisible();
// The button turns from the press to the end of the scan it asked for (#233): a scheduled
// scan's end, or the end of a scan of one other feed, leaves it turning.
const spins = () => document.body.classList.contains('scan-any');
expect(await page.evaluate(spins => { spinStart(null); spinEnd({ only: null, forced: false });
const a = document.body.classList.contains('scan-any'); spinEnd({ only: 'test-show', forced: true });
return a && document.body.classList.contains('scan-any'); })).toBe(true);
await page.evaluate(() => spinEnd({ only: null, forced: true }));
expect(await page.evaluate(spins)).toBe(false);
// Checking every feed says so on the rows, not in a toast (issue #37).
await page.locator('#scanAll').click();
await page.waitForTimeout(1500);
expect(await page.evaluate(() => { scanAll(); return document.body.classList.contains('scan-any'); }),
'turning the moment it is pressed').toBe(true);
await expect(page.locator('body')).not.toHaveClass(/\bscan-any\b/, { timeout: 15_000 });
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
});
@@ -1766,21 +1883,56 @@ test('Share hands the feed, the item and the file to the share sheet', async ({
]);
});
test('a refresh button turns while what it checks is being checked', async ({ page }) => {
test('a refresh button turns from the press to the end of the scan it asked for', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
const btn = '.fhead [data-a=scan] .i';
await expect(page.locator(btn)).toBeVisible();
expect(await spin(btn)).toBe('none');
await page.evaluate(() => setScanning('test-show', true));
// Pressed: this feed's button turns, the toolbar's does not (#78, #233).
await page.evaluate(() => spinStart('test-show'));
expect(await spin(btn)).toBe('ipxspin');
expect(await spin('#scanAll .i')).toBe('ipxspin');
// Another feed being checked turns the toolbar's, not this page's.
await page.evaluate(() => { setScanning('test-show', false); setScanning('multi-show', true); });
expect(await spin(btn)).toBe('none');
expect(await spin('#scanAll .i')).toBe('ipxspin');
await page.evaluate(() => setScanning('multi-show', false));
expect(await spin('#scanAll .i')).toBe('none');
// Feeds being checked one by one no longer start or stop it (#231).
await page.evaluate(() => { setScanning('test-show', true); setScanning('test-show', false); });
expect(await spin(btn)).toBe('ipxspin');
// Another feed's scan ending, or a scheduled one's, leaves it turning; its own stops it.
await page.evaluate(() => { spinEnd({ only: 'multi-show', forced: true }); spinEnd({ only: null, forced: false }); });
expect(await spin(btn)).toBe('ipxspin');
await page.evaluate(() => spinEnd({ only: 'test-show', forced: true }));
expect(await spin(btn)).toBe('none');
// The toolbar's turns for a scan of every feed, until that scan ends.
await page.evaluate(() => spinStart(null));
expect(await spin('#scanAll .i')).toBe('ipxspin');
await page.evaluate(() => spinEnd({ only: null, forced: true }));
expect(await spin('#scanAll .i')).toBe('none');
});
test("an open feed's page follows its row: an error a later check clears goes", async ({ page }) => {
// The header was drawn once, on opening, and kept a cleared error until reopened (#316).
await page.locator('.feed', { hasText: 'Test Show' }).click();
const head = page.locator('.fhead .meta');
await page.evaluate(() => patchFeed({ ...S.feeds.find(f => f.id === 'test-show'), last_error: 'connecting: timed out', failing: null }));
await expect(head.locator('.fail')).toHaveText('connecting: timed out');
await page.evaluate(() => patchFeed({ ...S.feeds.find(f => f.id === 'test-show'), last_error: null, entries: 99 }));
await expect(head.locator('.fail')).toHaveCount(0);
await expect(head.locator('.stat')).toContainText('99 items');
});
test.describe('with motion reduced', () => {
test.use({ reducedMotion: 'reduce' });
test('a check running still shows, as a pulse and in its label', async ({ page }) => {
// Turning was all that showed it, and with motion reduced nothing turned (#310).
const all = page.locator('#scanAll');
await page.evaluate(() => spinStart(null));
await expect(all).toHaveAttribute('aria-busy', 'true');
await expect(all).toHaveAttribute('aria-label', 'Checking for new items…');
expect(await all.locator('.i').evaluate(el => getComputedStyle(el).animationName)).toBe('ipxpulse');
await page.evaluate(() => spinEnd({ only: null, forced: true }));
await expect(all).not.toHaveAttribute('aria-busy');
await expect(all).toHaveAttribute('aria-label', 'Check every feed for new items');
});
});
test.describe('on a narrow phone', () => {
@@ -1828,6 +1980,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 () => {
@@ -1913,6 +2081,36 @@ test('DeadAir is off until turned on for a show, then plays it shortened, with y
await page.locator('#ssave').click();
await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual);
// Someone else on the same show at the usual pause, at the same address at the same time: one
// file on disk, a playlist each, and neither kept by a cache to be handed to the other.
{
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` };
try { execFileSync('./target/debug/ipx', ['user', 'add', 'quinn'], { input: 'quinnpassword', env }); }
catch (e) { if (!String(e.stderr || e.stdout).includes('already exists')) throw e; }
const ctx = await page.context().browser().newContext();
const quinn = await ctx.newPage();
await quinn.goto('/login');
await quinn.locator('#name').fill('quinn');
await quinn.locator('#pw').fill('quinnpassword');
await quinn.locator('button[type=submit]').click();
await expect(quinn.locator('#feedlist')).toBeVisible();
await quinn.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } });
await quinn.request.patch(`/api/feeds/${encodeURIComponent(id)}`, { data: { deadair: true } });
const enc = (await entry()).enclosures[0].id;
const fetchList = async p => { const r = await p.request.get(`/media/${enc}/deadair.m3u8`);
return { status: r.status(), cache: r.headers()['cache-control'], body: await r.text() }; };
const [mine, theirs] = await Promise.all([fetchList(page), fetchList(quinn)]);
const length = l => l.body.match(/#EXTINF:([\d.]+)/g).reduce((s, x) => s + Number(x.slice(8)), 0);
expect([mine.status, theirs.status]).toEqual([200, 200]);
expect([mine.cache, theirs.cache]).toEqual(['no-store', 'no-store']);
expect(mine.body).not.toBe(theirs.body);
expect(length(theirs), 'the usual pause cuts more than the longer one').toBeLessThan(length(mine));
await quinn.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
await ctx.close();
}
// Saved without its dead air too (#186): smaller than the original, named to say so.
const saved = await page.evaluate(async id => {
const [d, o, p] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`), fetch(`/media/${id}/deadair.mp3`, { headers: { Range: 'bytes=0-99' } })]);
@@ -1962,3 +2160,213 @@ test('a downloaded file\'s buttons sit on one row in the files pane', async ({ p
const tops = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
expect(new Set(tops).size, 'play, save, share and delete wrapped onto two rows').toBe(1);
});
test('a list seen before shows at once, and one not yet seen shows a spinner while it loads', async ({ page }) => {
// #247. Every list's answer held back a second and a half, as a slow connection would.
await page.route(/\/api\/(entries|feeds\/[^/]+\/entries)\?/, async route => {
await new Promise(r => setTimeout(r, 1500));
await route.continue();
});
await page.evaluate(() => { lists.clear(); fetching.clear(); });
await page.locator('.place[data-id=":all"]').click();
await expect(page.locator('#eps .listload'), 'waited for, and said so').toBeVisible();
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 10_000 });
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 10_000 });
// Back to All Subscriptions: drawn from what was kept, before the slow answer could come.
await page.locator('.place[data-id=":all"]').click();
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 500 });
await expect(page.locator('#eps .listload')).toHaveCount(0);
await page.unroute(/\/api\/(entries|feeds\/[^/]+\/entries)\?/);
});
test('Currently Listening and the Directory show at once once seen, with a spinner the first time', async ({ page }) => {
// #248. Both answers held back a second and a half.
const slow = /\/api\/(directory$|entries\?filter=in_progress)/;
await page.route(slow, async route => { await new Promise(r => setTimeout(r, 1500)); await route.continue(); });
// What the page fetched ahead when it loaded is let finish and then forgotten, and no more is
// fetched ahead, or the first visit would find the Directory already here, as it is meant to.
await page.waitForFunction(() => pageFetching.size === 0 && fetching.size === 0);
await page.evaluate(() => { window.preloadLists = () => {}; pages.clear(); });
for (const [id, drawn] of [[':listening', '#listening .childrow, #listening .hint'], [':directory', '#directory > *:not(.listload)']]) {
await page.locator(`.place[data-id="${id}"]`).click();
await expect(page.locator('.listload'), `${id}: waited for, and said so`).toBeVisible();
await expect(page.locator(drawn).first()).toBeVisible({ timeout: 10_000 });
await page.locator('.place[data-id=":all"]').click();
await page.locator(`.place[data-id="${id}"]`).click();
await expect(page.locator(drawn).first(), `${id}: drawn from what was kept`).toBeVisible({ timeout: 500 });
await expect(page.locator('.listload')).toHaveCount(0);
}
await page.unroute(slow);
});
test('the page comes compressed, and the compressed copies cannot be asked for by name', async ({ page }) => {
// #253: brotli made at build, served in place of the file.
const js = await page.request.get('/app.js', { headers: { 'Accept-Encoding': 'br, gzip' } });
expect(js.status()).toBe(200);
expect(js.headers()['content-encoding']).toBe('br');
expect(js.headers()['content-type']).toContain('javascript');
const gz = await page.request.get('/app.css', { headers: { 'Accept-Encoding': 'gzip' } });
expect(gz.headers()['content-encoding']).toBe('gzip');
// The admin page's script, under its compressed name, would skip the check that keeps it to admins.
for (const p of ['/admin.js.br', '/admin.html.gz', '/app.js.br']) {
expect((await page.request.get(p)).status(), p).toBe(404);
}
});
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();
await page.reload();
// The + on the folder's page (#265); making one opens the folder to show it.
await page.locator('#feedlist .sigs').click();
await page.locator('#content .acts [data-a="new"]').click();
await page.locator('#sgname').fill('F1');
await page.locator('#sgkwin').fill('monaco');
await page.keyboard.press('Enter');
// An icon of its own, found by searching Font Awesome Free (#261).
await page.locator('#sgicon').click();
await page.locator('#sgiconq').fill('flag checkered');
await page.locator('[data-ic="solid/flag-checkered"]').click();
await page.locator('#sgsave').click();
const row = page.locator('#feedlist .feed.child', { hasText: 'F1' });
await expect(row).toHaveClass(/sel/);
await expect(row.locator('.ficon')).toHaveAttribute('style', /\/icons\/solid\/flag-checkered\.svg/);
expect((await page.request.get('/icons/solid/flag-checkered.svg')).status()).toBe(200);
const signals = async () => (await (await page.request.get('/api/signals')).json());
const sg = (await signals()).find(s => s.name === 'F1');
const shown = async () => (await (await page.request.get(`/api/signals/${sg.id}/entries`)).json()).entries.map(e => e.title);
await expect.poll(shown, { timeout: 15_000 }).toEqual(['Monaco qualifying']);
await page.reload();
await row.click();
await page.locator('.ep', { hasText: 'Monaco qualifying' }).click();
await expect.poll(async () => (await (await page.request.get(`/api/entries/${encodeURIComponent(feed)}/sig-1`)).json()).read,
{ timeout: 5_000 }).toBe(true);
expect((await signals()).find(s => s.id === sg.id).unread).toBe(0);
// And the sidebar says so without a reload: it kept saying 1 (#258).
await expect(row.locator('.badge')).toHaveText('0');
expect((await page.request.get(`/api/signals/${sg.id + 1000}/entries`)).status(), 'no such Signal').toBe(404);
await page.request.delete(`/api/signals/${sg.id}`);
await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`);
await page.request.patch('/api/me', { data: { typesafe_key: null } });
});
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();
await page.evaluate(id => selectFeed(':signal:' + id), id);
await expect(page.locator('#content a[href="https://typesafe.ai"]')).toBeVisible();
await page.locator('#content [data-keylink]').click();
await expect(page.locator('#stskey')).toBeFocused();
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 } });
}
});
test('a dialog keeps the keyboard inside it and gives it back on closing', async ({ page }) => {
// #306: Tab walked out of a dialog into the toolbar, and closing one left focus on the page.
await page.locator('#addFeed').focus();
await page.keyboard.press('Enter');
await expect(page.locator('#modalCard[role=dialog]')).toHaveAttribute('aria-labelledby', /.+/);
await expect(page.locator('#nurl')).toBeFocused();
// Labels name their boxes (#307).
await expect(page.getByRole('textbox', { name: 'Feed URL' })).toHaveCount(1);
for (let i = 0; i < 8; i++) {
await page.keyboard.press('Tab');
// Past the last control Tab goes to the browser's own toolbar (the body, from here), never the page behind.
expect(await page.evaluate(() => { const a = document.activeElement; return a === document.body || document.querySelector('#modalCard').contains(a); }), `Tab ${i + 1} stays out of the page behind`).toBe(true);
}
await page.keyboard.press('Escape');
await expect(page.locator('#modal.on')).toBeHidden();
await expect(page.locator('#addFeed')).toBeFocused();
expect(await page.locator('#shell').evaluate(e => e.inert)).toBe(false);
});
test('an item opens from the keyboard, and its row actions show while focused', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).first().click();
const row = page.locator('#eps .ep').first();
// #298: the title is a button, so Tab reaches the item itself.
await row.locator('button.t').focus();
await page.keyboard.press('Enter');
await expect(row).toHaveClass(/\bsel\b/);
// #300: the read toggle is named for what it does, not its dot.
await expect(row.locator('.st')).toHaveAttribute('aria-label', /^Mark (read|unread)$/);
// #299: focus shows what hover shows.
await page.mouse.move(0, 0);
const act = row.locator('.rowacts button').first();
if (await act.count()) {
await act.focus();
await expect(row.locator('.rowacts')).toHaveCSS('opacity', '1');
}
// #298: a feed in the list is a link to a screen reader, the open one current.
await expect(page.locator('#feedlist .feed[aria-current=page]')).toHaveAttribute('role', 'link');
});
test('an error toast is announced and stays until it is dismissed', async ({ page }) => {
// #303: role=status, and no timer on an error.
await expect(page.locator('#toasts')).toHaveAttribute('role', 'status');
await page.evaluate(() => toast('Unable to play this file. Try again, or download it again.', true));
const t = page.locator('.toast.bad').last();
await page.waitForTimeout(6500);
await expect(t).toBeVisible();
await t.getByRole('button', { name: 'Dismiss' }).click();
await expect(t).toHaveCount(0);
});

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

@@ -7,6 +7,41 @@ const dir = __dirname;
const port = Number(process.env.FIXTURE_PORT || 8792);
http.createServer((req, res) => {
// Jev, for Signals (#254): sure of an item whose text has a question's keyword in it. Refuses
// a request in the shape TypeSafe refuses, as it does (#257).
if (req.method === 'POST' && req.url === '/jev') {
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"}');
return;
}
const title = String(state).toLowerCase();
const answers = {};
for (const [id, q] of Object.entries(questions)) {
// 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 }));
});
return;
}
// Dated today, since a Signal looks at the last 30 days only.
if (req.url === '/signal.xml') {
const now = new Date().toUTCString();
const item = (guid, title) => `<item><guid>${guid}</guid><title>${title}</title><pubDate>${now}</pubDate></item>`;
res.writeHead(200, { 'content-type': 'application/xml' }).end(`<?xml version="1.0"?><rss version="2.0"><channel>
<title>Signal Show</title>${item('sig-1', 'Monaco qualifying')}${item('sig-2', 'Baking bread')}</channel></rss>`);
return;
}
const name = decodeURIComponent(req.url.split('?')[0].replace(/^\//, '')) || 'index';
const file = path.join(dir, path.basename(name));
fs.readFile(file, (err, body) => {

View File

@@ -32,6 +32,8 @@ download_dir = "${path.join(root, 'downloads')}"
socket = "${path.join(root, 'ipx.sock')}"
schedule = "every 60m"
max_new_per_check = 1
# The fixture feeds are on loopback, which ipx refuses otherwise (#320).
fetch_internal = ["127.0.0.1"]
directory_url = "${DIRECTORY}"
directory_token = "${DIRECTORY_TOKEN}"
directory_share = true
@@ -75,6 +77,8 @@ auto_download = false
fs.writeFileSync(path.join(root, 'directory.toml'), `
bind = "127.0.0.1:8795"
database_url_env = "DIRECTORY_DATABASE_URL"
# The fixture feeds are on loopback, which the refresher refuses otherwise (#329).
fetch_internal = ["127.0.0.1"]
[[tokens]]
name = "test"

View File

@@ -5,7 +5,9 @@
"target": "es2022",
"lib": ["es2022", "dom", "dom.iterable"],
"noEmit": true,
"strict": false,
// Strict throughout (#235, #236). $ assumes the element is there: the page check proves the
// ones wired at load exist, and a lookup that may find nothing says so with ?.
"strict": true,
"skipLibCheck": true
},
"include": ["web/src/*.ts"]

View File

@@ -11,6 +11,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.3.0] - 2026-10-23
### Fixed
- An item opens from the keyboard and with a screen reader: its title is a button. The feed list's rows and the Directory's shows are links to a screen reader, the open one marked current (#298).
- An item's Play, Download and Delete buttons show while the keyboard is on one, as they do under the pointer (#299).
- A screen reader names an item's read button for what it does, Mark read or Mark unread, not "●" (#300).
- The player's position slider has a name, and a screen reader reads where you are as a time, "12:30 of 45:00" (#301).
- Placeholder text is in the theme's own hint colour, readable in every theme, and the search and feed filter boxes have names of their own (#302).
- A screen reader reads toasts out, and an error stays until you dismiss it with its x; no more than four show at once (#303).
- Error messages say what to do, for a server error, a lost connection, a file that would not play or a download that failed, in place of the browser's or the server's own words (#304).
- Text boxes, selects and switches have an edge you can see in every theme, and a box with the keyboard in it has the same ring as every other control (#305, #308).
- Dialogs are dialogs to a screen reader, named by their heading. The page behind one is out of reach while it is open, and closing it puts the keyboard back where it was (#306).
- The boxes in Add a feed, Settings, Download latest and OPML are named by their labels, and clicking a label puts the cursor in its box (#307).
- With reduced motion on, a check for new items still shows: the refresh button pulses and says "Checking for new items", spinners pulse, and toasts and sheets fade in (#310).
- On a phone, a tapped button, feed or Directory category no longer stays shaded or fanned out as if it were selected (#311).
- Scrolling to the end of a dialog or the phone's feed list no longer scrolls the page behind it (#312).
- Cover artwork has a faint edge, so a dark cover stands off a dark theme (#313).
- The tab bar's corners follow the tabs inside it (#314), and the Directory's covers fan out smoothly (#315).
- A feed's page drops an error once a later check succeeds, and its item count and when it was last checked stay current, instead of keeping what they were when you opened it (#316).
- Settings shows iPX's and the page's versions at its foot: the logo's tooltip was out of reach of a keyboard and of a phone (#309).
### Security
- Open the original, an episode's View in a new tab, the Directory's item links and the `v` key follow a link only when it is a web address. A podcast's `javascript:` or `data:` link was followed as it was (#326).
- `TRUSTED_HEADER` names ipx's `trusted_header` when it is not Cloudflare's, and ipx-web drops that header from anyone but `TUNNEL_PEER` too. Only Cloudflare's were dropped, so with another header anyone could sign in as anyone (#323).
## [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
- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need, and a Signal without one links to typesafe.ai to get one and to the key's place in Settings (#254, #266). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with its own page listing them and a + there for a new one (#255, #256, #265). Each can have an icon of its own, searched for among all of Font Awesome Free, in place of its initials (#261), or one picked for it with Auto, which a Signal made without an icon gets by itself; each Signal's row counts its items and unread as a feed's does (#263).
- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248).
### Changed
- The page reports how long each list you open takes to show, from the click to the list drawn, for the dashboard (#246).
- The page's files are compressed once when built, brotli at its strongest and gzip, and sent in place of the files: the script 30 KB where it was 37, the stylesheet 11 where it was 14. Traces go to the collector compressed (#253).
- The page and the API's answers are sent compressed, zstd or gzip, which the feed list and item lists, over 100 KB each, were not; live updates and audio are not compressed (#239).
## [1.0.2] - 2026-10-15
### Fixed
- The refresh buttons turn from the moment they are pressed until the scan they asked for has finished, smoothly, instead of jerking back upright between one feed and the next (#231, #233).
- A feed's unread count stays put while a scan checks it quickly; the spinner takes its place only when a check takes a moment (#232).
### Security
- The ipx-web image no longer carries curl, which Caddy's base image shipped and nothing used: 8.22.0 there is affected by CVE-2026-92392 (#237).
## [1.0.1] - 2026-10-13
### Fixed
@@ -38,6 +113,10 @@ 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.0.1...main
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.3.0...main
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/web-v1.2.0...web-v1.3.0
[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
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0

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) {
@@ -57,10 +59,28 @@ http://{$WEB_BIND::8080} {
}
}
# Compressed, zstd or gzip as the browser takes them: the feed list and a page of items went
# out at over 100 KB each (#239). Not the live updates, which a compressor would hold back
# until it had enough to send, nor /media, which is audio and video already compressed.
@compress not path /api/events /media/*
encode @compress zstd gzip
# The copies web/build.mjs compressed are served in place of their file, never asked for by
# name: /admin.js.br would be the admin page's script without the check below that keeps it to
# admins (#253).
# A handle of its own: a bare respond runs after the handles, and the last one serves any file.
@packed path *.br *.gz
handle @packed {
respond 404
}
# Before anything else, so no page or API call sees them: only ipx sets these.
@forged not remote_ip {$TUNNEL_PEER:255.255.255.255}
request_header @forged -Cf-Access-Authenticated-User-Email
request_header @forged -Cf-Access-Jwt-Assertion
# Whatever header ipx's [web] trusted_header names, when it is not Cloudflare's: ipx trusts it
# from this address, and anyone could send it through here (#323).
request_header @forged -{$TRUSTED_HEADER:Cf-Access-Authenticated-User-Email}
@api path /api/* /media/*
handle @api {
@@ -91,7 +111,9 @@ http://{$WEB_BIND::8080} {
rewrite / /index.html
rewrite /admin /admin.html
header Cache-Control no-cache
file_server
file_server {
precompressed br gzip
}
}
# The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app.
@@ -102,18 +124,22 @@ http://{$WEB_BIND::8080} {
}
rewrite * /login.html
header Cache-Control no-cache
file_server
file_server {
precompressed br gzip
}
}
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
# keeps them a year; the icons a day, under names that do not change.
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js /icons/index.json
header @hashed Cache-Control "public, max-age=31536000, immutable"
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
header /favicon.ico Content-Type image/png
@icons path *.png *.svg *.ico *.woff2
header @icons Cache-Control "public, max-age=86400"
handle {
file_server
file_server {
precompressed br gzip
}
}
}

View File

@@ -31,7 +31,7 @@
<section id="directory" hidden></section>
<section id="log" hidden></section>
</main>
<div id="toasts"></div>
<div id="toasts" role="status"></div>
<script data-src="web/src"></script>
</body>

View File

@@ -20,6 +20,12 @@
--warn:#f5a524; /* amber, apart from the needle's orange, for pending */
--bad:#ff6b7a; /* pushed towards pink, apart from the needle */
--shadow:0 8px 28px rgba(4,10,20,.55);
/* The edge of a text box or a switch: the theme's own text mixed into its ground, as far as it
takes to clear 3:1 on every surface one sits on (#305, #308). --line is for rules between
things, which need not, and a box drawn in it all but vanished. A theme whose grounds sit
further apart takes more; tests/contrast.js holds every palette to it. */
--fieldmix:60%;
--field:color-mix(in oklab,var(--fg) var(--fieldmix),var(--bg));
}
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's
script sets data-mode to light or dark, working Auto out from the system, so each theme has
@@ -132,6 +138,7 @@
--shadow:0 8px 28px rgba(0,0,0,.5);
}
:root[data-theme="adwaita"][data-mode="light"] {
--fieldmix:65%;
--bg:#ffffff;
--panel:#ebebed;
--panel2:#fafafb;
@@ -206,6 +213,7 @@
/* Nordic: the Nord palette. Polar Night for the dark half, Snow Storm for the light, Frost for
the accent and Aurora for the rest. Aurora red and Frost blue are shifted until they read. */
:root[data-theme="nordic"] {
--fieldmix:65%;
--bg:#2e3440; /* nord0 */
--panel:#3b4252; /* nord1 */
--panel2:#434c5e; /* nord2 */
@@ -243,6 +251,7 @@
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
and peach for the rest. */
:root[data-theme="catppuccin"] {
--fieldmix:70%;
--bg:#1e1e2e; /* base */
--panel:#181825; /* mantle */
--panel2:#313244; /* surface0 */
@@ -260,6 +269,7 @@
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="catppuccin"][data-mode="light"] {
--fieldmix:80%;
--bg:#eff1f5; /* base */
--panel:#e6e9ef; /* mantle */
--panel2:#dce0e8; /* crust */
@@ -296,6 +306,7 @@
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="gruvbox"][data-mode="light"] {
--fieldmix:70%;
--bg:#fbf1c7; /* bg0 */
--panel:#f2e5bc; /* bg0_s */
--panel2:#ebdbb2; /* bg1 */
@@ -332,6 +343,7 @@
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="solarized"][data-mode="light"] {
--fieldmix:65%;
--bg:#fdf6e3; /* base3 */
--panel:#eee8d5; /* base2 */
--panel2:#e6dfca;
@@ -483,7 +495,15 @@ a{color:var(--accent)}
.tgroup button:focus-visible,.tgroup a:focus-visible,.feed:focus-visible,.place:focus-visible,.chev:focus-visible{outline-offset:-2px}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation:none!important;transition:none!important}
/* Movement goes, not what it said: a still ring read as nothing happening (#310). Spinners pulse
instead of turning, and toasts and sheets fade in instead of sliding. */
.dlink.busy::after,.feed.scanning::after,.listload::before,#pullspin::before,
body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,body.scan-any #scanAll .i{
animation:ipxpulse .8s ease-in-out infinite alternate!important}
.toast,.card{animation:ipxfade .2s!important}
}
@keyframes ipxpulse{to{opacity:.3}}
@keyframes ipxfade{from{opacity:0}}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px}
::-webkit-scrollbar-track{background:transparent}
@@ -501,9 +521,9 @@ a{color:var(--accent)}
}
#applogo{display:flex;flex:none}
#applogo img{width:28px;height:28px;border-radius:7px}
/* The logo in the mode's own variant. Dark unless the page says light, as the palette is: with
/* The logo, and the app-icon tiles, in the mode's own variant. Dark unless the page says light, as the palette is: with
Auto, data-mode arrives with the script, and until then the page is drawn dark. */
:root[data-mode="light"] .logo.ondark,:root:not([data-mode="light"]) .logo.onlight{display:none}
:root[data-mode="light"] .ondark,:root:not([data-mode="light"]) .onlight{display:none}
/* Classic is the 2004 app, so it wears the 2004 app's icon. */
.logo.classic,:root[data-theme="classic"] .logo:not(.classic){display:none}
:root[data-theme="classic"] .logo.classic{display:block}
@@ -511,7 +531,7 @@ a{color:var(--accent)}
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
color:var(--dim);flex:none;
}
.iconbtn:hover{background:var(--raise);color:var(--fg)}
@media (hover:hover){.iconbtn:hover{background:var(--raise);color:var(--fg)}}
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
/* The display's own intrusions -- the Dynamic Island and status bar along the top (#54), the
home indicator along the bottom, the notch at one side in landscape. viewport-fit=cover hands the page the whole screen, which is what a standalone
@@ -537,18 +557,18 @@ 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}
.tgroup button+button,.tgroup button+a{border-left:1px solid var(--line)}
.tgroup button:hover:not(:disabled),.tgroup a:hover{background:var(--raise);color:var(--fg)}
@media (hover:hover){.tgroup button:hover:not(:disabled),.tgroup a:hover{background:var(--raise);color:var(--fg)}}
.tgroup button:disabled{opacity:.35;cursor:default}
.sidetools button,.sidefoot button{
flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 8px;font-size:12.5px;color:var(--dim);
}
.sidetools button:hover,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)}
@media (hover:hover){.sidetools button:hover,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)}}
/* Settings and the log are housekeeping: they sit under the feeds, out of the way. */
.sidefoot{
display:flex;flex-direction:column;gap:7px;padding:8px 12px;
@@ -558,23 +578,36 @@ a{color:var(--accent)}
.who{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--faint)}
.who span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.who button{flex:none;background:none;border:0;padding:2px 4px;color:var(--faint);text-decoration:underline}
.who button:hover{color:var(--fg)}
@media (hover:hover){.who button:hover{color:var(--fg)}}
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.sidefoot i{font-style:normal;opacity:.75}
.searchwrap{padding:12px 12px 8px}
/* The browser's own grey fell short of AA in most themes, and a placeholder is all some boxes say (#302). */
::placeholder{color:var(--faint);opacity:1}
input[type=search],input[type=text],input[type=password],input[type=number],select{
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
width:100%;background:var(--bg);border:1px solid var(--field);color:var(--fg);
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
}
input:focus,select:focus{outline:0;border-color:var(--accent)}
input:focus,select:focus{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)}
@media (hover:hover){.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}
#feedlist{overflow-y:auto;overscroll-behavior:contain;padding:0 8px 12px 16px;flex:1;min-height:0}
.feed{
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
cursor:pointer;margin-bottom:1px;position:relative;
}
.feed:hover{background:var(--panel2)}
@media (hover:hover){.feed:hover{background:var(--panel2)}}
.feed.sel{background:var(--raise)}
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
.feed.child{margin-left:11px}
@@ -590,6 +623,27 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.feed.lastpin{margin-bottom:9px}
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
.feed.child .art{width:28px;height:28px;font-size:11px}
/* A Signal's own icon (#261): Font Awesome's file as a mask, so it is drawn in the text's colour
and follows the theme as the page's own icons do. */
.ficon{
display:block;width:50%;height:50%;background:currentColor;
-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat;
}
.iconpick{flex:none;border-radius:9px}
.iconpick .art{width:38px;height:38px;font-size:13px}
.icongrid{
display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:4px;
max-height:220px;overflow-y:auto;margin-top:8px;
}
.iconcell{height:40px;border-radius:8px;display:grid;place-items:center;color:var(--dim)}
.iconcell[aria-selected="true"]{background:var(--raise);color:var(--fg)}
@media (hover:hover){.iconcell:hover{background:var(--raise);color:var(--fg)}}
.iconcell .ficon{width:20px;height:20px}
/* Auto in the icon picker, while an icon is picked: the scan spinner, beside the word. */
.dlink.busy::after{
content:"";display:inline-block;width:10px;height:10px;margin-left:6px;vertical-align:-1px;border-radius:50%;
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
}
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
above it. The button is the row's full height and 24 px wide: a near miss used to open the
folder's page, and 16 px left the triangle cramped against the art. */
@@ -597,7 +651,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;
border-radius:4px;color:var(--faint);
}
.chev:hover{color:var(--fg)}
@media (hover:hover){.chev:hover{color:var(--fg)}}
.chev.bad,.chev.bad:hover{color:var(--bad)}
/* A feed being checked: a small spinner in place of its unread count (issue #37). */
.feed.scanning .badge{display:none}
@@ -627,7 +681,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
border-radius:9px;cursor:pointer;background:var(--panel);
}
.childrow:hover{border-color:var(--accent)}
@media (hover:hover){.childrow:hover{border-color:var(--accent)}}
.childrow .art{width:32px;height:32px;font-size:12px}
.childrow .txt{flex:1;min-width:0}
.childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -655,7 +709,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
.dhead{margin-bottom:6px}
.dback{display:inline-flex;align-items:center;gap:5px;margin:0 0 8px -5px;padding:3px 5px;border-radius:6px;font-size:13px;color:var(--accent)}
.dback .i{width:11px;height:11px}
.dback:hover{background:var(--raise)}
@media (hover:hover){.dback:hover{background:var(--raise)}}
.dtitle{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px}
.dtitle h2{margin:0;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-.02em;overflow-wrap:anywhere}
.dhead .sub{margin:5px 0 0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums}
@@ -663,7 +717,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
.dsech{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px}
.dsech h3{margin:0;font-size:19px;line-height:1.25;font-weight:650;letter-spacing:-.01em}
.dall{font-size:13px;color:var(--accent);flex:none}
.dall:hover{text-decoration:underline}
@media (hover:hover){.dall:hover{text-decoration:underline}}
/* The most subscribed, numbered because they are ranked: down one column, then the next. */
.chart{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(var(--rows,5),auto);grid-auto-flow:column;column-gap:32px}
.rank{display:grid;grid-template-columns:22px 52px minmax(0,1fr) auto;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid var(--line)}
@@ -681,7 +735,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
display:flex;flex-direction:column;align-items:flex-start;
background:var(--panel);border:1px solid var(--line);
}
.cat:hover{border-color:var(--accent)}
@media (hover:hover){.cat:hover{border-color:var(--accent)}}
/* The whole width: the fan sits below two lines of name, and a narrower name broke Government
in two in the wider faces, Classic's and Glass's. */
.cat b{font-size:16px;line-height:1.2;font-weight:650;color:var(--fg);overflow-wrap:break-word}
@@ -693,22 +747,22 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
to open, the browser drew the covers again on the first frames, and again on the way back,
and the fan opened in a jerk (#132). All three move, for as long as the eye can follow, as
one motion that settles rather than stops. */
will-change:transform;
will-change:rotate,translate;
transition:rotate .45s cubic-bezier(.2,.7,.2,1),translate .45s cubic-bezier(.2,.7,.2,1);
}
.cat .fan .art:nth-last-child(1){right:0;rotate:8deg;translate:0 -12px}
.cat .fan .art:nth-last-child(2){right:25px;rotate:-3deg;translate:0 -4px}
.cat .fan .art:nth-last-child(3){right:50px;rotate:-13deg;translate:0 4px}
.cat:hover .fan .art:nth-last-child(1){rotate:13deg;translate:5px -17px}
.cat:hover .fan .art:nth-last-child(2){rotate:-2deg;translate:0 -8px}
.cat:hover .fan .art:nth-last-child(3){rotate:-19deg;translate:-7px 3px}
@media (hover:hover){.cat:hover .fan .art:nth-last-child(1){rotate:13deg;translate:5px -17px}}
@media (hover:hover){.cat:hover .fan .art:nth-last-child(2){rotate:-2deg;translate:0 -8px}}
@media (hover:hover){.cat:hover .fan .art:nth-last-child(3){rotate:-19deg;translate:-7px 3px}}
/* A category's subcategories, each with how many it holds, then the order. A picked one is
drawn as a picked tab is. */
.dtools{display:flex;align-items:center;gap:10px 14px;margin-bottom:14px}
.pills{display:flex;flex-wrap:wrap;gap:6px;flex:1 1 0;min-width:0}
.pills button{flex:none;padding:4px 11px;border:1px solid var(--line);border-radius:999px;font-size:13px;color:var(--dim);white-space:nowrap}
.pills button small{margin-left:6px;color:var(--faint);font-size:11.5px;font-variant-numeric:tabular-nums}
.pills button:hover{border-color:var(--accent);color:var(--fg)}
@media (hover:hover){.pills button:hover{border-color:var(--accent);color:var(--fg)}}
.pills button.on{background:var(--raise);border-color:var(--raise);color:var(--fg);font-weight:500}
.dtools .grow{flex:1}
#dirsort{flex:none;width:auto;padding:4px 6px;font-size:13px}
@@ -722,7 +776,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
/* Every one opens: yours its feed, the rest their page here. */
.tile,.rank{cursor:pointer}
.tile:hover .txt b,.rank:hover .txt b{color:var(--accent)}
@media (hover:hover){.tile:hover .txt b,.rank:hover .txt b{color:var(--accent)}}
/* A feed's page before you subscribe, as Apple's show page: the cover large, what it is and who
here reads it beside it, then what it has published lately. */
.show{display:flex;gap:22px;align-items:flex-end;margin-top:4px}
@@ -733,7 +787,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
.show .dfail{color:var(--bad)}
.show .acts{margin-top:8px}
.dlink{padding:0;font-size:13px;color:var(--accent);text-align:left}
.dlink:hover{text-decoration:underline}
@media (hover:hover){.dlink:hover{text-decoration:underline}}
/* What the feed says it is, three lines of it until More. */
.about{max-width:760px;margin-top:18px}
.about p{margin:0;color:var(--dim);font-size:14px;line-height:1.55;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;overflow-wrap:anywhere}
@@ -743,7 +797,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
.lep{padding:12px 0;border-bottom:1px solid var(--line)}
.lep b{display:block;font-size:15px;font-weight:600;line-height:1.35;overflow-wrap:anywhere}
.lep b a{color:var(--fg);text-decoration:none}
.lep b a:hover{color:var(--accent)}
@media (hover:hover){.lep b a:hover{color:var(--accent)}}
.lep p{margin:4px 0 0;color:var(--dim);font-size:13px;line-height:1.5;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden}
.lep small{display:flex;align-items:center;gap:7px;margin-top:6px;color:var(--faint);font-size:12px;font-variant-numeric:tabular-nums}
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
@@ -756,6 +810,12 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
}
.art>.tile{width:100%;height:100%;display:block}
/* A hairline inside the edge, so a dark cover keeps its boundary on a dark page (#313). Black or
white, not a palette colour, which shows as a tinted fringe; initials are not a picture. */
.art:not(.ini){outline:1px solid oklch(1 0 0/.1);outline-offset:-1px}
:root[data-mode="light"] .art:not(.ini){outline-color:oklch(0 0 0/.1)}
#pdead .tile{width:26px;height:26px;border-radius:6px}
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
.art.ini{background:color-mix(in srgb,var(--tint) 24%,var(--bg));color:var(--tint)}
@@ -797,6 +857,10 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
font-size:12px;color:var(--faint)}
/* What a pull started, for a couple of seconds after letting go, under the top bar. */
/* A list being waited for (#247): the pull-to-refresh spinner, in the list's place. */
.listload{display:flex;align-items:center;justify-content:center;gap:8px;padding:28px 8px;color:var(--dim);font-size:13px}
.listload::before{content:"";width:12px;height:12px;border-radius:50%;
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite}
#pullspin{position:fixed;left:50%;top:calc(58px + var(--safe-t));transform:translateX(-50%);z-index:55;
display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;font-size:12px;
color:var(--dim);background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow)}
@@ -814,7 +878,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
#files .empty{padding:24px 0}
.fhd{font-size:12px;color:var(--faint)}
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
#grab:hover{background:var(--accent)}
@media (hover:hover){#grab:hover{background:var(--accent)}}
#detail{grid-area:detail;overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere}
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
@@ -822,6 +886,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}
@@ -837,20 +902,20 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
.fhead h2{margin:0 0 3px;font-size:23px;line-height:1.2;overflow-wrap:anywhere}
.fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px}
.fhead .sub a{color:var(--dim);text-decoration:none}
.fhead .sub a:hover{color:var(--accent)}
@media (hover:hover){.fhead .sub a:hover{color:var(--accent)}}
.acts{display:flex;gap:7px;align-items:center;margin-top:auto}
.acts .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
.acts .btn:hover{background:var(--raise)}
@media (hover:hover){.acts .btn:hover{background:var(--raise)}}
.btn{
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 12px;font-size:13px;
}
.btn:hover{border-color:var(--accent)}
@media (hover:hover){.btn:hover{border-color:var(--accent)}}
a.btn{text-decoration:none;color:inherit}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--ink);font-weight:600}
.btn.primary:hover{filter:brightness(1.08)}
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
@media (hover:hover){.btn.primary:hover{filter:brightness(1.08)}}
@media (hover:hover){.btn.danger:hover{border-color:var(--bad);color:var(--bad)}}
/* An icon in place of a word; the word is in its tooltip. */
.btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center}
/* Inline in a sentence, next to a plain-word error explanation. */
@@ -875,7 +940,7 @@ kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:
display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap;
position:sticky;top:0;background:var(--bg);padding:6px 0 8px;z-index:3;
}
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px}
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:10px;padding:3px}
.tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)}
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:500}
.tabs a{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim);text-decoration:none}
@@ -915,7 +980,8 @@ body.adminpage #log{max-width:none}
/* Each heading sorts by its column; the caret says which way. */
.ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none;
font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}
.ephead .hs:hover,.ephead .hs.on{color:var(--fg)}
.ephead .hs.on{color:var(--fg)}
@media (hover:hover){.ephead .hs:hover{color:var(--fg)}}
.ephead .hs .arr{display:inline-flex}
.ephead .hs .arr .i{width:8px;height:8px}
.ephead .hs .arr.asc{transform:rotate(-90deg)}
@@ -927,7 +993,7 @@ body.adminpage #log{max-width:none}
.ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
.ep>*{min-width:0}
.ep.sel{background:var(--raise);border-color:var(--line)}
.ep:hover{background:var(--panel)}
@media (hover:hover){.ep:hover{background:var(--panel)}}
/* Amber means new: the unread dot, the badges and a download under way. Blue is for the one
primary action and links, so a count no longer looks like a button. */
/* No padding: a button keeps the browser's side padding, which left too little room for the 16px
@@ -953,8 +1019,8 @@ body.playing .eq i{animation:eq .9s ease-in-out infinite alternate}
body.playing .eq i:nth-child(1){animation-delay:-.3s}
body.playing .eq i:nth-child(3){animation-delay:-.6s}
@keyframes eq{from{transform:scaleY(.35)}}
.ep .st:hover,.ep .fl:hover{background:var(--raise)}
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@media (hover:hover){.ep .st:hover,.ep .fl:hover{background:var(--raise)}}
.ep .t{font-weight:600;font-size:13.5px;display:block;width:100%;padding:0;text-align:start;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* An item with no title shows its opening words: set as text, not as a heading. */
.ep .t.notitle{font-weight:400}
.ep.read .t{color:var(--dim);font-weight:500}
@@ -971,7 +1037,8 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
#split.one .h-fd,#split.one .ep .fd{display:none}
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
.ep:hover .rowacts{opacity:1}
/* Focus shows them as hover does: Tab reached Play and Delete while they were still invisible (#299). */
.ep:hover .rowacts,.ep:focus-within .rowacts{opacity:1}
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
.dlbar.live{background:var(--raise)}
.dlbar i{display:block;height:100%;width:0;background:var(--accent2);transition:width .25s}
@@ -1009,7 +1076,7 @@ body.has-video #audio{
width:40px!important;height:40px!important;background:var(--fg);color:var(--bg);
border-radius:50%;
}
#pplay:hover{background:var(--accent);color:var(--ink)}
@media (hover:hover){#pplay:hover{background:var(--accent);color:var(--ink)}}
#seekrow{display:flex;gap:9px;align-items:center;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--faint)}
input[type=range]{
-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;flex:1;
@@ -1029,9 +1096,12 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
place-items:center;z-index:50;padding:20px;
}
#modal.on{display:grid}
/* Focus lands on the card itself as a dialog opens (#306): a place to start from, not a control. */
#modalCard:focus{outline:none}
.card{
background:var(--panel);border:1px solid var(--line);border-radius:14px;
padding:20px;width:min(460px,100%);box-shadow:var(--shadow);max-height:88vh;overflow:auto;
overscroll-behavior:contain;
}
.card.wide{width:min(1000px,100%)}
#logbox{
@@ -1068,14 +1138,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%)}
.fsetbody{overflow:auto;min-height:0;padding:22px 24px 24px}
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(580px,100%);container-type:inline-size}
.fsetbody{overflow:auto;overscroll-behavior:contain;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. */
@@ -1084,22 +1170,22 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.switch.lead{font-weight:600}
.sw{position:relative;flex:none;width:42px;height:26px}
.sw input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
.sw i{position:absolute;inset:0;border-radius:999px;background:var(--raise);border:1px solid var(--line);pointer-events:none;transition:background-color .18s,border-color .18s}
.sw i{position:absolute;inset:0;border-radius:999px;background:var(--raise);border:1px solid var(--field);pointer-events:none;transition:background-color .18s,border-color .18s}
/* White, as iOS has it, in every theme: in --ink, the dark themes' knob was a black hole in the track. */
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35);transition:transform .18s}
.sw input:checked+i{background:var(--accent);border-color:var(--accent)}
/* White on a light track did not show where the knob was, or so whether it was on (#308). */
:root[data-mode="light"] .sw i::after{box-shadow:0 0 0 1px var(--field),0 1px 3px rgba(0,0,0,.35)}
.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}
@@ -1119,9 +1205,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}
@@ -1138,17 +1227,14 @@ 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. */
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:38px;padding:5px;border:1px solid var(--line);border-radius:9px;background:var(--bg);cursor:text}
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:38px;padding:5px;border:1px solid var(--field);border-radius:9px;background:var(--bg);cursor:text}
.chips:focus-within{border-color:var(--accent)}
.chip{display:inline-flex;align-items:center;gap:2px;max-width:100%;padding:3px 3px 3px 10px;border-radius:999px;background:var(--panel2);color:var(--fg);font-size:13px;line-height:1.3;overflow-wrap:anywhere}
.chipx{display:grid;place-items:center;flex:none;width:20px;height:20px;padding:0;border-radius:50%;color:var(--dim)}
.chipx:hover{background:var(--raise);color:var(--fg)}
@media (hover:hover){.chipx:hover{background:var(--raise);color:var(--fg)}}
.chipx .i{width:9px;height:9px}
.chips .chipin{flex:1 0 8ch;width:auto;min-width:0;padding:4px 6px;border:0;background:none}
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
@@ -1157,7 +1243,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
}
/* On --panel, not a toast's --raise: the error colours are tuned to read on the page's grounds. */
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel)}
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel);display:flex;gap:10px;align-items:flex-start}
.toastx{flex:none;display:grid;place-items:center;width:24px;height:24px;margin:-3px -7px -3px 0;border-radius:6px;color:inherit}
.toastx svg{width:11px;height:11px}
@keyframes in{from{opacity:0;transform:translateY(6px)}}
#burger,#dback{display:none}
/* Totals for what is showing, along the bottom, as the original's status bar. */
@@ -1183,7 +1271,7 @@ body:has(#player.on) #status{padding-bottom:3px}
/* Places above the feeds: not subscriptions, but where to look. */
.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px}
.place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer}
.place:hover{background:var(--panel2)}
@media (hover:hover){.place:hover{background:var(--panel2)}}
.place.sel{background:var(--raise)}
.place .ico{width:18px;text-align:center;color:var(--accent);flex:none}
.place b{flex:1;font-weight:500;font-size:13.5px}
@@ -1210,7 +1298,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}
@@ -1301,10 +1389,8 @@ body:has(#player.on) #status{padding-bottom:3px}
#player{border-top-color:var(--edge)}
.sidefoot{border-top-color:var(--edge)}
/* Buttons: filled and round. Hover is reset because Safari keeps it on whatever was last
tapped, which left an accent outline on it. */
/* Buttons: filled and round. */
.btn,.sidetools button,.sidefoot button,.tgroup,.toast,kbd,#logbox{border-color:var(--edge)}
.btn:hover,.sidetools button:hover,.sidefoot button:hover{border-color:var(--edge)}
.btn,.sidetools button,.sidefoot button{border-radius:999px;min-height:40px;padding-left:14px;padding-right:14px}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.btn.ico,#dback{min-width:40px;height:40px;padding:0 10px;display:inline-grid;place-items:center}
@@ -1316,9 +1402,9 @@ body:has(#player.on) #status{padding-bottom:3px}
/* Text boxes filled, and at 16px or more: Safari zooms the page in on one any smaller. */
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;
font-size:17px;background:var(--panel2);border-color:var(--field);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. */
@@ -1341,7 +1427,6 @@ body:has(#player.on) #status{padding-bottom:3px}
/* Rows: taller, larger type, and a hairline between them that starts where the text does. */
.ep{padding:10px 8px;border-radius:10px;min-height:44px}
.ep.sel{border-color:var(--edge)}
.ep:hover:not(.sel){background-color:transparent}
.ep .t{font-size:17px}
.ep .line,.ep .date{font-size:15px}
.ep+.ep{background-image:linear-gradient(var(--line),var(--line));background-size:calc(100% - 64px) 1px;background-position:64px 0;background-repeat:no-repeat}
@@ -1371,6 +1456,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}
@@ -1383,10 +1469,10 @@ body:has(#player.on) #status{padding-bottom:3px}
.dpends{font-size:14px}
/* 16px or more, or Safari zooms the page in on it. */
.wcap input[type=number]{font-size:16px;padding:3px 4px}
.chips{background:var(--panel2);border-color:var(--edge);border-radius:10px}
.chips{background:var(--panel2);border-color:var(--field);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%)}}
@@ -1412,9 +1498,11 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="classic"] .btn,
:root[data-theme="classic"] .tabs{background:linear-gradient(#ffffff,#d9d9d9);border-color:#8e8e8e;color:#1a1a1a}
:root[data-theme="classic"] .tabs{border:1px solid #8e8e8e;border-radius:6px}
:root[data-theme="classic"] .tgroup button:hover:not(:disabled),
:root[data-theme="classic"] .tgroup a:hover,
:root[data-theme="classic"] .btn:hover{background:linear-gradient(#f5f9ff,#cbdbf4)}
@media (hover:hover){
:root[data-theme="classic"] .tgroup button:hover:not(:disabled),
:root[data-theme="classic"] .tgroup a:hover,
:root[data-theme="classic"] .btn:hover{background:linear-gradient(#f5f9ff,#cbdbf4)}
}
:root[data-theme="classic"] .btn.primary,
:root[data-theme="classic"] .tabs button.on,
:root[data-theme="classic"] .feed.sel,
@@ -1449,7 +1537,8 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="classic"] .ep.sel .fl,
:root[data-theme="classic"] .ep.sel .file,
:root[data-theme="classic"] .ep.sel .kind,
:root[data-theme="classic"] .ep.sel .iconbtn:not(:hover){color:#fff}
:root[data-theme="classic"] .ep.sel .iconbtn{color:#fff}
@media (hover:hover){:root[data-theme="classic"] .ep.sel .iconbtn:hover{color:var(--fg)}}
/* Green and red stay green and red on the blue, just lighter so they read. */
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}

View File

@@ -12,6 +12,7 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
import swc from '@swc/core';
import html from '@swc/html';
@@ -23,7 +24,7 @@ const WEB_VERSION = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.js
// The files are one script, concatenated in this order, not modules: they share one top-level
// scope, as the single inline script did, and code that runs at load needs what came before it.
const PAGES = {
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'signals', 'gestures', 'events', 'native'] },
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
'login.html': { script: 'login.js', src: ['login'] },
};
@@ -37,6 +38,23 @@ const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 1
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
// Font Awesome Free, the set the page's own icons come from, whole, for a Signal's icon (#261):
// each icon a file of its own, /icons/<style>/<name>.svg, drawn as a mask so it takes the theme's
// colour, and an index of names and search terms the picker fetches when it opens. The page's
// script is given the index's address, named by its hash, in place of __ICONS__.
const FA = path.join(here, '..', 'node_modules', '@fortawesome', 'fontawesome-free');
const FA_STYLES = ['solid', 'regular', 'brands'];
/// [`style/name`, label, search terms] for every icon, solid first.
export function iconIndex() {
const meta = JSON.parse(fs.readFileSync(path.join(FA, 'metadata', 'icon-families.json'), 'utf8'));
return FA_STYLES.flatMap(style => fs.readdirSync(path.join(FA, 'svgs', style)).sort().map(f => {
const name = f.slice(0, -4), m = meta[name] || {};
return [`${style}/${name}`, m.label || name, (m.search?.terms || []).join(' ')];
}));
}
const ICONS = JSON.stringify(iconIndex());
const ICONS_URL = `/icons/index.json?v=${hash(ICONS)}`;
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
/// one; the doctype only keeps it from complaining that a fragment has none.
export function buildStyle({ minify = true } = {}) {
@@ -60,6 +78,7 @@ export function buildPage(name, { minify = true } = {}) {
const { script, src: files } = PAGES[name];
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
.replaceAll('__HLS_JS__', HLS_URL)
.replaceAll('__ICONS__', ICONS_URL)
.replaceAll('__WEB_VERSION__', WEB_VERSION);
const js = swc.transformSync(src, {
filename: name + '.ts',
@@ -104,13 +123,29 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
// nothing here shows any more but the classic theme.
for (const [from, to] of [
['logo.svg', 'logo.svg'], ['logo-dark.svg', 'logo-dark.svg'],
...['signals', 'deadair-on', 'deadair-off'].flatMap(n => [`${n}.svg`, `${n}-dark.svg`]).map(f => [f, f]),
['favicon.png', 'favicon.png'], ['favicon.png', 'favicon.ico'], ['favicon-dark.png', 'favicon-dark.png'],
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
for (const style of FA_STYLES) {
fs.mkdirSync(path.join(out, 'icons', style), { recursive: true });
for (const f of fs.readdirSync(path.join(FA, 'svgs', style))) fs.copyFileSync(path.join(FA, 'svgs', style, f), path.join(out, 'icons', style, f));
}
fs.writeFileSync(path.join(out, 'icons', 'index.json'), ICONS);
fs.copyFileSync(path.join(FA, 'LICENSE.txt'), path.join(out, 'icons', 'LICENSE.txt'));
for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html);
fs.writeFileSync(path.join(out, script), js);
}
// Each text file compressed once here, as hard as it goes, brotli and gzip beside it, for Caddy
// to send in its place (#253): smaller than compressing each answer as it goes, and no work then.
for (const f of fs.readdirSync(out).filter(f => /\.(html|js|css|svg|txt)$/.test(f))) {
const body = fs.readFileSync(path.join(out, f));
fs.writeFileSync(path.join(out, f + '.br'), zlib.brotliCompressSync(body, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: body.length },
}));
fs.writeFileSync(path.join(out, f + '.gz'), zlib.gzipSync(body, { level: 9 }));
}
}

17
web/deadair-off-dark.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- deadair-off.svg's dark appearance: the same layers in the same places, only recoloured. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1d4468"/>
<stop offset="1" stop-color="#0c2238"/>
</linearGradient>
</defs>
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
<g id="waveform" fill="#8fc2ea">
<rect x="120" y="336" width="112" height="352" rx="56" opacity=".3"/>
<rect x="264" y="208" width="112" height="608" rx="56" opacity=".5"/>
<rect x="648" y="208" width="112" height="608" rx="56" opacity=".5"/>
<rect x="792" y="336" width="112" height="352" rx="56" opacity=".3"/>
</g>
<g id="silence"><rect x="424" y="464" width="176" height="96" rx="48" fill="#ff6a1a"/></g>
</svg>

After

Width:  |  Height:  |  Size: 868 B

18
web/deadair-off.svg Normal file
View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- DeadAir off: deadair-on.svg's waveform with the silence still in its middle, a flat line,
its bars muted, as the page's off glyph is only outlined; the silence keeps the orange. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#dff0fb"/>
<stop offset="1" stop-color="#9fccef"/>
</linearGradient>
</defs>
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
<g id="waveform" fill="#2f6aa0">
<rect x="120" y="336" width="112" height="352" rx="56" opacity=".3"/>
<rect x="264" y="208" width="112" height="608" rx="56" opacity=".5"/>
<rect x="648" y="208" width="112" height="608" rx="56" opacity=".5"/>
<rect x="792" y="336" width="112" height="352" rx="56" opacity=".3"/>
</g>
<g id="silence"><rect x="424" y="464" width="176" height="96" rx="48" fill="#ff5500"/></g>
</svg>

After

Width:  |  Height:  |  Size: 965 B

17
web/deadair-on-dark.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- deadair-on.svg's dark appearance: the same layers in the same places, only recoloured. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1d4468"/>
<stop offset="1" stop-color="#0c2238"/>
</linearGradient>
</defs>
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
<g id="waveform" fill="#8fc2ea">
<rect x="188" y="336" width="112" height="352" rx="56" opacity=".55"/>
<rect x="332" y="208" width="112" height="608" rx="56" opacity=".85"/>
<rect x="580" y="208" width="112" height="608" rx="56" opacity=".85"/>
<rect x="724" y="336" width="112" height="352" rx="56" opacity=".55"/>
</g>
<g id="needle"><rect x="476" y="160" width="72" height="704" rx="36" fill="#ff6a1a"/></g>
</svg>

After

Width:  |  Height:  |  Size: 870 B

20
web/deadair-on.svg Normal file
View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- DeadAir on, drawn as logo.svg is (square, opaque, one background and flat hard-edged
layers, nothing thin enough to vanish at 32px) from the page's own DeadAir glyph: the
waveform closed up where its silence was, the cut marked by the needle in orange.
deadair-off.svg is the same waveform with the silence still in it. Each <g> is a layer. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#dff0fb"/>
<stop offset="1" stop-color="#9fccef"/>
</linearGradient>
</defs>
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
<g id="waveform" fill="#2f6aa0">
<rect x="188" y="336" width="112" height="352" rx="56" opacity=".55"/>
<rect x="332" y="208" width="112" height="608" rx="56" opacity=".85"/>
<rect x="580" y="208" width="112" height="608" rx="56" opacity=".85"/>
<rect x="724" y="336" width="112" height="352" rx="56" opacity=".55"/>
</g>
<g id="needle"><rect x="476" y="160" width="72" height="704" rx="36" fill="#ff5500"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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…" aria-label="Search"><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…" aria-label="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>
@@ -62,7 +62,7 @@
</div>
<div id="seekrow">
<span id="pcur">0:00</span>
<input type="range" id="seek" min="0" max="1000" value="0">
<input type="range" id="seek" min="0" max="1000" value="0" aria-label="Position">
<span id="pdur">0:00</span>
</div>
</div>
@@ -78,8 +78,8 @@
</div>
</div>
<div id="modal"><div class="card" id="modalCard"></div></div>
<div id="toasts"></div>
<div id="modal"><div class="card" id="modalCard" role="dialog" aria-modal="true" tabindex="-1"></div></div>
<div id="toasts" role="status"></div>
<!-- One element for both: a <video> plays an audio-only file exactly like <audio> does (same
HTMLMediaElement API), and it is the only tag that can also show a picture. Hidden unless
the current file is video -- see body.has-video below. -->

15
web/signals-dark.svg Normal file
View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- signals.svg's dark appearance: the same layers in the same places, only recoloured. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1d4468"/>
<stop offset="1" stop-color="#0c2238"/>
</linearGradient>
</defs>
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
<g id="waves" fill="none" stroke="#8fc2ea" stroke-width="112" stroke-linecap="round">
<path d="M293 252A340 340 0 0 0 293 772M731 252A340 340 0 0 1 731 772" opacity=".5"/>
<path d="M377 351A210 210 0 0 0 377 673M647 351A210 210 0 0 1 647 673" opacity=".85"/>
</g>
<g id="point"><circle cx="512" cy="512" r="96" fill="#ff6a1a"/></g>
</svg>

After

Width:  |  Height:  |  Size: 779 B

17
web/signals.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- The Signals folder's tile, drawn as logo.svg is (square, opaque, one background and flat
hard-edged layers, nothing thin enough to vanish at 32px) from the page's own Signals
glyph: waves going out from a point, the point in the needle's orange. Each <g> is a layer. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#dff0fb"/>
<stop offset="1" stop-color="#9fccef"/>
</linearGradient>
</defs>
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
<g id="waves" fill="none" stroke="#2f6aa0" stroke-width="112" stroke-linecap="round">
<path d="M293 252A340 340 0 0 0 293 772M731 252A340 340 0 0 1 731 772" opacity=".5"/>
<path d="M377 351A210 210 0 0 0 377 673M647 351A210 210 0 0 1 647 673" opacity=".85"/>
</g>
<g id="point"><circle cx="512" cy="512" r="96" fill="#ff5500"/></g>
</svg>

After

Width:  |  Height:  |  Size: 976 B

View File

@@ -53,20 +53,33 @@ 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>`;
$('#gsave').onclick = async () => {
$<HTMLButtonElement>('#gsave').onclick = async () => {
try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
max_total_gb: Number($('#gquota').value) || 0,
max_age_days: Number($('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
schedule: `every ${Math.max(1, Number($<HTMLInputElement>('#gnum').value) || 1)}${$<HTMLSelectElement>('#gunit').value}`,
max_new_per_check: Math.max(0, Number($<HTMLInputElement>('#gmax').value) || 0),
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),
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(e.message, true); }
}catch(e){ toast(why(e), true); }
};
}
@@ -99,15 +112,15 @@ async function drawDirectorySettings(){
await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)});
toast(said);
drawDirectorySettings();
}catch(e){ toast(e.message, true); }
}catch(e){ toast(why(e), true); }
};
$('#dsave').onclick = () => {
const patch: Record<string, unknown> = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked};
$<HTMLButtonElement>('#dsave').onclick = () => {
const patch: Record<string, unknown> = {directory_url: $<HTMLInputElement>('#durl').value.trim(), directory_share: $<HTMLInputElement>('#dshare').checked};
// Blank leaves the token as it is; Clear is the way to remove it.
if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim();
if($<HTMLInputElement>('#dtoken').value.trim()) patch.directory_token = $<HTMLInputElement>('#dtoken').value.trim();
save(patch, 'Directory settings saved');
};
const clear = $('#dclear');
const clear = $<HTMLButtonElement>('#dclear');
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
drawDirectoryStatus();
}
@@ -115,7 +128,7 @@ async function drawDirectorySettings(){
async function drawDirectoryStatus(){
const el = $('#dstatus');
let s;
try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = e.message; return; }
try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = why(e); return; }
if(!el.isConnected) return;
const lines: string[] = [];
if(!s.configured) lines.push('Not set up: it needs an address and a token.');
@@ -137,8 +150,8 @@ async function drawDirectoryStatus(){
/// sharing with it, and the feeds removed from it.
async function drawDirectoryAdmin(){
const box = $('#dadmin');
let a;
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(e.message)}</p>`; return; }
let a: DirAdmin;
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(why(e))}</p>`; return; }
if(!box.isConnected) return;
box.innerHTML = `<h2>Servers</h2>
<p class="hint">Each server sharing with the directory, by its token's name. Dropping one removes its
@@ -155,29 +168,29 @@ async function drawDirectoryAdmin(){
<small style="display:block;color:var(--faint)">Removed ${dateOf(b.at)}</small></div>
<button class="btn" data-a="unblock">Unblock</button></div>`).join('')
: '<p class="hint">Nothing is blocked.</p>'}`;
box.onclick = async ev => {
box.onclick = async (ev: MouseEvent) => {
const b = (ev.target as Element).closest<HTMLElement>('[data-a]');
if(!b) return;
const row = b.closest<HTMLElement>('.urow');
try{
if(b.dataset.a === 'drop'){
const name = row.dataset.name;
const name = row!.dataset.name!;
if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return;
await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'});
toast(`Dropped ${name}'s snapshot`);
}else if(b.dataset.a === 'unblock'){
await api(`/api/directory-blocked/${encodeURIComponent(row.dataset.id)}`, {method: 'DELETE'});
await api(`/api/directory-blocked/${encodeURIComponent(row!.dataset.id!)}`, {method: 'DELETE'});
toast('Unblocked. It comes back with the next snapshot that has it');
}
drawDirectoryAdmin();
}catch(e){ toast(e.message, true); }
}catch(e){ toast(why(e), true); }
};
}
/* ---------------- accounts ---------------- */
async function drawAccounts(){
const box = $('#accounts');
const users = await api('/api/users') || [];
const users: Account[] = await api('/api/users') || [];
box.innerHTML = `<h2>Accounts</h2>
${users.map(u => `<div class="inline urow" data-id="${u.id}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
@@ -195,44 +208,44 @@ async function drawAccounts(){
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
through the proxy. New people start with no feeds.</span></div>
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
const change = async (u, opts) => {
const change = async (u: Account, opts: RequestInit) => {
try{
await api(`/api/users/${u.id}`, opts);
// Demoting yourself takes this page away; go back to the app rather than stay on a page
// the server no longer answers. Only on success: a refusal's toast has to stay readable.
if(u.name === me?.name){ location.href = '/'; return; }
}catch(e){ toast(e.message, true); }
}catch(e){ toast(why(e), true); }
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
};
for(const row of $$('#accounts [data-id]')){
const u = users.find(x => String(x.id) === row.dataset.id);
$('[data-a="admin"]', row).onchange = e =>
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
const u = users.find(x => String(x.id) === row.dataset.id)!;
$('[data-a="admin"]', row).onchange = (e: Event) =>
change(u, {method: 'PATCH', body: JSON.stringify({admin: (e.target as HTMLInputElement).checked})});
$('[data-a="rm"]', row).onclick = () => {
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
change(u, {method: 'DELETE'});
};
}
$('#uadd').onclick = async () => {
$<HTMLButtonElement>('#uadd').onclick = async () => {
try{
await api('/api/users', {method: 'POST', body: JSON.stringify({
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
name: $<HTMLInputElement>('#uname').value, password: $<HTMLInputElement>('#upass').value, admin: $<HTMLInputElement>('#uadmin').checked})});
toast('Added'); drawAccounts();
}catch(e){ toast(e.message, true); } // keep what was typed
}catch(e){ toast(why(e), true); } // keep what was typed
};
}
/* ---------------- log ---------------- */
let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all';
let logTimer: number|null = null, logSeq = 0, logLines: LogLine[] = [], logFilter = '', logLevel = '', logTab = 'all';
// Which sources belong to each tab. "daemon" is the control protocol itself: every
// command in and every event out, whatever sent it.
const LOG_TABS = {
const LOG_TABS: Record<string, ((t: string) => boolean)|null> = {
all: null,
daemon: t => t === 'ipx::io',
scan: t => t === 'ipx::scan',
web: t => t === 'ipx::http',
};
const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
const LEVELS: Record<string,number> = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
function drawLogView(){
logSeq = 0; logLines = [];
@@ -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>
@@ -259,14 +272,14 @@ function drawLogView(){
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
what it captures.</span>`;
for(const b of $$('#logtabs button')) b.onclick = () => {
logTab = b.dataset.t;
logTab = b.dataset.t!;
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
drawLog();
};
$('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); };
$('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); };
$('#logcopy').onclick = () => copyText(visibleLog().map(l =>
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
$<HTMLSelectElement>('#loglevel').onchange = (e: Event) => { logLevel = (e.target as HTMLSelectElement).value; drawLog(); };
$<HTMLInputElement>('#logq').oninput = (e: Event) => { logFilter = (e.target as HTMLInputElement).value.toLowerCase(); drawLog(); };
$<HTMLButtonElement>('#logcopy').onclick = () => copyText(visibleLog().map(l =>
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $<HTMLButtonElement>('#logcopy'));
pollLog();
if(!logTimer) logTimer = setInterval(pollLog, 2000);
}
@@ -281,7 +294,7 @@ async function pollLog(){
}else if(!logLines.length){ drawLog(); }
}catch(e){
const box = $('#logbox');
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`;
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(why(e))}</p>`;
}
}
function visibleLog(){
@@ -294,7 +307,7 @@ function visibleLog(){
}
function drawLog(){
const box = $('#logbox'); if(!box) return;
const follow = $('#logfollow')?.checked;
const follow = $<HTMLInputElement>('#logfollow')?.checked;
const rows = visibleLog();
box.innerHTML = rows.length ? rows.map(l => {
const t = new Date(l.ts * 1000).toLocaleTimeString();

View File

@@ -1,36 +1,58 @@
/* ---------------- modals ---------------- */
/// `cls` names a dialog with a frame of its own. Set whole each time, so it never outlives its dialog.
// A dialog is a dialog to a screen reader, named by its heading, and the page behind it is out of
// reach until it closes: Tab used to walk out of it into the toolbar, and closing it left focus
// nowhere (#306). Not a <dialog>: its top layer would cover the toasts, and an error's Dismiss
// with them.
let modalFrom: HTMLElement|null=null, benched: HTMLElement[]=[];
function openModal(html: string, wide?: boolean, cls?: string){
$('#modalCard').innerHTML=html;
$('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:'');
$('#modal').classList.add('on');
const m=$('#modal'), card=$('#modalCard');
card.innerHTML=html;
card.className='card'+(wide?' wide':'')+(cls?' '+cls:'');
const h=card.querySelector('h2,h3');
if(h){ h.id=h.id||'modalTitle'; card.setAttribute('aria-labelledby',h.id); } else card.removeAttribute('aria-labelledby');
if(!m.classList.contains('on')){
modalFrom=document.activeElement as HTMLElement|null;
// Not what is inert already (#dpeek), which must stay so.
benched=([...document.body.children] as HTMLElement[]).filter(el=>el!==m&&el.id!=='toasts'&&!el.inert);
for(const el of benched) el.inert=true;
m.classList.add('on');
}
// Into the dialog; a dialog that wants the cursor in a box puts it there after this.
if(!card.contains(document.activeElement)) card.focus();
}
function closeModal(){
$('#modal').classList.remove('on');
const m=$('#modal');
if(!m.classList.contains('on')) return;
m.classList.remove('on');
for(const el of benched) el.inert=false;
benched=[];
if(modalFrom?.isConnected) modalFrom.focus();
modalFrom=null;
}
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); };
$('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
$('#addFeed').onclick=()=>{
$<HTMLButtonElement>('#addFeed').onclick=()=>{
openModal(`<h3>Add a feed</h3>
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
<div class="field"><label for="nurl">Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
<div class="field"><label>Keywords (optional, comma separated)</label>
<div class="field"><label for="nkw">Keywords (optional, comma separated)</label>
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
$('#nurl').focus();
$('#nsave').onclick=async()=>{
const url=$('#nurl').value.trim(); if(!url) return;
$('#nsave').disabled=true; $('#nsave').title='Adding…';
$<HTMLInputElement>('#nurl').focus();
$<HTMLButtonElement>('#nsave').onclick=async()=>{
const url=$<HTMLInputElement>('#nurl').value.trim(); if(!url) return;
$<HTMLButtonElement>('#nsave').disabled=true; $<HTMLButtonElement>('#nsave').title='Adding…';
try{
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
url, allow_explicit:$('#nexp').checked,
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
url, allow_explicit:$<HTMLInputElement>('#nexp').checked,
keywords:$<HTMLInputElement>('#nkw').value.split(',').map((s: string)=>s.trim()).filter(Boolean)})});
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
}catch(e){ toast(why(e),true); $<HTMLButtonElement>('#nsave').title='Add feed'; $<HTMLButtonElement>('#nsave').disabled=false; }
};
};
@@ -43,7 +65,7 @@ let dirError='';
/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
/// page in the Directory, to see what it is before you take it (#128).
function listedFeed(p,cls,rank?: number){
function listedFeed(p: DirFeed,cls: string,rank?: number){
const el=document.createElement('div');
el.className=cls+(p.subscribed?' mine':'');
el.tabIndex=0; el.dataset.id=p.id;
@@ -60,53 +82,64 @@ function listedFeed(p,cls,rank?: number){
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
asLink(el,false);
// Yours has an id here as well as the directory's, and opens by it.
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
// Its own click, not the tile's as well.
$('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); };
$('[data-a="sub"]',el).onclick=(ev: Event)=>{ ev.stopPropagation(); subscribeListed(p); };
return el;
}
async function subscribeListed(p){
async function subscribeListed(p: DirFeed){
try{
const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
toast(`Subscribed to ${NAME(p)}`);
await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(e.message,true); }
}catch(e){ toast(why(e),true); }
}
// Where you are in the Directory: what kind of feed, which category and subcategory, in what
// order. Out here because a search redraws the page, and the kind and order hold across visits.
let dirKind='All', dirCat=null, dirSub=null, dirSort='az', dirRows=[];
let dirKind='All', dirCat: string|null=null, dirSub: string|null=null, dirSort='az', dirRows: DirFeed[]=[];
// The feed whose page is open over all that, by id; going back leaves the rest as it was.
let dirShow=null;
let dirShow: string|null=null;
// The two pages that are not a category: every feed, and the feeds that name none. A category is
// Apple's or one an admin typed, neither of which starts with a colon.
const EVERY=':every', OTHER=':other';
const NAME=p=>(p.title||p.id);
const byName=(a,b)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
const bySubs=(a,b)=>b.subscribers-a.subscribers||byName(a,b);
const NAME=(p: {title: string|null, id: string})=>(p.title||p.id);
const byName=(a: DirFeed,b: DirFeed)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
const bySubs=(a: DirFeed,b: DirFeed)=>b.subscribers-a.subscribers||byName(a,b);
/// The grid's orders. It opens A to Z, as the server sends it.
const DIR_SORTS={
const DIR_SORTS: Record<string,[string,(a: DirFeed,b: DirFeed)=>number]>={
subscribers:['Most subscribers',bySubs],
az:['Name, A to Z',byName],
za:['Name, Z to A',(a,b)=>byName(b,a)],
za:['Name, Z to A',(a: DirFeed,b: DirFeed)=>byName(b,a)],
};
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
const KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'};
const KINDS: Record<string,(p: DirFeed)=>boolean>={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
const KIND_WORD: Record<string,string>={All:'feed',Podcasts:'podcast',Blogs:'blog'};
/// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the
/// categories, and a category has a page of its own, its subcategories along the top of its
/// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across
/// every page, and the search box looks through the lot. One fetch draws all of it.
async function renderDirectory(url,box){
async function renderDirectory(url: string,box: HTMLElement,counted: (n: number)=>void){
// Each visit starts at the front, as going back to a tab does.
dirCat=null; dirSub=null; dirShow=null;
dirError='';
try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=e.message; }
// Drawn at once from what was kept or fetched ahead, then again only if the catalogue changed
// (#248); with nothing kept, a spinner until it comes.
const kept=pages.get(url);
if(kept){ dirRows=kept; drawDirectory(box); counted(dirRows.length); }
else listLoading(box);
try{
const fresh=await fetchPage(url)||[];
if(kept&&JSON.stringify(kept)===JSON.stringify(fresh)) return;
if(!box.isConnected) return;
dirRows=fresh;
}catch(e){ if(kept) return; dirRows=[]; dirError=why(e); }
drawDirectory(box);
return dirRows.length;
counted(dirRows.length);
}
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
@@ -117,24 +150,24 @@ function drawDirectory(box=$('#directory'), focus?: string){
const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs);
if(!both) dirKind='All';
const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind];
const count=n=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
const of=c=>p=>c===EVERY||(c===OTHER?!p.category:p.category===c);
const count=(n: number)=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
const of=(c: string)=>(p: DirFeed)=>c===EVERY||(c===OTHER?!p.category:p.category===c);
// A kind can leave a category with nothing in it; that goes back to the front.
if(dirCat&&!rows.some(of(dirCat))) dirCat=null;
const catName=c=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
const top=list=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
const btn=(k,v,label,on)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`;
const catName=(c: string)=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
const top=(list: DirFeed[])=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
const btn=(k: string,v: string,label: string,on: boolean)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`;
// back: where its button goes, '' for the front; none on the front itself.
const head=(title,sub,back?)=>`<div class="dhead">${back==null?'':
const head=(title: string,sub: string,back?: string)=>`<div class="dhead">${back==null?'':
`<button type="button" class="dback" data-go="${esc(back)}">${ICON.left}${esc(back?catName(back):'Directory')}</button>`}
<div class="dtitle"><h2>${esc(title)}</h2>${both?`<div class="tabs" role="group" aria-label="Kind">${
Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}</div>`:''}</div>
<p class="sub">${esc(sub)}</p></div>`;
const sec=(title,body,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`;
const sec=(title: string,body: string,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`;
const tools=(pills='')=>`<div class="dtools">${pills||'<span class="grow"></span>'}<select id="dirsort" aria-label="Sort">${
Object.entries(DIR_SORTS).map(([k,[label]])=>`<option value="${k}"${k===dirSort?' selected':''}>${label}</option>`).join('')}</select></div>`;
const q=S.q.trim(), grid='<div class="tiles" id="dtiles"></div>', board='<div class="chart" id="dchart"></div>';
let html, chart=[], tiles=null;
let html: string, chart: DirFeed[]=[], tiles: DirFeed[]|null=null;
const show=dirShow&&dirRows.find(p=>p.id===dirShow);
if(show){
const n=show.subscribers;
@@ -154,21 +187,21 @@ function drawDirectory(box=$('#directory'), focus?: string){
html=head(`Results for “${q}”`,tiles.length?count(tiles.length):`No ${word} here has that in its name.`,dirCat||'')+tools()+grid;
}else if(!dirCat){
chart=top(rows);
const cats=new Map<string,any[]>();
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c).push(p); }
const cats=new Map<string,DirFeed[]>();
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c)!.push(p); }
// Other, the feeds with no category, goes last.
const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b));
html=head('Directory',`${count(rows.length)} shared by iPX servers. Private feeds are never listed.`)
+(chart.length?sec('Most subscribed',board):'')
+sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c).length),cats.get(c))).join('')}</div>`,
+sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c)!.length),cats.get(c)!)).join('')}</div>`,
`<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`);
}else{
const inCat=rows.filter(of(dirCat));
const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter(Boolean))].sort();
if(!subs.includes(dirSub)) dirSub=null;
const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter((s): s is string=>!!s))].sort();
if(!dirSub||!subs.includes(dirSub)) dirSub=null;
if(dirCat!==EVERY) chart=top(inCat);
tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub);
const pill=(v,label,n)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v);
const pill=(v: string,label: string,n: number)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v);
html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'')
+(chart.length?sec('Most subscribed',board):'')
// Its own heading only when the chart is above it, to say where one list ends.
@@ -178,25 +211,25 @@ function drawDirectory(box=$('#directory'), focus?: string){
box.innerHTML=html;
if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow(box,show); }
const c=$('#dchart',box);
if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p,i)=>c.appendChild(listedFeed(p,'rank',i+1))); }
if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p: DirFeed,i: number)=>c.appendChild(listedFeed(p,'rank',i+1))); }
const t=$('#dtiles',box);
if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
box.onclick=ev=>{
if(t&&tiles) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
box.onclick=(ev: MouseEvent)=>{
const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]');
if(!b) return;
// Back from a feed's page, onto the tile or row it was opened from.
if(b.dataset.back!=null){ const id=dirShow; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); }
if(b.dataset.back!=null){ const id=dirShow??''; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); }
if(b.dataset.kind!=null){ dirKind=b.dataset.kind; return drawDirectory(box,`[data-kind="${CSS.escape(dirKind)}"]`); }
if(b.dataset.sub!=null){ dirSub=b.dataset.sub||null; return drawDirectory(box,`[data-sub="${CSS.escape(b.dataset.sub)}"]`); }
// Going anywhere ends a search.
if(S.q){ S.q=''; $('#epSearch').value=''; }
if(S.q){ S.q=''; $<HTMLInputElement>('#epSearch').value=''; }
const from=dirCat;
dirCat=b.dataset.go||null; dirSub=null; dirShow=null;
$('#content').scrollTop=0;
// Into a page, onto its way back; back out, onto the tile you went in by.
drawDirectory(box,dirCat?'.dback':from?`[data-go="${CSS.escape(from)}"]:not(.dback)`:undefined);
};
const s=$('#dirsort',box);
const s=$<HTMLSelectElement>('#dirsort',box);
if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); };
if(focus) $(focus,box)?.focus();
}
@@ -204,11 +237,11 @@ function drawDirectory(box=$('#directory'), focus?: string){
/// A listed feed's page, filled in: what it says it is, its first lines unless you ask for the
/// rest, and its latest items, each one's title, opening its post on the publisher's site where
/// it has one, its first lines, and when and how long.
async function fillShow(box,p){
async function fillShow(box: HTMLElement,p: DirFeed){
const list=$('#dshow',box), about=$('#dabout',box);
let got;
let got: {description: string|null, failing: string|null, items: ListedItem[], can_admin: boolean};
try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); }
catch(e){ list.innerHTML=`<p class="hint">${esc(e.message)}</p>`; return; }
catch(e){ list.innerHTML=`<p class="hint">${esc(why(e))}</p>`; return; }
// Gone back, or on to another, while it loaded.
if(dirShow!==p.id||!list.isConnected) return;
// Some feeds give their title again as their description, which says nothing new.
@@ -233,12 +266,12 @@ async function fillShow(box,p){
+`<button class="btn ico danger" data-a="dremove" title="Remove from the directory" aria-label="Remove from the directory">${ICON.trash}</button>`);
$('[data-a="drefresh"]',acts).onclick=async()=>{
try{ await api(`/api/directory/${encodeURIComponent(p.id)}/refresh`,{method:'POST'}); toast('Checked'); fillShow(box,p); }
catch(e){ toast(e.message,true); }
catch(e){ toast(why(e),true); }
};
$('[data-a="dremove"]',acts).onclick=async()=>{
if(!confirm(`Remove ${NAME(p)} from the directory? It is blocked, so no server's snapshot brings it back. Admin, Directory can unblock it.`)) return;
try{ await api(`/api/directory/${encodeURIComponent(p.id)}`,{method:'DELETE'}); }
catch(e){ return toast(e.message,true); }
catch(e){ return toast(why(e),true); }
toast(`Removed ${NAME(p)} from the directory`);
dirRows=dirRows.filter(r=>r.id!==p.id); dirShow=null;
drawDirectory(box);
@@ -248,7 +281,7 @@ async function fillShow(box,p){
list.innerHTML=items.length?items.map(e=>{
const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500);
const when=[dateOf(e.published),e.duration&&clock(e.duration)].filter(Boolean);
return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
return `<article class="lep"><b>${webUrl(e.link)?`<a href="${esc(webUrl(e.link))}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
+(text?`<p>${esc(text)}</p>`:'')
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>';
}).join(''):'<p class="hint">Nothing read from it yet. The directory checks each feed every few hours.</p>';
@@ -256,7 +289,7 @@ async function fillShow(box,p){
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
/// fanned in the corner, so each one is coloured by what is in it, as Apple's are.
function catTile(c,name,count,rows){
function catTile(c: string,name: string,count: string,rows: DirFeed[]){
// Covers before initials, which are all Other had; the likeliest first, and first is in front:
// the fan is drawn back to front.
const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse();
@@ -265,43 +298,53 @@ function catTile(c,name,count,rows){
}
/// The Directory and Currently Listening open in the main pane, as the original's Directory did.
async function renderListed(v){
async function renderListed(v: {title: string, icon: string, url?: string, blurb?: string}){
const box=$('#content');
box.classList.add('plain');
$('#tbRemove').disabled=true;
$<HTMLButtonElement>('#tbRemove').disabled=true;
syncTools(null);
if(v===VIEWS[':directory']){
$('#epSearch').placeholder='Find a feed…';
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
$<HTMLInputElement>('#epSearch').placeholder='Find a feed…';
box.innerHTML='<div id="directory"></div>';
$('#count').textContent=v.title;
const n=await renderDirectory(v.url,$('#directory'));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
await renderDirectory(v.url!,$('#directory'),n=>{ if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`; });
return;
}
$('#epSearch').placeholder='Search items…';
$<HTMLInputElement>('#epSearch').placeholder='Search items…';
box.innerHTML=`
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta"><h2>${v.title}</h2><div class="sub">${v.blurb}</div></div>
</div>
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
<div class="childlist" id="listening"></div>`;
$('#count').textContent=v.title;
// The search box looks through these too, title and text, as it does a feed's items (#127).
const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url,$('#listening',box));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`;
await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url!,$('#listening',box),
n=>{ if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`; });
}
/// Currently Listening: episodes you started and have not finished, across every feed you
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
/// where you left off, not another way to browse. The one in the player pauses instead.
async function renderListening(url,box){
let rows=[];
try{ rows=(await api(url)).entries||[]; }catch{}
async function renderListening(url: string,box: HTMLElement,counted: (n: number)=>void){
// Drawn at once from what was kept or fetched ahead, then again only if it changed (#248): it
// took over a second to answer. With nothing kept, a spinner until it comes.
const kept: {entries?: Entry[]}|undefined=pages.get(url);
if(kept){ drawListening(box,kept.entries||[]); counted((kept.entries||[]).length); }
else listLoading(box);
let fresh: {entries?: Entry[]};
try{ fresh=await fetchPage(url); }catch{ if(kept) return; fresh={}; }
if(kept&&JSON.stringify(kept)===JSON.stringify(fresh)) return;
if(!box.isConnected) return;
drawListening(box,fresh.entries||[]);
counted((fresh.entries||[]).length);
}
function drawListening(box: HTMLElement,rows: Entry[]){
box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>`
:'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
for(const e of rows){
// Carries its episode, for paintListenRow to repaint as the player moves.
const el: HTMLDivElement & {entry?: any}=document.createElement('div');
const el: HTMLDivElement & {entry?: Entry}=document.createElement('div');
el.className='childrow';
el.entry=e;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
@@ -315,12 +358,11 @@ async function renderListening(url,box){
paintListenRow(el);
box.appendChild(el);
}
return rows.length;
}
/// One row's time left, progress and play button, taken from the player when it is the one in it.
function paintListenRow(el){
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
function paintListenRow(el: HTMLElement & {entry?: Entry}){
const e=el.entry!, now=player.guid===e.guid&&player.feed===e.feed_id;
// Zero until the player has sought to where you left off; the saved position stands till then.
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
// The player's own length first: a feed's can be minutes out.
@@ -335,32 +377,34 @@ function paintListenRow(el){
}
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
function syncListening(){
for(const el of $$('#listening .childrow'))
for(const el of $$<HTMLElement & {entry?: Entry}>('#listening .childrow'))
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
}
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every
/// episode with a saved position short of the end, so the position is what has to go.
async function forget(e){
async function forget(e: Entry){
// Closed without saving first, or the player's next save would put it straight back.
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); }
if(player.guid===e.guid){ player.guid=null; $<HTMLButtonElement>('#pclose').click(); }
try{
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
{method:'POST',body:JSON.stringify({secs:0})});
}catch(err){ toast(err.message,true); }
}catch(err){ toast(why(err),true); }
// The kept list still has it; drawn from that, it would show again until the refresh came.
for(const k of [...pages.keys()]) if(k.startsWith(VIEWS[':listening'].url!)) pages.delete(k);
if(S.feed===':listening') renderListed(VIEWS[':listening']);
}
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
let searchT;
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
$<HTMLButtonElement>('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
$<HTMLButtonElement>('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
$<HTMLButtonElement>('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
$<HTMLButtonElement>('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
let searchT: number;
$<HTMLInputElement>('#epSearch').oninput=(ev: Event)=>{ clearTimeout(searchT);
// The Directory looks through what it already has; Currently Listening and the rest ask the
// server (#126, #127).
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0;
searchT=setTimeout(()=>{ S.q=(ev.target as HTMLInputElement).value; S.offset=0;
if(S.feed===':directory'){ dirShow=null; drawDirectory(); }
else if(S.feed===':listening') renderListed(VIEWS[':listening']);
else loadEntries(); },250); };
@@ -368,13 +412,13 @@ $('#epSearch').oninput=ev=>{ clearTimeout(searchT);
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
function toggleGroup(id){
function toggleGroup(id: string){
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
renderFeeds();
}
let globalMax = 3;
function due(ts){
function due(ts: number){
const d = ts - Date.now()/1000;
if(d <= 0) return 'due now';
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
@@ -385,7 +429,7 @@ function due(ts){
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
function deadSavedText(me){
function deadSavedText(me: Me|null){
const s=me&&me.deadair_saved||0;
return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.`
:'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.';
@@ -397,10 +441,10 @@ async function prefsModal(){
const g = await api('/api/settings');
const admin = !!(S.me&&S.me.admin);
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
<div class="field"><label>Theme</label>
<div class="field"><label for="stheme">Theme</label>
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label>Light or dark</label>
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label for="smode">Light or dark</label>
<select id="smode">${Object.entries(MODES).map(([k,t])=>
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
<span class="hint">Auto follows your system's light/dark setting.</span></div>
@@ -419,6 +463,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>
${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">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">
@@ -428,46 +484,72 @@ async function prefsModal(){
Anyone holding one is you here, so revoke one you no longer use.</span></div>
<div class="field"><label>Feeds are checked every</label>
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal;
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
<div class="field"><span class="hint" id="sversion">${S.me?`iPX ${esc(S.me.version)}, page __WEB_VERSION__`:''}</span></div>`);
$<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
$<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
drawTokens();
$('#tokadd').onclick=async()=>{
const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; }
$<HTMLButtonElement>('#tokadd').onclick=async()=>{
const name=$<HTMLInputElement>('#tokname').value.trim(); if(!name){ $<HTMLInputElement>('#tokname').focus(); return; }
try{
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
$('#tokname').value='';
$<HTMLInputElement>('#tokname').value='';
await drawTokens();
// Shown this once: only its hash is kept, so a lost token is revoked and made again.
$('#tokens').insertAdjacentHTML('afterbegin',`<div class="field" id="toknew"><span class="hint">Your new token,
${esc(t.name)}. Copy it now: it is not shown again.</span>
<div class="inline"><input type="text" id="tokval" readonly value="${esc(t.token)}">
<button class="btn ico" id="tokcopy" title="Copy" aria-label="Copy">${ICON.copy}</button></div></div>`);
$('#tokcopy').onclick=()=>copyText(t.token,$('#tokcopy'));
$('#tokval').select();
}catch(err){ toast(err.message,true); }
$<HTMLButtonElement>('#tokcopy').onclick=()=>copyText(t.token,$<HTMLButtonElement>('#tokcopy'));
$<HTMLInputElement>('#tokval').select();
}catch(err){ toast(why(err),true); }
};
$('#sblock').onchange=async e=>{
const blocked=splitWords(e.target.value);
// 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})});
if(S.me) S.me.typesafe_key_set=!!typesafe_key;
$<HTMLInputElement>('#stskey').value='';
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
$<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=()=>$<HTMLInputElement>('#ssigon').click();
}
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
const blocked=splitWords((e.target as HTMLInputElement).value);
try{
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
if(S.me) S.me.blocked=blocked;
toast('Saved'); await loadFeeds(true); loadEntries();
}catch(err){ toast(err.message,true); }
}catch(err){ toast(why(err),true); }
};
}
/// Your API tokens, each with when it was made and last used, and a button to revoke it.
async function drawTokens(){
const box=$('#tokens'); if(!box) return;
const list=await api('/api/tokens').catch(()=>[]);
const list: {id: number, name: string, created: number, last_used: number|null}[]=await api('/api/tokens').catch((): []=>[]);
box.innerHTML=list.map(t=>`<div class="tokrow"><span><b>${esc(t.name)}</b>
<span class="hint">made ${dateOf(t.created)}, last used ${ago(t.last_used)}</span></span>
<button class="btn ico" data-tok="${t.id}" title="Revoke ${esc(t.name)}" aria-label="Revoke ${esc(t.name)}">${ICON.trash}</button></div>`).join('');
for(const b of $$('[data-tok]',box)) b.onclick=async()=>{
try{ await api(`/api/tokens/${b.dataset.tok}`,{method:'DELETE'}); toast('Revoked'); drawTokens(); }
catch(err){ toast(err.message,true); }
catch(err){ toast(why(err),true); }
};
}
@@ -486,37 +568,37 @@ function chipsHTML(words: string[], input: string){
placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`;
}
/// The words in a chip box, with whatever is typed and not yet a chip.
function chipWords(box){
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($('.chipin',box).value)];
function chipWords(box: ParentNode){
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($<HTMLInputElement>('.chipin',box).value)];
}
function wireChips(box){
const input=$('.chipin',box);
function wireChips(box: HTMLElement){
const input=$<HTMLInputElement>('.chipin',box);
// The hint only in an empty box: beside chips it reads as one more.
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
const add=()=>{
const have=$$('.chip',box).map(c=>c.dataset.w.toLowerCase());
const have=$$('.chip',box).map(c=>c.dataset.w!.toLowerCase());
for(const w of splitWords(input.value)){
if(have.includes(w.toLowerCase())) continue;
input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase());
}
input.value=''; hint();
};
input.onkeydown=e=>{
input.onkeydown=(e: KeyboardEvent)=>{
if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); }
else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); }
};
// Typed and left without Enter is still meant: Save takes it either way.
input.onblur=add;
box.onclick=e=>{
const x=e.target.closest('.chipx');
if(x){ x.parentElement.remove(); hint(); }
box.onclick=(e: MouseEvent)=>{
const x=(e.target as Element).closest('.chipx');
if(x){ x.parentElement!.remove(); hint(); }
input.focus();
};
}
/// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare
/// box whose blank, number and 0 were explained in bold small print.
function settingsModal(f, newUrl?: string){
function settingsModal(f: Feed, newUrl?: string){
const kids=S.feeds.filter(c=>c.group===f.id).length;
const isGroup=kids>0;
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
@@ -524,20 +606,30 @@ function settingsModal(f, newUrl?: string){
const name=f.title||f.id;
// Blank follows the server's default; 0 is every item (#166).
let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest';
let newest=f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
let newest=f.max_new_per_check&&f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
const whose=isGroup
? `Your settings for the ${plural(kids,'show')} in this ${isPatreon(f)?'Patreon creator':'OPML subscription'}. A show you set on its own keeps its own.`
: parent
? `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])=>
@@ -548,27 +640,29 @@ function settingsModal(f, 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');
@@ -576,7 +670,7 @@ function settingsModal(f, newUrl?: string){
// What only matters while it downloads on its own folds away, out of reach, when it does not.
const deps=$('#sdeps'), inner=$('.fdepsin',deps);
inner.inert=!f.auto_download;
$('#sauto').onchange=e=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; };
$<HTMLInputElement>('#sauto').onchange=(e: Event)=>{ deps.classList.toggle('off',!(e.target as HTMLInputElement).checked); inner.inert=!(e.target as HTMLInputElement).checked; };
// The download limit, said as what it does. A number in a box hid that it is a window on the
// newest items, not a pace through the back catalogue (#97).
@@ -591,7 +685,7 @@ function settingsModal(f, newUrl?: string){
? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}`
: `The server's default: downloads every item${each}, the whole back catalogue.`;
const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest;
let bars=[];
let bars: HTMLElement[]=[];
const draw=(words: boolean)=>{
// The newest items with a file, as hold_back counts them: one whose files a filter all
// skipped holds no place.
@@ -605,21 +699,21 @@ function settingsModal(f, newUrl?: string){
}
if(!words) return;
$('#swcap').innerHTML=caption();
const n=$('#smax');
const n=$<HTMLInputElement>('#smax');
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
};
for(const r of $$('[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.
$('#sdead').onchange=e=>{ $('#sdpause').disabled=!e.target.checked; };
const pause=$('#sdpause'), pauseText=()=>{ const s=`${pause.value/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
for(const r of $$<HTMLInputElement>('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
// 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();
draw(true);
// The feed's own newest items, each as tall as it is long, so the strip reads as this show.
if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{
const items=page?.entries||[];
const items: Entry[]=page?.entries||[];
const strip=$('#sepwin'); if(!items.length||!strip) return;
const longest=Math.max(1,...items.map(e=>e.duration||0));
strip.innerHTML=items.map((e,i)=>{
strip.innerHTML=items.map((e: Entry,i: number)=>{
const file=e.enclosures.some(x=>x.state!=='skipped');
const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60;
return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`;
@@ -634,65 +728,70 @@ function settingsModal(f, newUrl?: string){
if(x<=b.getBoundingClientRect().right) break;
}
newest=Math.max(1,n);
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); }
else { $('#smax').value=String(newest); draw(false); }
if(mode!=='newest'){ mode='newest'; $<HTMLInputElement>('[name=swin][value=newest]').checked=true; draw(true); }
else { $<HTMLInputElement>('#smax').value=String(newest); draw(false); }
};
strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
strip.onpointerdown=(e: PointerEvent)=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
strip.onpointermove=(e: PointerEvent)=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
}).catch(()=>{});
wireChips($('#skw'));
if($('#sblock')) wireChips($('#sblock'));
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
$<HTMLButtonElement>('#scopy').onclick=()=>copyText($<HTMLInputElement>('#surl').value,$<HTMLButtonElement>('#scopy'));
// Opened to follow a feed that moved: the new address is what there is to look at.
if(newUrl){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); }
if(newUrl){ $<HTMLInputElement>('#surl').scrollIntoView({block:'center'}); $<HTMLInputElement>('#surl').focus(); }
$('#ssave').onclick=async()=>{
$<HTMLButtonElement>('#ssave').onclick=async()=>{
try{
const patch: Record<string, unknown>={
keywords:chipWords($('#skw')),
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
deadair_pause:Number($('#sdpause').value)};
auto_download:$<HTMLInputElement>('#sauto').checked, allow_explicit:$<HTMLInputElement>('#sexp').checked, deadair:$<HTMLInputElement>('#sdead').checked,
deadair_pause:Number($<HTMLInputElement>('#sdpause').value)};
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
const priv=$('#sprivate');
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=$('#surl').value.trim();
if(admin) patch.url=$<HTMLInputElement>('#surl').value.trim();
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
closeModal(); toast('Saved. It applies from the next check.');
await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); }
}catch(e){ toast(why(e),true); }
};
}
function downloadLatestModal(f){
function downloadLatestModal(f: Feed){
openModal(`<h3>Download latest items</h3>
<div class="field"><label>How many of the newest undownloaded items?</label>
<div class="field"><label for="dcount">How many of the newest undownloaded items?</label>
<input type="number" id="dcount" min="1" max="100" value="5">
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
$('#dgo').onclick=async()=>{
const n=Number($('#dcount').value)||5;
$<HTMLButtonElement>('#dgo').onclick=async()=>{
const n=Number($<HTMLInputElement>('#dcount').value)||5;
try{
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
{method:'POST',body:JSON.stringify({count:n})});
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
}catch(e){ toast(e.message,true); }
}catch(e){ toast(why(e),true); }
};
}
function removeFeed(f){
function removeFeed(f: Feed){
openModal(`<h3>Unsubscribe?</h3>
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else
reading it keeps it, along with their own read state.
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
$('#rgo').onclick=async()=>{
$<HTMLButtonElement>('#rgo').onclick=async()=>{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
closeModal(); toast('Unsubscribed'); S.feed=null;
await loadFeeds(); if(!S.feeds.length) renderFeed();
@@ -706,16 +805,16 @@ function opmlModal(){
<div class="inline">
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
</div></div>
<div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
<div class="field" style="margin-top:16px"><label for="opmlFile">Import: choose a file, or paste OPML</label>
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
<textarea id="opmlText" aria-label="OPML to import" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--field);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
$('#oimp').onclick=async()=>{
$<HTMLButtonElement>('#oimp').onclick=async()=>{
// A chosen file is read here and sent as text, so the server never stores it. Clearing
// the picker lets go of it on this side too, whether it was refused or imported.
const pick=$('#opmlFile'), file=pick.files[0];
const xml=file ? await file.text() : $('#opmlText').value;
const pick=$<HTMLInputElement>('#opmlFile'), file=pick.files![0];
const xml=file ? await file.text() : $<HTMLTextAreaElement>('#opmlText').value;
const letGo=()=>{ pick.value=''; };
// A quick look before sending anything. The server parses it properly and has the last word.
if(!/<opml[\s>]/i.test(xml)){
@@ -724,21 +823,26 @@ function opmlModal(){
try{
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
}catch(e){ letGo(); toast(e.message,true); }
}catch(e){ letGo(); toast(why(e),true); }
};
}
// No toast: the spinners on the rows being checked say it (issue #37).
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
$('#scanAll').onclick=scanAll;
$('#prefs').onclick=prefsModal;
async function scanAll(){
spinStart(null);
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
catch(e){ spinEnd(); throw e; }
}
$<HTMLButtonElement>('#scanAll').onclick=scanAll;
$<HTMLButtonElement>('#prefs').onclick=prefsModal;
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
// the proxy still vouches for them. /api/me says where, when that is the case.
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
$<HTMLButtonElement>('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
api('/api/me').then(u=>{
S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':'');
// ipx's version and the page's, each released on its own.
// Also at the foot of Settings: the tooltip alone was out of reach of a keyboard and of a phone (#309).
$('#applogo').title=`iPX ${u.version}, page __WEB_VERSION__`;
// Whether the link to Admin shows, now and before the first frame of the next visit.
document.documentElement.toggleAttribute('data-admin',!!u.admin);
@@ -746,18 +850,18 @@ api('/api/me').then(u=>{
// A browser with no theme of its own takes the one the account kept from before, once.
if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true);
}).catch(()=>{});
$('#feedFilter').oninput=renderFeeds;
$<HTMLInputElement>('#feedFilter').oninput=renderFeeds;
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
$('#feedlist').onkeydown=ev=>{
const row=ev.target.closest('[data-id]'); if(!row) return;
const id=row.dataset.id;
$('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
const id=row.dataset.id!;
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
else if(row.classList.contains('group')&&
else if(row.matches('.group,.sigs')&&
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
else return;
ev.preventDefault(); ev.stopPropagation();
};
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$<HTMLButtonElement>('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$('#scrim').onclick=()=>nav(false);

View File

@@ -1,12 +1,12 @@
/* ---------------- live events ---------------- */
let sse;
let sse: EventSource;
/// The feed list and the open list, taken again from the server. The events keep both current,
/// and one missed is a count wrong until a reload: reading an item sends none to your other
/// pages, so one read on another device stayed unread here, and an iPhone stops the stream while
/// the app is in the background, when everything is missed (#168).
const catchUp=(()=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
loadFeeds(true).catch(()=>{});
if(S.feed) loadEntries().catch(()=>{});
if(S.feed) loadEntries(false,true).catch(()=>{});
},300); }; })();
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) catchUp(); });
let dropped=false;
@@ -14,19 +14,23 @@ function connect(){
sse=new EventSource('/api/events');
// Back after a drop: whatever was sent meanwhile went to nobody.
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); });
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(false,true).catch(()=>{}); });
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
const mine=id=>S.feeds.some(f=>f.id===id);
const mine=(id: string)=>S.feeds.some(f=>f.id===id);
// The lists a change made stale, fetched again behind, once a burst of rows has passed (#247).
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
// every pending bar at once, so adding a feed looked like it was fetching the lot.
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); }
if(bar){ bar.style.width=pct+'%'; bar.parentElement!.classList.add('live'); }
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
}
else if(ev.ev==='download_done'){
@@ -41,12 +45,20 @@ function connect(){
if(!mine(ev.feed)) return;
if(ev.saved_s>0&&document.querySelector('.dapend')) toast(`DeadAir is done: ${shorter(ev.saved_s)}`);
refreshEntries();
if(S.sel) loadEntries().then(()=>{ const e=cur(); if(e) showDetail(e); }).catch(()=>{});
if(S.sel) loadEntries(false,true).then(()=>{ const e=cur(); if(e) showDetail(e); }).catch(()=>{});
}
else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live');
toast('Download failed: '+ev.msg,true); refreshEntries();
// Which item, and what to do: the reason (ev.msg) is in the daemon's log (#304).
const e=S.entries.find(x=>x.enclosures.some(n=>n.id===ev.enclosure));
toast(`Unable to download ${e?`"${entryName(e).text}"`:'a file'}. Try again with its download button.`,true); refreshEntries();
}
// Jev picked items out for someone's Signals: theirs are fetched again, and the one open.
else if(ev.ev==='signal_hits'){
if(!S.signals.length) return;
refreshSignals();
if(signalOf(S.feed)) refreshEntries();
}
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
@@ -54,21 +66,23 @@ 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.
else if(ev.ev==='feed_error') setScanning(ev.feed,false);
// The server sends a feed's new row after anything changes it (counts, error, last check),
// only to those who subscribe; the page used to reload the whole list after each event.
else if(ev.ev==='feed_row') patchFeed(ev.row);
else if(ev.ev==='feed_row'){ patchFeed(ev.row); forgetLists(ev.row.id); preloadSoon(); }
// Only a scan that checked something: the scheduler scans every minute, due or not, and
// every open page reloaded the whole list each time (#103).
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
// page reloads; reload the list here when a scan synced an OPML if that ever matters.
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); }
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); spinEnd(ev); if(ev.feeds) refreshEntries(); }
// The worker's job failed, so the scan someone is waiting on will send no scan_done.
else if(ev.ev==='error') spinEnd();
};
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
}
connect();
loadFeeds().catch(()=>{});
loadFeeds().then(preloadLists).catch(()=>{});

View File

@@ -1,27 +1,43 @@
/* ---------------- feed page ---------------- */
function statHTML(f: Feed){
return `<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>`;
}
/// The open feed's counts, last check and error, from a row that came after its page was drawn:
/// the page kept an error a later check had cleared (#316). Not the whole page, which would close
/// the open item.
function patchFeedHead(f: Feed){
const meta=document.querySelector<HTMLElement>('#content .fhead .meta'); if(!meta) return;
meta.querySelector('.fail')?.remove();
const stat=meta.querySelector('.stat'); if(!stat) return;
// An OPML's header has a stat line of its own.
if(!S.feeds.some(c=>c.group===f.id)) stat.outerHTML=statHTML(f);
meta.querySelector('.stat')!.insertAdjacentHTML('afterend',failBannerHTML(f));
wireFailBanner(meta,f);
}
function renderFeed(){
paintScanning(); // another feed's page may be the one open now
const box=$('#content');
box.classList.remove('plain');
const v=VIEWS[S.feed];
const v=VIEWS[S.feed ?? ''];
if(v&&v.url) return renderListed(v);
if(S.feed===SIGNALS) return renderSignals();
const f=v?null:S.feeds.find(x=>x.id===S.feed);
$('#tbRemove').disabled=!f;
const sg=signalOf(S.feed);
$<HTMLButtonElement>('#tbRemove').disabled=!f;
syncTools(null);
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
const name=f?(f.title||f.id):v.title;
$('#epSearch').placeholder=`Search ${name}…`;
if(!v&&!f&&!sg){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
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">
<h2>${esc(name)}</h2>
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
${statHTML(f)}
${failBannerHTML(f)}
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
@@ -32,11 +48,12 @@ 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>
</div>
</div>` : `
</div>` : sg ? signalHead(sg) : `
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta">
@@ -58,10 +75,11 @@ 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));
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!));
box.appendChild(pane);
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
dragSplit(pane);
@@ -69,9 +87,10 @@ function renderFeed(){
S.sel=null;
showDetail(null);
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):sg?signalAction(b.dataset.a!,sg):allAction(b.dataset.a!));
$$('#content .tabs button').forEach(b=>b.onpointerenter=b.ontouchstart=()=>{ if(S.feed) prefetch(S.feed,b.dataset.f!); });
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
S.filter=b.dataset.f; S.offset=0;
S.filter=b.dataset.f!; S.offset=0;
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
renderFeed(); loadEntries();
});
@@ -89,33 +108,32 @@ function sortHead(){
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
}).join('')+'<span></span></div>';
}
function sortBy(col){
function sortBy(col: string){
const first=['published','size','kept'].includes(col)?'desc':'asc';
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
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=>/patreon\.com\//.test(f&&f.url||'');
const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||'');
function renderGroup(f,kids){
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">
@@ -126,54 +144,44 @@ function renderGroup(f,kids){
<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=($('#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);
}
};
$('#kidSearch').oninput=draw;
draw();
</div>`;
}
async function feedAction(a,f){
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
/// 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);
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
catch(e){ spinEnd(); throw e; }
}
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
if(a==='rm') removeFeed(f);
if(a==='pin'){
try{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
await loadFeeds(true); renderFeed();
}catch(e){ toast(e.message,true); }
}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);
}
/// All Subscriptions' own buttons: a feed's, across every feed you read.
async function allAction(a){
async function allAction(a: string){
if(a==='scanall') return scanAll();
if(a==='readall'){
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
@@ -183,7 +191,7 @@ async function allAction(a){
try{
const r=await api('/api/read-all',{method:'POST'});
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); }
}catch(e){ toast(why(e),true); }
}
}

View File

@@ -2,14 +2,16 @@
/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just
/// listed). A scan sends dozens; the list is redrawn once a frame, not once each.
let rowsQueued=0;
function patchFeed(row){
function patchFeed(row: Feed){
const i=S.feeds.findIndex(f=>f.id===row.id);
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
if(row.id===S.feed) patchFeedHead(row);
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
}
async function loadFeeds(keepSel?: boolean){
S.feeds = await api('/api/feeds');
// A Signal list that fails leaves the feeds to show.
[S.feeds, S.signals] = await Promise.all([api('/api/feeds'), api('/api/signals').catch((): Signal[]=>[])]);
// Only for a hint in a feed's settings, so once, on the page's first load, not on every reload
// of the list (#103).
if(!keepSel) api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
@@ -18,61 +20,94 @@ async function loadFeeds(keepSel?: boolean){
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
if(!keepSel && S.feeds.length){
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
selectFeed(known ? S.feed : ':all');
const known = S.feed && (VIEWS[S.feed] || S.feed===SIGNALS || signalOf(S.feed) || S.feeds.some(f=>f.id===S.feed));
selectFeed(known ? S.feed! : ':all');
}
}
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
// server's alphabetical order still holds within each half.
const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0);
const unreadFirst=(a: Feed,b: Feed)=>Number(b.unread>0)-Number(a.unread>0);
// The original's source list opened with these, above the feeds. They are places, not feeds:
// an id starting with ':' can never be a feed's, since feed ids are slugs.
const VIEWS={
const VIEWS: Record<string,{title: string, icon: string, url?: string, blurb?: string}>={
// The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124).
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'},
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
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},
};
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
/// holding one, carry a spinner.
const scanning=new Set<string>();
/// 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>();
/// A row's spinner takes the place of its unread count, so it waits this long: most feeds are
/// checked in a few milliseconds, and each count blinked out and back as its feed went by.
const SPIN_AFTER=300;
function setScanning(id: string, on: boolean){
if(on) scanning.add(id); else scanning.delete(id);
if(on){ scanning.set(id,Date.now()); setTimeout(paintScanning,SPIN_AFTER); } else scanning.delete(id);
paintScanning();
}
function paintScanning(){
const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)!>=SPIN_AFTER;
for(const row of $$('#feedlist .feed')){
const id=row.dataset.id;
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
const id=row.dataset.id!;
const on=slow(id)||S.feeds.some(c=>c.group===id&&slow(c.id));
row.classList.toggle('scanning',on);
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
}
// The refresh buttons turn while what they check is being checked (#78). On <body>, because
// a feed's page is redrawn as its items come in and would take a class on the button with it.
const busy=id=>scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
document.body.classList.toggle('scan-this',busy(S.feed));
document.body.classList.toggle('scan-any',S.feeds.some(f=>scanning.has(f.id)));
// A feed's button turns on its own page only, wherever the person has gone since pressing it.
const self=spinFeed!==null&&spinFeed===S.feed, any=document.body.classList.contains('scan-any');
document.body.classList.toggle('scan-this',self);
// Turning was all that said a check was running, and with motion reduced nothing turns (#310).
for(const b of $$('#scanAll,.fhead [data-a=scanall],.fhead [data-a=scan]')){
const on=b.dataset.a==='scan'?self:any;
b.dataset.label??=b.getAttribute('aria-label')!;
if(on) b.setAttribute('aria-busy','true'); else b.removeAttribute('aria-busy');
b.title=on?'Checking for new items…':b.dataset.label;
b.setAttribute('aria-label',b.title);
}
}
/// The refresh buttons turn from the press until that scan's scan_done (#78, #233). On <body>,
/// because a feed's page is redrawn as its items come in and would take a class on the button
/// with it. Following the feeds being checked instead, the icon snapped back upright in the gap
/// between one feed and the next, many times a second (#231).
let spinFeed: string|null=null;
function spinStart(feed: string|null){
if(feed===null) document.body.classList.add('scan-any'); else spinFeed=feed;
paintScanning();
}
/// `only` and `forced` from scan_done: a scheduled scan, or one limited to another feed, leaves
/// a pressed button turning, since the scan it asked for is still queued behind it. Nothing
/// (an error ended the job) stops them all.
function spinEnd(done?: {only: string|null, forced: boolean}){
if(!done||done.forced&&done.only==null) document.body.classList.remove('scan-any');
if(!done||done.forced&&done.only===spinFeed) spinFeed=null;
paintScanning();
}
function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase();
const q=$<HTMLInputElement>('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop;
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
const a=document.activeElement, was=a&&a.closest&&a.closest<HTMLElement>('#feedlist [data-id]');
const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]);
const done=()=>{
list.scrollTop=top;
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
if(row) (back[1]&&$('.chev',row)||row).focus();
const row=back&&list.querySelector<HTMLElement>(`[data-id="${CSS.escape(back[0])}"]`);
if(row) (back[1]&&$('.chev',row)||row as HTMLElement).focus();
};
// The images already drawn, to go into the rows that replace theirs. Made anew, every icon in
// the list went blank and loaded again, once a frame through a check of every feed, as each
// feed's new row came in (#134). Matched by their markup, so a changed one is made anew.
const drawn=new Map<string,HTMLImageElement[]>();
for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k).push(img); }
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)){
@@ -81,19 +116,22 @@ function renderFeeds(){
el.tabIndex=0; el.dataset.id=id;
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
asLink(el,S.feed===id);
el.onclick=()=>{ selectFeed(id); nav(false); };
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
places.appendChild(el);
}
list.appendChild(places);
signalFolder(list,q);
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds
// go first: a folder with its feeds under it, a feed from inside one lifted out of it.
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned);
const inside=(f: Feed)=>shown.filter(c=>c.group===f.id&&!c.pinned);
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
const order=[];
const order: [Feed, number][]=[];
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
order.push([f,0]);
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
@@ -110,14 +148,14 @@ function renderFeeds(){
// A subscription holds no entries itself, so its counts are the sum of what is inside --
// taken from every feed it holds, not just the ones a filter left showing.
const mine=S.feeds.filter(c=>c.group===f.id);
const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0);
const sum=(k: 'unread'|'entries'|'downloaded')=>mine.reduce((n,c)=>n+(c[k]||0),0);
const [unread,eps,saved]=mine.length
? [sum('unread'),sum('entries'),sum('downloaded')]
: [f.unread,f.entries,f.downloaded];
// A group's own row has no error of its own worth mentioning if the OPML itself
// reads fine; it is failing when any feed inside it is. The feed's own page says what
// went wrong (failBannerHTML); the list only has to make it findable.
const bad=c=>c.failing?.reason||c.last_error;
const bad=(c: Feed)=>c.failing?.reason||c.last_error;
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
const nbad=mine.filter(bad).length;
const el=document.createElement('div');
@@ -142,16 +180,19 @@ function renderFeeds(){
`</small></div>`+
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
asLink(el,S.feed===f.id);
el.onclick=()=>{ selectFeed(f.id); nav(false); };
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
// Fetched as the pointer or a finger reaches it, so the click finds it here (#247).
el.onpointerenter=el.ontouchstart=()=>prefetch(f.id);
if(kids) $('.chev',el).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(f.id); };
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
list.appendChild(el);
}
paintScanning();
done();
}
function selectFeed(id){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
function selectFeed(id: string){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $<HTMLInputElement>('#epSearch').value='';
try{ localStorage.setItem('ipx.feed',id); }catch{}
renderFeeds(); renderFeed(); loadEntries();
}
@@ -159,15 +200,15 @@ function selectFeed(id){
/// A failing feed's error, in plain words with something to do about it, once `failing` is
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
/// src/feed.rs). Anything else still shows the raw error, as before.
function failBannerHTML(f){
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
function failBannerHTML(f: Feed){
if(f.failing) return `<div class="sub fail" style="color:var(--bad)">${esc(f.failing.reason)}
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
if(f.last_error) return `<div class="sub fail" style="color:var(--bad)">${esc(f.last_error)}</div>`;
return '';
}
function wireFailBanner(box,f){
function wireFailBanner(box: ParentNode,f: Feed){
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing?.new_url??undefined);
}

View File

@@ -51,7 +51,7 @@ function refreshFeed(){
document.body.append(spin);
// New items arrive by the event stream when the scan finishes, as they do for a button press.
const asked = api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, true));
.catch(e => toast(why(e), true));
loadEntries();
// Up for two seconds at least, so it is seen even when the server answers at once.
Promise.all([asked, new Promise(r => setTimeout(r, 2000))]).then(() => { spin.remove(); refreshing = false; });
@@ -70,7 +70,7 @@ document.addEventListener('touchstart', ev => {
// back button is never swallowed with it.
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
}
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed ?? '']?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
}, {passive: false});
document.addEventListener('touchmove', ev => {

View File

@@ -1,27 +1,113 @@
/* ---------------- items ---------------- */
let loadSeq=0;
async function loadEntries(append?: boolean){
// The Directory lists feeds, and Currently Listening draws its own episodes.
if(!S.feed || VIEWS[S.feed]?.url) return;
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
type ListPage={total: number, entries: Entry[]};
/// The first page of each list opened or fetched ahead, by what it shows, so a list seen before is
/// drawn the moment it is asked for and refreshed behind it (#247). The newest few dozen: a page is
/// 50 items, about 100 KB as JSON.
const lists=new Map<string,ListPage>();
const LISTS_KEPT=40;
/// Lists on their way, so a click on one already being fetched ahead waits for that answer.
const fetching=new Map<string,Promise<ListPage>>();
const listKey=(feed: string, filter=S.filter)=>[feed,filter,S.sort.col,S.sort.dir,S.q].join('\n');
function listUrl(feed: string, filter=S.filter, offset=0){
const p=new URLSearchParams({offset:String(offset),limit:String(LIMIT),filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q);
const asked=performance.now(), seq=++loadSeq;
const r=await api(S.feed===':all' ? `/api/entries?${p}`
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
const sg=signalId(feed);
return feed===':all' ? `/api/entries?${p}`
: sg!==null ? `/api/signals/${sg}/entries?${p}`
: `/api/feeds/${encodeURIComponent(feed)}/entries?${p}`;
}
function keepList(key: string, r: ListPage){
lists.delete(key); lists.set(key,r);
if(lists.size>LISTS_KEPT) lists.delete(lists.keys().next().value!);
}
/// 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.
const url=VIEWS[feed]?.url;
if(url) return prefetchPage(url);
const key=listKey(feed,filter);
if(lists.has(key)||fetching.has(key)) return;
const got: Promise<ListPage>=api(listUrl(feed,filter));
fetching.set(key,got);
got.then(r=>keepList(key,r),()=>{}).finally(()=>fetching.delete(key));
}
/// Other answers a view draws from, kept by address as the lists are (#248): Currently Listening
/// and the Directory's catalogue. A request already on its way is shared, not sent twice.
const pages=new Map<string,any>();
const pageFetching=new Map<string,Promise<any>>();
function fetchPage(url: string): Promise<any>{
const on=pageFetching.get(url); if(on) return on;
const got=api(url).then(r=>{ pages.delete(url); pages.set(url,r); if(pages.size>LISTS_KEPT) pages.delete(pages.keys().next().value!); return r; });
pageFetching.set(url,got);
got.finally(()=>pageFetching.delete(url)).catch(()=>{});
return got;
}
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, 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.
function preloadLists(){
const want=[':all',':listening',':directory',...[...S.feeds].filter(f=>f.unread>0&&!f.group).sort((a,b)=>b.unread-a.unread).slice(0,20).map(f=>f.id)];
const next=()=>{
const f=want.shift(); if(!f) return;
prefetch(f);
const url=VIEWS[f]?.url, on=url ? pageFetching.get(url) : fetching.get(listKey(f));
// On to the next when this one is in, failed or not: a failure here is nobody's to see.
if(on) on.then(next,next); else next();
};
const go=()=>{ next(); next(); };
'requestIdleCallback' in window ? (window as any).requestIdleCallback(go,{timeout:2000}) : setTimeout(go,300);
}
/// `quiet` for a refresh nobody asked for (a live update): not a wait anyone sees, so not timed.
async function loadEntries(append?: boolean, quiet?: boolean){
// The Directory lists feeds, and Currently Listening draws its own episodes.
if(!S.feed || VIEWS[S.feed]?.url || S.feed===SIGNALS) return;
const feed=S.feed, key=listKey(feed), asked=performance.now(), seq=++loadSeq;
// From asking to the list drawn: what someone waits for after a click, under that click's trace
// (#246). Once, when the list first shows: from what is kept, at once.
let timed=quiet;
const shown=()=>{ if(!timed){ timed=true; report('list_shown',{ms:Math.round(performance.now()-asked),feed,filter:S.filter}); } };
const kept=append ? undefined : lists.get(key);
let spin=0;
if(kept){ showList(kept,false,asked); shown(); }
// Nothing to show yet: a spinner, if the answer is not back in a blink.
else if(!append&&!quiet) spin=setTimeout(()=>{ if(seq===loadSeq) listLoading(); },120);
let r: ListPage;
try{
r=await (!append&&!kept&&fetching.get(key) || api(listUrl(feed,S.filter,S.offset)));
}finally{ clearTimeout(spin); }
// Only the latest list counts: marking everything read reloads the All tab, and clicking
// Unread straight after could have that answer land last and replace the Unread one.
if(seq!==loadSeq) return;
if(!append) keepList(key,r);
// The kept one was this already: drawn again it only flickers.
if(kept&&JSON.stringify(kept)===JSON.stringify(r)) return;
showList(r,!!append,asked);
shown();
}
/// A list's page into S and onto the screen.
function showList(r: ListPage, append: boolean, asked: number){
S.total=r.total;
for(const e of r.entries){
const w=readWrites.get(readKey(e));
if(w && !(w.done<asked)) e.read=w.read;
if(w && !(w.done!==undefined&&w.done<asked)) e.read=w.read;
}
let entries = append ? S.entries.concat(r.entries) : r.entries;
// A background scan finishing refreshes the list from the server, which -- on the Unread
// tab -- would drop the item you have open the moment reading it took it off the filter.
// Keep it until you pick a different one; the next refresh after that no longer protects it.
// The items turned past on the way to it are kept too, so a swipe back still finds them.
const kept=e=>e.guid===S.sel||turned.has(e.guid);
const kept=(e: Entry)=>e.guid===S.sel||turned.has(e.guid);
if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e));
if(!append && S.sel){
const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid));
@@ -30,17 +116,26 @@ async function loadEntries(append?: boolean){
S.entries = entries;
renderEntries();
}
/// A list being waited for: said with a spinner, so the click is seen to have been heard.
function listLoading(box=$('#eps')){
if(!box) return;
box.innerHTML='<p class="listload" role="status">Loading…</p>';
}
function renderEntries(){
const box=$('#eps'); if(!box) return;
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed];
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''], sg=signalOf(S.feed);
// 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):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
`${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;
box.innerHTML='';
if(!S.entries.length){
// Said only for a search, where the words typed are why; an empty feed or tab speaks for itself.
if(S.q) box.innerHTML='<p class="empty">Nothing matches that search.</p>';
// A new Signal is empty while Jev reads, which would otherwise look broken.
else if(sg&&S.filter==='all') box.innerHTML='<p class="empty">Nothing has come through yet. New items are read as your feeds are checked.</p>';
return;
}
for(const e of S.entries) box.appendChild(epEl(e));
@@ -53,7 +148,7 @@ function renderEntries(){
box.appendChild(b);
}
}
function epEl(e){
function epEl(e: Entry){
// An item may carry several files. The row summarises the one you would act on --
// the playable one, else anything already downloaded, else the first -- and says how
// many others there are; the pane below lists them all.
@@ -67,18 +162,20 @@ function epEl(e){
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
const name=entryName(e);
// The title is a button, its click the row's, so Tab and a screen reader can open the item:
// the row alone was a click target only a mouse could reach (#298).
el.innerHTML=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}" aria-label="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?EQ:(e.read?'':'<span aria-hidden="true">●</span>')}</button>
<button class="fl${e.flagged?' on':''}" data-a="flag" aria-pressed="${!!e.flagged}" title="${
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
<div class="body">
<span class="t${name.derived?' notitle':''}">${esc(name.text)}</span>
<button type="button" class="t${name.derived?' notitle':''}">${esc(name.text)}</button>
<div class="line">${[
num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`,
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
enc&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
enc&&enc.deadair&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
enc&&enc.deadair_pending&&DEAD_PENDING,
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
].filter(Boolean).join('<span class="dot"></span>')}</div>
@@ -97,13 +194,13 @@ function epEl(e){
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
</div>`;
el.onclick=()=>selectEntry(e);
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
$$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a!,e,el)});
return el;
}
/// Whether the browser can play it. Having a file is not the same as being playable:
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
/// is just a broken player.
function isPlayable(x){
function isPlayable(x: Enclosure|undefined){
if(!x || !x.path) return false;
const m=(x.mime||'').toLowerCase();
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
@@ -114,7 +211,7 @@ function isPlayable(x){
}
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
function kindOf(enc){
function kindOf(enc: {mime?: string|null, url?: string}){
const m=(enc.mime||'').toLowerCase();
if(m.startsWith('image/')) return 'image';
if(m.startsWith('video/')) return 'video';
@@ -128,9 +225,9 @@ function kindOf(enc){
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
/// either way keeps the column lined up; the words are in its tooltip.
function kindIcon(enc){
function kindIcon(enc: Enclosure){
const k=kindOf(enc);
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file;
const i=(<Record<string,string>>{audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent})[k]||ICON.file;
const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
: ['',k];
@@ -138,7 +235,7 @@ function kindIcon(enc){
}
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
const feedName=(id: string|null|undefined): string=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):(id??''); };
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
@@ -147,24 +244,24 @@ const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id
/// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached
/// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded"
/// read "2010)Recorded".
const plainText=(html?: string)=>html
const plainText=(html?: string|null)=>html
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
.replace(/\s+/g,' ').trim() : '';
function entryName(e): {text: string, derived: boolean}{
function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
const words=plainText(e.description);
if(words){
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
return {text:cut, derived:true};
}
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop().replace(/\.[a-z0-9]{1,5}$/i,'');
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop()!.replace(/\.[a-z0-9]{1,5}$/i,'');
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } }
return {text:[feedName(e.feed_id), dateOf(e.published)].filter(Boolean).join(', '), derived:true};
return {text:[feedName(e.feed_id), dateOf(e.published??null)].filter(Boolean).join(', '), derived:true};
}
/// Selecting an item shows it in the pane below, rather than expanding the row.
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
function swapRow(e){
function swapRow(e: Entry){
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
}
@@ -172,28 +269,30 @@ function swapRow(e){
/// Read and unread as this page last set them, and when the server had it. A list asked for
/// before then answers with the old state: it put the dot back on an item just read, until
/// the next refresh took it off again (loadEntries).
const readWrites=new Map();
const readKey=e=>e.feed_id+'\n'+e.guid;
function setRead(e,read){
const readWrites=new Map<string, {read: boolean, done?: number}>();
const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
function setRead(e: Entry,read: boolean){
e.read=read;
// Kept lists of its feed hold their own copy of it, still as it was.
forgetLists(e.feed_id);
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read})})
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); });
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); if(S.signals.length) refreshSignals(); });
}
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
function markRead(e){
function markRead(e: Entry){
if(e.read) return;
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(why(err),true); });
}
/// On the Unread tab, the items read while turning from one to the next (a swipe, j and k), kept
/// in the list until the reader closes or another item is picked from the list. Dropped as each
/// was left, a swipe back had nothing to go back to: the item just read was already gone.
const turned=new Set<string>();
function dropTurned(keep){
function dropTurned(keep: string|null){
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
turned.clear();
if(!gone.length) return;
@@ -201,7 +300,7 @@ function dropTurned(keep){
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
}
function selectEntry(e,turning=false){
function selectEntry(e: Entry,turning=false){
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
// next happens to come along, which left a few read ones in the list for a while.
if(S.filter==='unread' && S.sel && S.sel!==e.guid){
@@ -217,10 +316,10 @@ function selectEntry(e,turning=false){
}
/// Drag the divider between the item list and the item text.
function dragSplit(pane){
function dragSplit(pane: HTMLElement){
const grab=$('#grab',pane);
if(!grab) return;
const move=ev=>{
const move=(ev: MouseEvent)=>{
const box=pane.getBoundingClientRect();
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
@@ -231,7 +330,7 @@ function dragSplit(pane){
document.body.style.userSelect='';
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
};
grab.onmousedown=ev=>{
grab.onmousedown=(ev: MouseEvent)=>{
ev.preventDefault();
document.body.style.userSelect='none';
document.addEventListener('mousemove',move);
@@ -243,24 +342,24 @@ function dragSplit(pane){
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
// the first. Key rows by feed as well if that ever happens.
const cur=()=>S.entries.find(x=>x.guid===S.sel);
function syncTools(e){
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
function syncTools(e: Entry|null){
$<HTMLButtonElement>('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
$<HTMLButtonElement>('#tbRead').disabled=$<HTMLButtonElement>('#tbFlag').disabled=!e;
// The same icons as the item's own buttons beside its title, so the two never disagree. Each
// shows what is, as the pin always did: read showed what a click would do, so the two side by
// side said opposite things (#74). The shape carries it, not the colour.
$('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
$('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
$('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
$<HTMLButtonElement>('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
$<HTMLButtonElement>('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
$<HTMLButtonElement>('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
$<HTMLButtonElement>('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
if(e){
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
$<HTMLButtonElement>('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
$<HTMLButtonElement>('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
}
}
/// The item's text in the pane below the list, and its files in the pane beside it.
function showDetail(e){
function showDetail(e: Entry|null){
const box=$('#detail'), files=$('#files'); if(!box) return;
// A pane that can only say "No files" gives its width to the list instead.
if(files) files.hidden=!(e&&e.enclosures.length);
@@ -278,15 +377,21 @@ function showDetail(e){
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
box.innerHTML=detailHtml(e);
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
$('#dback').onclick=()=>showDetail(null);
$<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):null));
b.onclick=()=>epAction(b.dataset.a!,e,null,b.dataset.enc?Number(b.dataset.enc):undefined));
syncPlayButtons();
}
/// What the reader shows for an item. Its own function because a phone also draws the next or
/// previous item beside the reader while it is swiped (gestures.ts).
function detailHtml(e){
function detailHtml(e: Entry){
const encs=e.enclosures.map(encBox).join('');
// A phone has no room for the files pane, so the files go above the text there instead.
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
@@ -304,20 +409,32 @@ function detailHtml(e){
[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'}"
aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
${webUrl(e.link)?`<a class="btn ico" href="${esc(webUrl(e.link))}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a>
<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.
function encBox(x){
function encBox(x: Enclosure){
const size=x.length?mb(x.length):'';
// One file serves everyone reading the feed, so deleting is not a private act.
const f=S.feeds.find(y=>y.id===x.feed_id);
@@ -328,8 +445,9 @@ function encBox(x){
: 'Delete file';
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
// Played without its dead air, so saved without it too (#186).
const saveLabel=x.deadair>0?'Save to this computer, without its dead air':'Save to this computer';
const saveBtn=`<a class="btn ico" href="/media/${x.id}${x.deadair>0?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
const dead=(x.deadair??0)>0;
const saveLabel=dead?'Save to this computer, without its dead air':'Save to this computer';
const saveBtn=`<a class="btn ico" href="/media/${x.id}${dead?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
// The publisher's address, not this server's copy, which only someone signed in here can open.
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
if(x.path && !isPlayable(x)){
@@ -358,7 +476,7 @@ function encBox(x){
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
// through here, which would make ipx a fetch-anything relay.
const viewable = !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
const viewable = !!webUrl(x.url) && !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
@@ -369,14 +487,14 @@ function encBox(x){
</div>`;
}
async function epAction(a: string, e, el, encId?: number){
async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
// Swap the row in place and refresh the text below when it is the one being read.
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{
if(a==='play') play(e, encId!=null ? enc : undefined);
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link, el);
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link!, el??undefined);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
if(a==='read'){ await setRead(e,!e.read); redraw(); }
if(a==='get'){
@@ -396,12 +514,12 @@ async function epAction(a: string, e, el, encId?: number){
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
}catch(err){
// 409: somebody else has it starred or unplayed. Their reason, their words.
if(!/one copy of this file/.test(err.message)) throw err;
if(!confirm(err.message+'\n\nDelete it anyway?')) return;
if(!/one copy of this file/.test(why(err))) throw err;
if(!confirm(why(err)+'\n\nDelete it anyway?')) return;
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
}
toast('Deleted'); loadEntries(); loadFeeds(true);
}
}catch(err){ toast(err.message,true); }
}catch(err){ toast(why(err),true); }
}

View File

@@ -1,6 +1,6 @@
document.getElementById('f').onsubmit = async e => {
document.getElementById('f')!.onsubmit = async e => {
e.preventDefault();
const msg = document.getElementById('msg');
const msg = document.getElementById('msg')!;
msg.textContent = '';
const r = await fetch('/api/login', {
method: 'POST',

View File

@@ -165,7 +165,8 @@ function installNativePlayback(){
case 'error':
N.paused = true;
fire('pause');
toast('Playback failed' + (m.message ? ': ' + m.message : ''), true);
report('player_error', {msg: `native: ${m.message || 'error'}`, ...playing()});
toast('Unable to play this file. Try again, or download it again.', true);
break;
}
},

View File

@@ -1,11 +1,11 @@
/* ---------------- player ---------------- */
const audio=$('#audio');
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, src?: string, moved?: boolean,
const audio=$<HTMLAudioElement>('#audio');
const player: {guid: string|null, feed: string|null, entry: Entry|null, enc?: number, src?: string, moved?: boolean,
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
/// Where a file plays from: DeadAir's playlist when the server says this person plays it
/// shortened (#171, #181), whose times the entry is already given in, else the file itself.
const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
const mediaSrc=(enc: {id: number, deadair?: number|null})=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it
/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails
@@ -31,7 +31,7 @@ function setSrc(url: string): Promise<void>|null{
return ready.then(()=>{
// Its playlist and audio requests join the trace of the tap that played them (#192).
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Unable to play the DeadAir version. Try again, or turn DeadAir off for this show.',true);
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
hls.loadSource(url); hls.attachMedia(audio);
});
@@ -48,10 +48,18 @@ function markPlayed(){
setRead(e,true).catch(()=>{});
}
/// What a failed play() means to whoever pressed play; the browser's own words go to the log (#304).
function playFailed(err: Error){
// A newer load took its place: nothing failed.
if(err.name==='AbortError') return;
report('player_error',{msg:`play(): ${err.name}: ${err.message}`,...playing()});
toast(err.name==='NotAllowedError' ? 'The browser held playback back. Press play to start it.'
: 'Unable to play this file. Try again, or download it again.', true);
}
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
function play(e,enc=e.enclosures.find(isPlayable)){
if(!isPlayable(enc)){
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
function play(e: Entry,enc=e.enclosures.find(isPlayable)){
if(!enc||!isPlayable(enc)){
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet. Download it first, with its button on the item.', true);
return;
}
// The same file carries on where it was; another of the item's files starts from its top. So
@@ -76,17 +84,17 @@ function play(e,enc=e.enclosures.find(isPlayable)){
player.marked=false;
paintDead();
}
const go=()=>audio.play().catch(err=>toast('Playback failed: '+err.message,true));
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
const go=()=>audio.play().catch(playFailed);
if(wait) wait.then(go,playFailed); else go();
renderEntries();
}
function mediaSession(e,f){
function mediaSession(e: Entry,f: Feed|undefined){
if(!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata=new MediaMetadata({
title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[],
artwork:(e.image||(f&&f.image))?[{src:artSrc((e.image||f?.image)!),sizes:'512x512'}]:[],
});
const h={play:()=>audio.play(),pause:()=>audio.pause(),
const h: Record<string,()=>void>={play:()=>audio.play(),pause:()=>audio.pause(),
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
}
@@ -94,7 +102,9 @@ audio.addEventListener('timeupdate',()=>{
const d=audio.duration||player.entry?.duration||0;
$('#pcur').textContent=clock(audio.currentTime);
$('#pdur').textContent=clock(d);
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
if(d) $<HTMLInputElement>('#seek').value=String(Math.round(audio.currentTime/d*1000));
// Its value is thousandths, which a screen reader would read out as they are (#301).
$('#seek').setAttribute('aria-valuetext',`${clock(audio.currentTime)} of ${clock(d)}`);
// Only playing counts as moving: the seek to where you left off happens paused, and saving
// that would write back whatever the list said, however old.
if(!audio.paused) player.moved=true;
@@ -117,29 +127,29 @@ function savePos(){
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
navigator.sendBeacon?.(
// A beacon sends no headers, so the trace goes in the address (#192).
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position?traceparent=${traceparent()}`,
`/api/entries/${encodeURIComponent(player.feed!)}/${encodeURIComponent(player.guid!)}/position?traceparent=${traceparent()}`,
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
}
audio.addEventListener('pause',savePos);
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
audio.addEventListener('ended',()=>{savePos();markPlayed();$<HTMLButtonElement>('#pplay').innerHTML=ICON.play});
// body.playing is what sets the EQ bars moving.
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
audio.addEventListener('play',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
audio.addEventListener('pause',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons);
/// Every play button for what is playing shows pause, like the player bar's: a row's, the files
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
function syncPlayButtons(){
const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
const paint=(b,now,idle)=>{
const on=(guid: string|null,enc?: number)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
const paint=(b: HTMLElement,now: boolean,idle: string)=>{
const label=now?'Pause':idle;
if(b.title===label) return;
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
};
for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest('.ep').dataset.guid),'Play');
for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest<HTMLElement>('.ep')!.dataset.guid!),'Play');
for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]'))
paint(b,on(S.sel,Number(b.dataset.enc)),'Play');
const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
const e=cur(); paint($<HTMLButtonElement>('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
}
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
window.addEventListener('beforeunload',savePos);
@@ -157,27 +167,27 @@ audio.addEventListener('playing',()=>{
asked=0; waited=0;
});
audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); });
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15;
$('#pfwd').onclick=()=>audio.currentTime+=30;
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
$<HTMLButtonElement>('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$<HTMLButtonElement>('#pback').onclick=()=>audio.currentTime-=15;
$<HTMLButtonElement>('#pfwd').onclick=()=>audio.currentTime+=30;
$<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000};
$<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
$<HTMLInputElement>('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)};
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
/// icon's shape and aria-pressed, never a colour.
/// icon's shape (the silence still in it, or closed up) and aria-pressed, never a colour.
function paintDead(){
const b=$('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
const b=$<HTMLButtonElement>('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
b.hidden=!f||document.body.classList.contains('has-video');
if(b.hidden) return;
if(!f||b.hidden) return;
b.setAttribute('aria-pressed',String(!!f.deadair));
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
b.innerHTML=tile(f.deadair?'deadair-on':'deadair-off');
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
}
$('#pdead').onclick=async()=>{
$<HTMLButtonElement>('#pdead').onclick=async()=>{
const f=S.feeds.find(x=>x.id===player.feed); if(!f) return;
const on=!f.deadair;
try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); }
catch(err){ toast(err.message,true); return; }
catch(err){ toast(why(err),true); return; }
f.deadair=on; paintDead();
const switched=await switchVersion();
toast(!on?'DeadAir is off for this show':switched?'DeadAir is cutting the dead air from this show'
@@ -188,14 +198,14 @@ $('#pdead').onclick=async()=>{
/// turned on or off for its show. True if it changed version: a copy not made yet plays next time.
async function switchVersion(){
if(!player.guid) return false;
const [feed,guid]=[player.feed,player.guid].map(encodeURIComponent);
const [feed,guid]=[player.feed!,player.guid!].map(s=>encodeURIComponent(s));
// Saved and waited for, not beaconed, so the entry asked for next has this place in it.
if(audio.readyState){
await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({
secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{});
player.moved=false;
}
const e=await api(`/api/entries/${feed}/${guid}`).catch(()=>null);
const e: Entry|null=await api(`/api/entries/${feed}/${guid}`).catch((): null=>null);
const enc=e&&e.enclosures.find(x=>x.id===player.enc);
if(!enc||mediaSrc(enc)===player.src) return false;
const was=!audio.paused;
@@ -205,13 +215,13 @@ async function switchVersion(){
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
return true;
}
$('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
$<HTMLButtonElement>('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
// Called here, not left to the pause event: closing a player already paused fires none.
syncListening()};
(function restore(){
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
if(r){$('#rate').value=r;audio.playbackRate=+r}
if(v){$('#vol').value=v;audio.volume=Number(v)/100}
if(r){$<HTMLSelectElement>('#rate').value=r;audio.playbackRate=+r}
if(v){$<HTMLInputElement>('#vol').value=v;audio.volume=Number(v)/100}
})();
document.addEventListener('keydown',ev=>{
@@ -223,35 +233,35 @@ document.addEventListener('keydown',ev=>{
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
else if(ev.key==='/'){ev.preventDefault();$<HTMLInputElement>('#epSearch')?.focus()}
else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev);
});
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
// own shortcuts still work, or while a dialog is open.
const GO={a:':all',d:':directory',l:':listening'};
const GO: Record<string,string>={a:':all',d:':directory',l:':listening'};
let gAt=0;
function stepEntry(by){
if(VIEWS[S.feed]?.url||!S.entries.length) return;
function stepEntry(by: number){
if(VIEWS[S.feed ?? '']?.url||!S.entries.length) return;
const i=S.entries.findIndex(x=>x.guid===S.sel);
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
selectEntry(e,true);
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
}
function stepFeed(by){
function stepFeed(by: number){
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
if(!id) return;
selectFeed(id);
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
}
const KEYS={
const KEYS: Record<string,()=>void>={
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
J:()=>stepFeed(1), K:()=>stepFeed(-1),
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
// they are disabled.
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(),
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
o:()=>$<HTMLButtonElement>('#tbPlay').click(), m:()=>$<HTMLButtonElement>('#tbRead').click(), s:()=>$<HTMLButtonElement>('#tbFlag').click(),
v:()=>{ const u=webUrl(cur()?.link); if(u) window.open(u,'_blank','noopener'); },
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
'[':()=>matchMedia('(max-width:820px)').matches
@@ -259,7 +269,7 @@ const KEYS={
'?':()=>keysModal(),
g:()=>{ gAt=Date.now(); },
};
function typed(ev){
function typed(ev: KeyboardEvent){
// The second key of a g pair counts only if it follows within a second and a half.
const pair=Date.now()-gAt<1500; gAt=0;
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
@@ -268,7 +278,7 @@ function typed(ev){
}
/// What ? shows: every key, grouped as Feedly's own list is.
function keysModal(){
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c);
const k=(s: string)=>`<kbd>${esc(s)}</kbd>`, g=(c: string)=>k('g')+' '+k(c);
const rows=[
['Go to'],
[g('a'),'All Subscriptions'],[g('d'),'Directory'],

294
web/src/signals.ts Normal file
View File

@@ -0,0 +1,294 @@
/* ---------------- signals ---------------- */
/// Signals (#254): feeds you make from your own subscriptions, holding the items TypeSafe's Jev
/// says are about the keywords you gave. A Signal is a place in the sidebar, `:signal:<id>`.
/// The Signals' counts again, which nothing else brings: reading an item, here or in its feed,
/// and Jev picking one out both change them (#258). Once a burst has passed.
const refreshSignals=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
api('/api/signals').then((r: Signal[])=>{ S.signals=r; renderFeeds(); }).catch(()=>{});
},400); }; })();
/// The Signal's id in a place's id, or null for anything else.
function signalId(view: string|null){
const m=/^:signal:(\d+)$/.exec(view||'');
return m ? Number(m[1]) : null;
}
function signalOf(view: string|null){
const id=signalId(view);
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';
const sigArt=()=>`<span class="fart"><div class="art">${tile('signals')}</div></span>`;
function feedRow(id: string, cls: string, html: string, open: ()=>void){
const el=document.createElement('div');
el.className='feed '+cls+(S.feed===id?' sel':'');
el.tabIndex=0; el.dataset.id=id; el.innerHTML=html;
asLink(el,S.feed===id);
el.onclick=()=>{ open(); nav(false); };
return el;
}
/// A Signal's icon, drawn from /icons as a mask so it takes the theme's colour, or its initials.
/// The name goes into an address and a style: only a style and a plain name are drawn (the server
/// takes nothing else either).
const ICON_ID=/^(solid|regular|brands)\/[a-z0-9-]{1,64}$/;
const iconHTML=(icon: string)=>`<span class="ficon" style="--icon:url('/icons/${icon}.svg')"></span>`;
/// Every Signal in the accent tint the folder has, icon or initials: tinted by a hash of its name,
/// as a feed without art is, one came out a colour of its own for no reason anyone could see (#262).
function signalTile(sg: {name: string, icon: string|null}){
const inner=sg.icon&&ICON_ID.test(sg.icon) ? iconHTML(sg.icon) : esc(initials(sg.name));
return `<div class="art ini sig" style="--tint:var(--accent)">${inner}</div>`;
}
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;
const n=S.signals.length;
list.appendChild(feedRow(SIGNALS,'sigs',
`<button class="chev" aria-expanded="${open}" title="Show or hide your signals" aria-label="Show or hide your signals">${ICON.caret}</button>`+
sigArt()+`<div class="txt"><b>Signals</b><small>${n?plural(n,'signal'):'What matters, out of the noise'}</small></div>`+
// No + of its own (#265): a row like any folder's. New signal is on the folder's page.
badgeHTML(S.signals.reduce((k,s)=>k+s.unread,0)),
()=>selectFeed(SIGNALS)));
const row=list.lastElementChild!;
$('.chev',row).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(SIGNALS); };
if(!open) return;
for(const sg of mine){
const el=feedRow(':signal:'+sg.id,'child',
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${signalCounts(sg)}</small></div>`+badgeHTML(sg.unread),
()=>selectFeed(':signal:'+sg.id));
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
list.appendChild(el);
}
}
/// The folder's page: every Signal, as an OPML's page lists its feeds.
function renderSignals(){
const unread=S.signals.reduce((k,s)=>k+s.unread,0);
$('#count').textContent=`Signals: ${plural(S.signals.length,'signal')}, ${unread} unread`;
$<HTMLButtonElement>('#tbRemove').disabled=true;
syncTools(null);
const box=$('#content');
box.classList.add('plain');
box.innerHTML=`
<div class="fhead slim">
<div class="art">${tile('signals')}</div>
<div class="meta">
<h2>Signals</h2>
<div class="sub">Each one picks what matters out of the noise of your ${plural(S.feeds.length,'feed')} · ${unread} unread</div>
</div>
<div class="acts">
<button class="btn ico" data-a="new" title="New signal" aria-label="New signal">${ICON.plus}</button>
</div>
</div>
<div class="childlist" id="siglist"></div>`;
$<HTMLButtonElement>('#content .acts [data-a="new"]').onclick=()=>signalModal();
const list=$('#siglist');
if(!S.signals.length){
list.innerHTML='<p class="empty">No signals yet. Make one to pull a subject out of every feed you subscribe to.</p>';
return;
}
for(const sg of S.signals){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
`<small class="meta">${signalCounts(sg)}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
el.onclick=()=>selectFeed(':signal:'+sg.id);
list.appendChild(el);
}
}
function signalHead(sg: Signal){
const nokey=S.me&&!signalsOn();
return `
<div class="fhead slim">
${signalTile(sg)}
<div class="meta">
<h2>${esc(sg.name)}</h2>
<div class="sub">Picked out of the noise of your ${plural(S.feeds.length,'feed')}: ${esc(sg.description||sg.keywords.join(', '))} · ${sg.unread} unread</div>
${nokey?`<div class="sub" style="color:var(--warn)">No TypeSafe key yet, so nothing is being picked out. ${KEY_LINKS}</div>`:''}
</div>
<div class="acts">
<button class="btn ico" data-a="settings" title="Edit this signal" aria-label="Edit this signal">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Delete this signal" aria-label="Delete this signal">${ICON.trash}</button>
</div>
</div>`;
}
async function signalAction(a: string, sg: Signal){
if(a==='settings') signalModal(sg);
if(a==='rm'){
if(!confirm(`Delete the signal ${sg.name}? The items stay in their own feeds.`)) return;
try{
await api(`/api/signals/${sg.id}`,{method:'DELETE'});
toast('Deleted'); await loadFeeds(true); selectFeed(':all');
}catch(e){ toast(why(e),true); }
}
}
/// Making a Signal, or changing one.
function signalModal(sg?: Signal){
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">${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>
</div></div>
<div class="field"><label for="sgkwin">Listen for</label>
<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 ${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.${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>`);
wireChips($('#sgkw'));
let icon=sg?.icon??null;
const drawPick=()=>{ $('#sgicon').innerHTML=signalTile({name:$<HTMLInputElement>('#sgname').value||'?',icon}); };
drawPick();
$<HTMLInputElement>('#sgname').oninput=drawPick;
const grid=$('#sgicons');
const drawGrid=async()=>{
const all=await iconList();
const shown=iconMatches(all,$<HTMLInputElement>('#sgiconq').value,ICON_CELLS);
grid.innerHTML=shown.length ? shown.map(([id,label])=>`<button type="button" class="iconcell" role="option" data-ic="${id}"
aria-selected="${id===icon}" title="${esc(label)}" aria-label="${esc(label)}">${iconHTML(id)}</button>`).join('')
: '<p class="hint">No icon matches that.</p>';
};
$<HTMLButtonElement>('#sgicon').onclick=()=>{
const p=$('#sgpicker'), open=p.hidden;
p.hidden=!open; $('#sgicon').setAttribute('aria-expanded',String(open));
if(open){ drawGrid().catch(e=>toast(why(e),true)); $<HTMLInputElement>('#sgiconq').focus(); }
};
$<HTMLInputElement>('#sgiconq').oninput=()=>{ drawGrid().catch(()=>{}); };
grid.onclick=(e: MouseEvent)=>{
const b=(e.target as Element).closest<HTMLElement>('[data-ic]'); if(!b) return;
icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false');
};
$<HTMLButtonElement>('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; };
$<HTMLButtonElement>('#sgjev').onclick=async()=>{
// A spinner while it is picked: what picks it is nobody's concern.
const b=$<HTMLButtonElement>('#sgjev'); b.disabled=true; b.classList.add('busy'); b.setAttribute('aria-busy','true');
try{
const got=await jevIcon($<HTMLInputElement>('#sgname').value.trim(),chipWords($('#sgkw')).filter((w): w is string=>!!w),
$<HTMLInputElement>('#sgdesc').value.trim()||null);
if(got){ icon=got; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false'); }
else toast('No icon matches those words');
}catch(e){ toast(why(e),true); }
finally{ b.disabled=false; b.classList.remove('busy'); b.removeAttribute('aria-busy'); }
};
$<HTMLInputElement>('#sgname').focus();
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
if(!name){ $<HTMLInputElement>('#sgname').focus(); return; }
if(!keywords.length){ $<HTMLInputElement>('#sgkwin').focus(); return; }
const description=$<HTMLInputElement>('#sgdesc').value.trim()||null;
// Only what changed: a new name, words or sentence has Jev read everything again, and saving
// a new icon alone did that too while the dialog sent them all back each time.
const body=JSON.stringify(sg ? signalChanges(sg,{name,keywords,description,icon}) : {name,keywords,description,icon});
try{
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
: await api('/api/signals',{method:'POST',body});
// A new one made without an icon gets Jev's pick, behind, so making it does not wait (#263).
if(!sg&&!icon) jevIcon(name,keywords,description)
.then(got=>{ if(got) return api(`/api/signals/${r.id}`,{method:'PATCH',body:JSON.stringify({icon:got})}).then(refreshSignals); })
.catch(()=>{});
closeModal(); toast(sg?'Saved. Everything is read again':'Listening. What comes through shows up here');
// Open, so the new one is there to see.
if(!expanded.has(SIGNALS)) toggleGroup(SIGNALS);
await loadFeeds(true); selectFeed(':signal:'+(sg?sg.id:r.id));
}catch(e){ toast(why(e),true); }
};
}
/// What differs between a Signal and the dialog's fields, as PATCH takes it.
function signalChanges(sg: Signal, now: {name: string, keywords: string[], description: string|null, icon: string|null}){
const out: Record<string, unknown>={};
if(now.name!==sg.name) out.name=now.name;
if(now.keywords.join('\n')!==sg.keywords.join('\n')) out.keywords=now.keywords;
if(now.description!==sg.description) out.description=now.description;
if(now.icon!==sg.icon) out.icon=now.icon;
return out;
}
/// Font Awesome Free's index, `[style/name, label, terms]`, fetched once, when the picker first
/// opens: 2,883 icons are about 90 KB compressed, which the page has no other use for.
const ICON_CELLS=120;
let icons: Promise<[string,string,string][]>|null=null;
function iconList(){
if(!icons) icons=api('__ICONS__').catch((e: unknown)=>{ icons=null; throw e; });
return icons!;
}
/// The icons with a word starting with each word typed, in their name, label or search terms. The
/// first word typed ranks them: the icon named it, then one with it as a word of its name, then as
/// a search term, then the rest. Matching anywhere in a word, "car" filled the grid with caret and
/// cart (#261). At most `max`; nothing typed is the first `max`.
function iconMatches(all: [string,string,string][], q: string, max: number){
const words=q.toLowerCase().split(/\s+/).filter(Boolean);
if(!words.length) return all.slice(0,max);
const split=(s: string)=>s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
const rank=([id,label,terms]: [string,string,string])=>{
const name=id.slice(id.indexOf('/')+1), own=split(name), hay=[...own,...split(label),...split(terms)];
if(!words.every(w=>hay.some(h=>h.startsWith(w)))) return -1;
const w=words[0];
return name===w ? 0 : own.includes(w) ? 1 : hay.includes(w) ? 2 : 3;
};
return all.map(i=>[rank(i),i] as const).filter(([r])=>r>=0).sort((a,b)=>a[0]-b[0]).slice(0,max).map(([,i])=>i);
}
/// "12 items · 3 unread", as a feed's row counts its own.
const signalCounts=(sg: Signal)=>`${plural(sg.items,'item')} · ${sg.unread} unread`;
/// The icons likeliest for a Signal: the best few for each word of its name, words and sentence,
/// at most `max`. Jev picks from these, since it chooses among options and cannot name one.
const ICON_STOP=new Set(['the','and','for','not','with','about','their','own','from','into','that','this','your']);
function iconCandidates(all: [string,string,string][], name: string, keywords: string[], description: string|null, max=40){
const words=[name,...keywords,description||''].join(' ').toLowerCase().split(/[^a-z0-9]+/).filter(w=>w.length>1&&!ICON_STOP.has(w));
const out=new Map<string,string>();
// A brand's icon stands for that brand, so it is offered only when its name is one of the
// Signal's words: by its search terms, "disney" brought the Mandalorian, a Disney+ show (#264).
const brandFor=(id: string, w: string)=>!id.startsWith('brands/')||id.slice(7).split('-').includes(w);
for(const w of [...new Set(words)])
for(const [id,label,terms] of iconMatches(all.filter(([id])=>brandFor(id,w)),w,6))
if(out.size<max) out.set(id,`${label}: ${terms.split(' ').slice(0,8).join(', ')}`);
return [...out];
}
/// Jev's pick of an icon, on the server's TypeSafe key, or null with nothing to choose from.
async function jevIcon(name: string, keywords: string[], description: string|null): Promise<string|null>{
const candidates=iconCandidates(await iconList(),name,keywords,description);
if(!candidates.length) return null;
const r=await api('/api/signals/icon',{method:'POST',body:JSON.stringify({name,keywords,description,candidates})});
return r.icon;
}
/// Where to get a TypeSafe key and where it goes, wherever a Signal says it has none: one click to
/// each, rather than "in Settings" left to be found.
const KEY_LINKS=`<a href="https://typesafe.ai" target="_blank" rel="noopener">Get one at typesafe.ai</a>, then
<button type="button" class="dlink" data-keylink>add it in Settings</button>.`;
/// Settings, open at the key's field.
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)=>{
if(!(e.target as Element).closest?.('[data-keylink]')) return;
e.preventDefault(); keySetting().catch(err=>toast(why(err),true));
});

View File

@@ -37,13 +37,13 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){
root.dataset.mode = !both ? 'light'
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
// The tab's icon in the same variant as the logo on the page.
const fav = $('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode];
const fav = $<HTMLLinkElement>('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode!]!;
// The top bar's colour for an iPhone before iOS 26 to paint behind the status bar, as Android's
// toolbar does; the page's where the bar is see-through (Glass). iOS 26 reads the bar itself.
const tc = $('meta[name=theme-color]'), bar = $('#topbar');
const tc = $<HTMLMetaElement>('meta[name=theme-color]'), bar = $('#topbar');
if(tc){ const c = bar ? getComputedStyle(bar).backgroundColor : ''; tc.content = /^rgb\(/.test(c) ? c : getComputedStyle(root).backgroundColor; }
const sel = $('#stheme'); if(sel) sel.value = theme.name;
const ms = $('#smode'); if(ms) ms.value = theme.mode;
const sel = $<HTMLSelectElement>('#stheme'); if(sel) sel.value = theme.name;
const ms = $<HTMLSelectElement>('#smode'); if(ms) ms.value = theme.mode;
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
if(save) saveTheme();
}
@@ -62,6 +62,6 @@ systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTh
// becomes its cookie, once.
let name: string | null = null, mode: string | null = null;
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
if(name&&OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
setTheme(name ?? undefined, mode ?? undefined, !!name);
})();

183
web/src/types.ts Normal file
View File

@@ -0,0 +1,183 @@
// What ipx's API sends the page, as types for the type-check (#235), each named after the Rust
// struct it mirrors in crates/ipx/src. Types only: swc strips this file to nothing, so it is not
// in web/build.mjs's list; tsconfig.json's glob reads it. A field added there goes here too.
// An Option is `T | null`, as serde writes None.
/// A feed in your list, `/api/feeds` and the live `feed_row` (web.rs FeedRow).
interface Feed {
id: string;
url: string;
title: string | null;
image: string | null;
folder: string | null;
category: string | null;
feed_category: string | null;
keywords: string[];
blocked: string[];
deadair: boolean;
deadair_pause: number;
allow_explicit: boolean;
auto_download: boolean;
max_new_per_check: number | null;
/// The OPML subscription it came from.
group: string | null;
orphaned: boolean;
managed: boolean;
schedule: string | null;
schedule_mins: number | null;
every_mins: number;
last_checked: number | null;
next_check: number | null;
last_error: string | null;
failing: {reason: string, new_url: string | null} | null;
entries: number;
downloaded: number;
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;
}
/// An item, in a page of `/api/entries` (db.rs EntryRow).
interface Entry {
guid: string;
feed_id: string;
title: string | null;
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;
duration: number | null;
episode: number | null;
season: number | null;
position: number;
enclosures: Enclosure[];
}
/// An item's file (db.rs EncRow).
interface Enclosure {
id: number;
/// Seconds DeadAir's copy saves, when you play it from one.
deadair?: number | null;
deadair_pending?: boolean;
feed_id: string;
guid: string;
url: string;
mime: string | null;
length: number | null;
path: string | null;
state: string;
last_error: string | null;
}
/// `/api/me`.
interface Me {
name: string;
admin: boolean;
sign_out: string;
blocked: string[];
deadair_saved: number;
theme: {name: string, mode: string} | null;
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).
interface Signal {
id: number;
name: string;
keywords: string[];
/// What its owner means by it, in their words (#259).
description: string | null;
/// A Font Awesome Free icon, `solid/robot`, in place of its initials (#261).
icon: string | null;
/// The items it holds, read or not, and of those the unread.
items: number;
unread: number;
}
/// A feed as the Directory lists it (web.rs PopularRow): never its address.
interface DirFeed {
id: string;
title: string | null;
image: string | null;
subscribers: number;
subscribed: boolean;
/// Its id in your list, when it is yours.
feed: string | null;
category: string | null;
subcategory: string | null;
podcast: boolean;
}
/// The page's own state (util.ts `S`).
interface State {
feeds: Feed[];
signals: Signal[];
/// The feed or place open: a feed's id, or a view such as ':all'.
feed: string | null;
entries: Entry[];
total: number;
offset: number;
filter: string;
q: string;
/// The selected item's guid.
sel: string | null;
me: Me | null;
sort: {col: string, dir: 'asc' | 'desc'};
}
/// An account, `/api/users` (web.rs list_users).
interface Account {
id: number;
name: string;
admin: boolean;
/// Has a password; none signs in through the proxy.
password: boolean;
created: number | null;
last_login: number | null;
}
/// A line of the daemon's log, `/api/logs` (logbuf.rs LogLine).
interface LogLine {
seq: number;
ts: number;
level: string;
target: string;
msg: string;
}
/// `/api/directory-admin`: the servers sharing with the directory and the feeds it blocks
/// (directory lib.rs Instance, Blocked).
interface DirAdmin {
instances: {name: string, feeds: number, subscribers: number}[];
blocked: {id: string, url: string | null, title: string | null, at: number}[];
}
/// One of a listed feed's latest items, on its page in the Directory (web.rs ListedItem): never
/// its files.
interface ListedItem {
guid: string;
title: string | null;
link: string | null;
published: number | null;
duration: number | null;
description: string | null;
}

View File

@@ -1,15 +1,33 @@
'use strict';
// `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the
// smoke test, not the type checker, is what makes sure a selector exists.
const $ = (s: string, r: ParentNode = document): any => r.querySelector(s);
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)];
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const $ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T => r.querySelector<T>(s)!;
const $$ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T[] => [...r.querySelectorAll<T>(s)];
/// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know.
const why = (e: unknown): string => e instanceof Error ? e.message : String(e);
/// A row that opens a feed, a place or a show (the feed list, the Directory): a link to a screen
/// reader, named by what it shows, the open one marked current. Not a button, whose contents a
/// screen reader flattens, which would hide a folder's triangle inside it; as a bare div it read as
/// text, nothing to press (#298).
function asLink(el: HTMLElement, current: boolean){
const said=(s: string)=>el.querySelector(s)?.textContent?.trim();
const marks=$$('[role=img][aria-label],.subbed[aria-label]',el).map(m=>m.getAttribute('aria-label'));
el.setAttribute('role','link');
el.setAttribute('aria-label',[said('b'),said('small'),...marks,el.querySelector<HTMLElement>('.badge:not(.zero)')?.title].filter(Boolean).join(', '));
if(current) el.setAttribute('aria-current','page');
}
const ESCAPED: Record<string,string> ={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]);
// A feed's link, only if it is a web address: esc() keeps a javascript: or data: one working (#326).
const webUrl = (u: string|null|undefined): string|null => /^https?:\/\//i.test(u??'') ? u! : null;
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
const ICON={
const fa=(box: string,body: string)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
/// An app-icon tile (web/signals.svg, deadair-on.svg, ...) in the mode's own variant, as the logo is.
const tile=(name: string)=>`<img class="tile onlight" src="/${name}.svg" alt=""><img class="tile ondark" src="/${name}-dark.svg" alt="">`;
const ICON: Record<string,string>={
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
@@ -21,10 +39,6 @@ const ICON={
// for the outline's stroke, so the two draw the same size.
pin:fa('-18 -18 420 548','<path fill="none" stroke="currentColor" stroke-width="36" stroke-linejoin="round" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack, outlined
pinOn:fa('-18 -18 420 548','<path fill="currentColor" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack
// DeadAir (#171): a waveform. Off for this show, outlined with the silence still in its
// middle; on, solid with the gap closed up. Padded like pin for the outline's stroke.
deadair:fa('-16 -16 544 544','<g fill="none" stroke="currentColor" stroke-width="24"><rect x="14" y="200" width="44" height="112" rx="22"/><rect x="84" y="136" width="44" height="240" rx="22"/><rect x="154" y="96" width="44" height="320" rx="22"/><rect x="314" y="96" width="44" height="320" rx="22"/><rect x="384" y="136" width="44" height="240" rx="22"/><rect x="454" y="200" width="44" height="112" rx="22"/></g>'),
deadairOn:fa('-16 -16 544 544','<g fill="currentColor"><rect x="59" y="200" width="44" height="112" rx="22"/><rect x="129" y="136" width="44" height="240" rx="22"/><rect x="199" y="96" width="44" height="320" rx="22"/><rect x="269" y="96" width="44" height="320" rx="22"/><rect x="339" y="136" width="44" height="240" rx="22"/><rect x="409" y="200" width="44" height="112" rx="22"/></g>'),
scan:fa('0 0 512 512','<path fill="currentColor" d="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/>'), // solid/arrows-rotate
download:fa('0 0 448 512','<path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>'), // solid/download
save:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>'), // solid/floppy-disk
@@ -58,15 +72,29 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
// 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]);
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>';
/// What DeadAir took off an episode, as the list says it.
function shorter(secs){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
function shorter(secs: number){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
/// A total of time saved: "3 h 12 min", "12 min", "40 s".
function spanText(secs){
function spanText(secs: number){
const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60);
return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`;
}
@@ -90,8 +118,9 @@ function traceparent(){
/// a frame.
const reported=new Map<string,number>();
function report(ev: string, o: Record<string,any>={}){
// A timing comes of something someone did, not a loop, so every one is sent.
const key=ev+(o.msg||''), now=Date.now();
if(o.msg==='signed out' || now-(reported.get(key)||0)<60_000) return;
if(o.msg==='signed out' || (o.ms==null && now-(reported.get(key)||0)<60_000)) return;
reported.set(key,now);
fetch('/api/client',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json',traceparent:traceparent()},
body:JSON.stringify({ev,...o})}).catch(()=>{});
@@ -100,17 +129,24 @@ window.addEventListener('error',e=>report('page_error',{msg:String(e.message||e.
window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e.reason?.message||e.reason)}));
async function api(url: string, opts?: RequestInit): Promise<any>{
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
// The browser's "Failed to fetch" said nothing anyone could act on (#304).
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts})
.catch(()=>{ throw new Error('Unable to reach iPX. Check your connection and try again.'); });
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
if(!r.ok){
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
// whole 502 page (#219).
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
return r.status===204?null:r.json().catch(()=>null);
// whole 502 page (#219). A 500's words are the daemon's own workings, which its log already
// has; they are not something to show the person who pressed the button (#304).
const said=!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>'');
throw new Error(r.status===500 ? 'iPX could not do that just now. Try again in a minute.'
: said || 'Unable to reach iPX just now. Try again in a minute.');
}
return r.status===204?null:r.json().catch((): null=>null);
}
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
// address it is undefined, so fall back to the old selection-based copy.
async function copyText(text,btn){
const flash=ok=>{
async function copyText(text: string,btn?: HTMLElement){
const flash=(ok: boolean)=>{
if(!btn) return;
// The button is an icon, so it is the markup that has to come back, not just its text.
const was=btn.innerHTML;
@@ -144,14 +180,14 @@ async function copyText(text,btn){
///
/// ponytail: a guess from the address. A private feed whose key has another name is shared
/// without asking; add its pattern here when one turns up.
async function share(title: string, url: string, btn?){
async function share(title: string, url: string, btn?: HTMLElement){
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url)
&& !confirm('This address looks like it includes your own access to the feed, and anyone you '
+'send it to could use it as you.\n\nShare it anyway?')) return;
if(navigator.share){
// Cancelling the sheet rejects too, and is not a failure worth a word.
try{ await navigator.share({title, url}); }
catch(e){ if(e.name!=='AbortError') toast(e.message,true); }
catch(e){ if((e as Error)?.name!=='AbortError') toast(why(e),true); }
return;
}
await copyText(url,btn);
@@ -159,17 +195,26 @@ async function share(title: string, url: string, btn?){
}
function toast(msg: string, bad?: boolean){
const t=document.createElement('div');
const t=document.createElement('div'), box=$('#toasts');
t.className='toast'+(bad?' bad':''); t.textContent=msg;
$('#toasts').appendChild(t);
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
const go=()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)};
// An error stays until it is put away: gone after six seconds, it was easy to miss and could not
// be read again (#303).
if(bad){
const x=document.createElement('button');
x.type='button'; x.className='toastx'; x.title='Dismiss'; x.setAttribute('aria-label','Dismiss'); x.innerHTML=ICON.close;
x.onclick=go; t.appendChild(x);
}else setTimeout(go,3200);
box.appendChild(t);
// A run of failed downloads would otherwise stack errors up the screen.
while(box.children.length>4) box.firstElementChild!.remove();
}
const clock = s => {
const clock = (s: number) => {
s=Math.max(0,Math.floor(s||0));
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;
return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;
};
const ago = t => {
const ago = (t: number|null) => {
if(!t) return 'never';
const d=(Date.now()/1000)-t;
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
@@ -177,16 +222,16 @@ const ago = t => {
if(d<2592000) return Math.round(d/86400)+'d ago';
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
};
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
const dateOf = (t: number|null) => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
// A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB".
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
const mb = (n: number|null) => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
const plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`;
const initials = (s: string|null) => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
const plural=(n: number,word: string)=>`${n} ${word}${n===1?'':'s'}`;
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))'];
const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
function tileHTML(name,cls){
const tint=(name: string)=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
function tileHTML(name: string,cls?: string){
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
}
/// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and
@@ -195,26 +240,26 @@ function tileHTML(name,cls){
function artSrc(url: string){
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
}
function artHTML(url: string | null, name: string, cls?: string){
function artHTML(url: string | null | undefined, name: string, cls?: string){
return url
? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
: tileHTML(name,cls);
}
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
function folderArt(f,kids){
function folderArt(f: Feed,kids: Feed[]){
const art=kids.filter(c=>c.image).slice(0,4);
if(art.length<4) return artHTML(f.image,f.title||f.id);
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
`<img src="${esc(artSrc(c.image))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
`<img src="${esc(artSrc(c.image!))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
}
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
function nav(on: boolean){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
/* ---------------- state ---------------- */
const S = {
feeds:[],
const S: State = {
feeds:[], signals:[],
// Which feed (or place) and which tab were open last time, so a refresh lands back where
// you were instead of jumping to the first feed alphabetically.
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
@@ -222,25 +267,25 @@ const S = {
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
q:'', sel:null, me:null,
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')??'null'); }catch{ return null; } })()
||{col:'published',dir:'desc'},
};
const LIMIT = 50;
const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
const UNIT_MINS = {m:1, h:60, d:1440, w:10080};
const UNIT_MINS: Record<string,number> = {m:1, h:60, d:1440, w:10080};
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
function splitEvery(m){
function splitEvery(m: number){
if(!m) return {n:1, u:'h'};
for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u};
return {n:m, u:'m'};
}
function unitOptions(sel){
function unitOptions(sel: string){
return UNITS.map(([v,l]) =>
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
}
function everyText(m){
function everyText(m: number){
if(!m) return '\u2014';
const {n,u} = splitEvery(m);
const name = {m:'min', h:'hour', d:'day', w:'week'}[u];