190 Commits

Author SHA1 Message Date
7c3d36b29a Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:37:55 +00:00
78eb255a78 Artwork panel: call the hit rate the hit rate (#198)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:31:48 +00:00
afd3cf448d One Artwork panel: full, hit rate and evictions together (#198)
Evictions are bars on a right-hand axis of their own, since they are images,
not a share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:29:19 +00:00
c28accb8e4 Twenty database connections on Postgres, and a panel that does not cry wolf (#199, #200)
The Database connections panel drew idle connections as their own line, which
sits on the max for ten minutes after any burst and read as the pool maxing out,
while at most two were ever in use. It now draws in use against the max, with
open dashed behind them. The pool itself goes from sqlx's ten to twenty: the
server allows 100 and had about 13 open across every app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:27:21 +00:00
8dc6dd4ce6 Artwork panels: how full, the hit rate and evictions (#198)
Kept bytes against the limit was a flat line once the cache filled. Now a
percentage of the limit beside the share served from disk, and the images each
sweep evicts. The counter shipped minutes ago as ipx_art_trimmed_total is
renamed ipx_art_evicted_total before anything came to read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:17:56 +00:00
f032d50ed3 Graph the artwork thrown out, not the artwork kept (#198)
The sweep trims the cache to its limit, so the kept bytes it reports are the
limit every time once the cache has filled: the panel was two lines flat at
500 MiB. A counter of images thrown out says whether the limit is too small.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:15:11 +00:00
12fadbd0f8 Drop the spent DeadAir clean-up and keep not-due skips off live updates (#196, #197)
The clean-up of DeadAir's old copies probed for its table on every open, and on
Postgres each failed probe is an ERROR in the server's log: 120 an hour from the
healthcheck alone. Production ran it already and is the only database.

A scan sent a not-due skip for each of ~1,535 feeds into a broadcast of 1,024,
so a page slow to read lagged and dropped events: 443 counted in five hours.
Nothing listening uses them; the terminal and the log still get them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:12:22 +00:00
0d930e75ee Prod check reads the page's reports, today's trace steps and DeadAir's events
A seventh check groups the browser's errors and lists play starts and
stalls (ipx::client, #190); the trace check names the steps of an item
list, the feed list, a reap and DeadAir's per-file trace (#187), and says
a trace with no name began in the page (#192), which query.py now labels;
the events list has deadair_done and DeadAir's own lines; and "since the
last deploy" is worked out from the container's start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:17:34 +00:00
29f1cbce29 Metrics for the worker's job, the queues and the work in flight (#195)
The healthcheck answers while the worker is stuck on one job, and nothing
showed which job or for how long. STATS, one static of atomics, is kept as
the work happens: the worker's job and since when (a guard, so a failed or
stopped job is counted too), jobs and their seconds by kind, when a scan
last finished, web requests, feed fetches and downloads in progress, bytes
downloaded, live updates dropped for a listener that fell behind, DeadAir's
backlog and how long it has been on its file, and the artwork kept. With
the event queue, the DNS retries and the healthcheck's last counts, /metrics
still reads memory only. The dashboard's Daemon row and the prod-check
skill take them up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:14:54 +00:00
a985cfafef Prod check reads the daemon's metrics from Prometheus (#194)
query.py prom asks Prometheus as it asks Loki, and a sixth check looks for
a pool at its max, a worker or runtime queue that only grows, memory, open
files or tasks that only climb, and restarts by the uptime's resets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:06:58 +00:00
973eeb67c7 Metrics on a thread and port of their own, from memory alone (#194)
/metrics shared the web server's threads, and answered after a token
lookup and three counting queries, so a pool run dry, one of the things it
is there to show, would have stalled it, and a slow scrape held a web
thread. Now [web] metrics_bind starts a listener on its own OS thread with
a current-thread runtime, reading only what is in memory: pool counts,
channel capacity, the daemon runtime's metrics through its handle, /proc.
No sign-in, as exporters have none; publish the port to the LAN only. The
feed, pending and downloaded counts stay with the healthcheck's log lines,
which the dashboard already reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:04:51 +00:00
9fc1e7e248 Prometheus metrics for the daemon's state between requests (#194)
Logs and traces show requests, scans and downloads; nothing showed what
the daemon holds meanwhile. /metrics, behind sign-in, gives database
connections in use and idle against the pool's maximum, commands waiting
for the worker, live-update listeners, free torrent slots, episodes
playing through DeadAir, the library's counts, the tokio runtime's
workers, tasks and queue, and the process's memory and open files, in
Prometheus's text format, written by hand rather than with a client
library. Not logged or traced: Prometheus asks every 15 s. The iPX
dashboard gains a Daemon row of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:56:54 +00:00
b51df33bb6 Clear dead feeds from the Directory once a day, not before every scan (#193)
stale_unsubscribed takes the newest item of every unsubscribed feed, 2-5 s
warm and up to 12 s in production, and reap ran it before each scan, every
few minutes, holding up the sequential worker's scans and downloads. Its
thresholds are a month dead and a year quiet, so a day is plenty. Kept in
memory: a restart looks again at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:51:14 +00:00
cd9aef36a1 A play's trace holds the playlist, audio and saved place it fetched (#192)
hls.js sends traceparent on its own requests (xhrSetup); a beacon, which
cannot set a header, and Safari's player, fetching the playlist itself,
carry it in the query, which the server reads where there is no header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:41:28 +00:00
35fca0a6a2 Page an item list from an index, then read only its rows (#188)
All Subscriptions took 130-170 ms when used a minute before, and 0.5-1.9 s
the first time after a quiet spell. Its sort needs each item's date, which
was only in the item's row, so every one of a person's 13,700 rows was
read, show notes inline and all, and cold that came off disk; count_in,
which needs no date, took 20 ms throughout. entries_by_time holds the keys
and dates, the page's keys are sorted and cut from it, and only the rows
shown are read. On a copy of the shape in the test database: 619 pages
read against 4,174, with no visit to the table. Neither change alone
helped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:41:27 +00:00
3d005da847 Changelog: each past release says what changed since the one before (#191)
Following the rule CLAUDE.md now gives for cutting a release: a feature
added and reworked in one release is one line saying what it became, and a
fix for a bug no release had is gone. 0.11.0's DeadAir fixes join DeadAir,
0.9.1's JSON log, website icons, adding a website and logo are one line
each, 0.10.0's Directory, sort and Popular one, 0.4.0's toolbar, item table
and icons fold together, and 0.2.0 and 0.3.0 lose the fixes to the web UI
and accounts they introduced. The [unreleased] link compares from v0.11.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:18:41 +00:00
6c931d0485 The page reports its errors and playback timings, and its requests carry a trace (#190)
Nothing the page did in the browser reached the logs: a DeadAir playback
failure on the phone was a toast and nothing more. POST /api/client takes
page errors, unhandled rejections, hls.js and media errors, the time from
play to sound and the length of a stall, and logs them as ev lines under
ipx::client, inside the request's span. Only those kinds are taken, the
text cut to 500 characters, and the page sends each at most once a minute.

The page's API requests send traceparent, a new trace id for each tap or
key, and the server's request span takes it as its parent, so one action's
requests are one trace in Tempo, the report included. The stub DOM the
smoke and native-bridge tests run in gains crypto and performance, which
every browser the page runs in has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:15:20 +00:00
9648a79f3f Keep media out of shared caches (#189)
Played from ipodderx.sdf1.net, DeadAir's audio reached ipx as one request
with no Range header: Cloudflare caches by extension, deadair.mp3 has one,
and /media said nothing about caching, so Cloudflare fetched the whole file
and served ranges from its own copy, to anyone Access let in, without the
subscriber check (#129). /media/{id} has no extension and was never cached.
Cache-Control: private on every media response, the save included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:23:27 +00:00
e5b9b0cfdb Steps in the traces of the disk sweep, episode lists and DeadAir (#187)
A 47 s reap had a trace of one span, so whether the time went on checking
every file on disk, the database or deleting could not be told. The reap's
steps are spans now, with what it removed on the reap span; an episode
list's query, sanitising, DeadAir and count are steps; and DeadAir's work
on a file is a trace of its own (probe, silencedetect, frame_bytes,
store_plans), only for files it looks at, not the idle passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:04:59 +00:00
ba9759f44f Gesture tests send a drag's touches at once, not one by one (#184)
Each touch event was awaited before the next went, so on a busy machine a
drag's ten messages took longer than the 800 ms a swipe has, and the page
took it for a slow drag. Reproduced by spacing the messages 110 ms apart:
the swipe back stayed on Second Episode, as the failing run did. The page
is right to want a swipe quick; a finger does it in well under that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:58:26 +00:00
c214b3e79a CLAUDE.md: a release's changelog says what changed since the last release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:41:41 +00:00
e56bc9e8dd Save an episode without its dead air when DeadAir is on (#186)
The save button fetched the original while the player played it shortened.
/media/{id}/deadair sends the original's ID3 tag and then the pieces of the
person's plan one after another, read as it goes: MP3 and ADTS frames each
stand alone, so that is a file any player takes, and nothing is stored, as
with the playlist. The tag stops at the first kept frame, since what can
follow it, the Xing frame, gives the original's length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:38:30 +00:00
b2fe1c725d DeadAir in the box with a show's download settings (#185)
Below the box it read as a setting about something else. It sits after
the download options' fold, not in it, since DeadAir applies whether or
not the show downloads on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:31:28 +00:00
7bec82d053 DeadAir's pause is its breathing room, greyed out while DeadAir is off (#183)
"Pause it leaves" said how DeadAir works, with an "it" to decode, not
what you hear. Greyed out rather than hidden, so the choice can be seen
before DeadAir is turned on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:22:21 +00:00
4ccf2e5511 DeadAir's pause on a slider, shown only while DeadAir is on (#183)
The four segmented buttons gave no sense that 0.2 to 0.5 s is one scale,
and stayed on screen with DeadAir off, where they did nothing. A range
input with a dot at each stop and Brisk/Relaxed at its ends says both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:14:50 +00:00
089202f72b DeadAir plays an HLS playlist of the original, with a pause of your own (#181, #182)
DeadAir made a shortened copy of each episode with ffmpeg: as much disk
again as the original, one pause length for everyone, and a minutes-long
wait after the download. Seeking past silences instead was tried on an
iPhone and left about 1.3 s of nothing at every cut (#178).

Now it finds a file's silences once (silencedetect, plus ffprobe for
each frame's byte position) and stores a plan for each pause a person
can choose, 0.2 to 0.5 s: the cuts, and the frames kept as byte ranges
of the original. /media/{id}/deadair.m3u8 hands this person's plan out
as an HLS playlist of ranges of /media/{id}/deadair.mp3, the original.
Tested on iOS Safari first (#180): no gap at the cuts, plays locked,
seeks exactly, and needs no ID3 timestamp in front of each piece.

Other browsers get hls.js (1.6.19, the light build), loaded only when
needed. Not where canPlayType says it can play HLS: Chromium answers
"maybe" and then fails on MP3 ranges with "stream parsing failed", so
only Apple's browsers play the playlist themselves.

The pause is per person and per show, on deadair_prefs.pause_ms, 0.3 s
unless chosen; 0.2 s, what the copies left, made speech sound rushed.
AAC in MP4 has no ranges a playlist can use, so only MP3 and ADTS AAC
are shortened (the library is 803 MP3s and no .m4a). The copies, their
table and #179's expiry go: the first start drops the table and deletes
the files it names, only those named name.deadair.*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:46:01 +00:00
f29d493a0e Remove DeadAir copies unplayed for a week, and remake them on play (#179)
A copy is nearly the size of its original and was kept as long as the
original. Now `deadair.played_at` notes when one was last served (at
most once an hour, since a player asks again at every seek), and each
pass of the maker removes copies unplayed, or unmade, for 7 days. Only
the file goes: the row keeps its cuts with its path cleared, which
tells an expired copy apart from a file that cannot have one (no cuts)
and keeps the background queue from making it again straight away.
Serving the original of such an episode deletes the row, which puts it
back on the queue; the copy then comes back within a pass, though the
page does not switch to it mid-play (#178).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:51:17 +00:00
9ea2a9250c Build the container on Debian trixie for ffmpeg 7.1 (#177)
Bookworm's ffmpeg is 5.1.9, two major versions behind, and DeadAir runs
everything through it. Both stages move, since the binary built in the
first has to find the same glibc in the second.

Checked in the new image on techpod-358 with a 643-term drop expression,
the size that failed with a bsf_list error on 5.1 today: the copy is
352.73s shorter, as 643 cuts of 21 frames at 26.122ms predict, and
decodes without errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:17:42 +00:00
95202ec411 Start 0.11.1-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:21:49 +00:00
17fbf79db8 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:10:20 +00:00
d6c0e3e7a6 Show that DeadAir is still working on an episode (#176)
A new download in a show with DeadAir on plays at full length until its
copy is made, and nothing said so. Each file now carries
deadair_pending while DeadAir is on for its show and the copy is still
to come (not for one heard through, which it passes over), and the
episode's line and details say "Cutting dead air…". The maker sends a
deadair_done event when it finishes a file, and the page takes the list
again, so the line becomes how much shorter the episode is without a
reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:05:18 +00:00
527a90ed2d Keep a file's buttons on one row in the Files pane (#175)
The pane is 245 px wide above 820 px, and a downloaded file's size and
four buttons did not fit, so delete wrapped under the rest. The row no
longer wraps, its gaps and the buttons' padding are tighter, and the
size gives way with an ellipsis if anything still has to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:05:18 +00:00
a67b949960 DeadAir copes with hundreds of silences, and retries a failure (#174)
Production's ffmpeg, Debian's 5.1, refused the copy of Brad & Will's
359: "Error initializing bitstream filter: bsf_list". The frames to drop
went to it as between(...)+between(...)+..., which nests a level a term,
and 5.1 takes 100 terms and refuses 200; the 6.1 the tests ran on took
all 520. Summed as a balanced tree the same 520 nest about 10 deep, and
5.1 takes 4000 that way, checked in the production container.

The failure was also stored as the file having no copy, so nothing would
have tried it again after this fix. Failures are now remembered until
the next start instead. The cap on cuts drops to 3500, so the argument
stays under Linux's 128 KiB with frame numbers in the millions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:52:28 +00:00
845448a754 DeadAir shortens episodes opened but not played (#173)
The copy maker skips an episode everyone who would hear the copy has
finished, and took finished to be the read mark. Opening an item sets it
as well, so 359: Potatoes From the Sky, opened and downloaded with
DeadAir on for its show, got no copy. Finished is now a place at 90% of
the length or past it, as Currently Listening has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:48:25 +00:00
00bc039964 DeadAir's wording: cut the dead air (#172)
The switch, the button's tooltip, the toasts and the time saved now say
what the name already pictures, the dead air cut, instead of explaining
it as silences shortened each time. The caption says the voice keeps its
own pace, which is the part people doubt, and names the shows to leave
out: stories whose pauses are part of the telling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:32:16 +00:00
111a0e29a4 DeadAir's icon: the silence open when off, closed up when on (#172)
Outlined bars against solid ones read the wrong way round at 20 px: the
outlines ran together and the off icon looked the heavier. The off icon
now has the gap in its middle, the on icon has it closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:27:42 +00:00
827cc7489f DeadAir in the page: a switch per show, one beside the speed, and the time saved (#172)
A DeadAir switch in each show's settings, and the same switch beside
the speed for the show that is playing, drawn as a waveform with its gap
closed up, outlined when off and solid when on, with aria-pressed. A
file the server gives a copy for plays from /media/{id}/deadair. An
episode's line says how much shorter it is, and Settings says how much
DeadAir has saved you.

Turning it on or off mid-episode saves the place with a request it waits
for, not a beacon, then asks for the entry again from the new
GET /api/entries/{feed}/{guid}, whose position is in the clock of the
version it now plays, and carries on there. A copy not made yet plays
from the next time. Turning a show on wakes the copy maker, so the copy
is there in seconds rather than at its next pass.

The browser test plays a real MP3 with long silences, quiet.mp3, from a
show it adds and removes itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:18:41 +00:00
16b0686aab DeadAir is off for every feed until turned on for it (#171)
There was one switch for everything, with a feed kept out by hand; any
feed already subscribed would have been in it the moment the switch was
turned on. Now each feed, or the group it came in, is turned on by
itself, and a feed of a group turned on can still be kept out. The
general switch and /api/me's deadair are gone; /api/me keeps
deadair_saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:49:24 +00:00
63e192767d DeadAir on the server: copies with long silences shortened (#171)
For anyone who turns DeadAir on, a background task makes each episode a
copy with every silence quieter than -38 dB for over 0.35 s shortened to
0.2 s. Nothing is re-encoded: ffmpeg copies the original's frames and
leaves the silent ones out, about 11 s for an hour of MP3 in a release
build. On Brad & Will's episode 359 that is 520 cuts and 3.3 minutes
saved, the copy decodes clean, and it has no isolated spikes in its
silences that the original lacks.

Cuts are made from the middle of each silence, keeping 0.1 s at each
end, so the frames either side of a cut are silent: decoding blends a
frame with the one before it, and an MP3 frame can begin in the bytes of
the frame before, so the first frame after a cut decodes against the
wrong neighbour. Inside a silence nobody hears that; at a word they would.

Positions stay in the original's time and the server converts them both
ways. The iOS and Android apps save positions themselves from their own
player, even while the page is asleep, so converting in the page would
have stored the copy's times as the original's. /media/{id}/deadair
serves the copy at its own address and records that this person is
playing it; a position saved meanwhile is converted from the copy's
clock, as is the length a player measures.

The copy is made by a task of its own, not the worker: a first pass over
a library is hours of ffmpeg, and scans must not wait behind it. It
checks every two minutes, which covers new downloads, files already
downloaded, a switch turned on and a file downloaded again with one rule.
Episodes everyone who would hear the copy has finished get none.
download::remove deletes a copy with its original, and the quota counts
copies.

Settings: the switch is PATCH /api/me {deadair}, a feed kept out is
PATCH /api/feeds/{id} {deadair: false}. GET /api/me gives the switch and
deadair_saved, the seconds saved on episodes heard 75% or more on a copy,
each counted once. Three new tables, so no ALTER.

The image gains Debian's ffmpeg, about 480 MB, in a layer that stays
cached until the base image changes. The player's switch, the feed
setting and the stat come next, after mockups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:36:49 +00:00
8687e4ff1b Turn Nagle's algorithm off on the web server's connections (#170)
axum 0.7 set TCP_NODELAY on every accepted connection; 0.8 stopped, and serve
never set it, so each response sent in parts held its last small segment
until the browser's delayed ACK came back. On production's light traffic
nothing showed: small responses go out in one write. Under load it did.
tests/load, Nagle on then off: media range requests median 41.8 ms to
10.3 ms, p95 44.4 to 13.2, throughput 39 to 149 MB/s. Browse and sign-in
unchanged within noise; flag saves vary 80-195 ms either way, behind the
scan's writes, not the network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:40:46 +00:00
1e7a858e9c Show artwork up to 20 MB, and remember and log what failed (#169)
Five Glass Cannon Network feeds name Patreon covers of about 7 MB, over the
5 MB limit on /api/art. Each page load fetched them all again, refused them,
and logged a 502 per cover, about 84 a day, with nothing in the log to say
which image or why: the reason went only to the browser. The limit is now
20 MB, a failure is answered from memory for an hour, and the fetch failure
is logged with its address and reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:10:18 +00:00
c37bcf816b Start 0.10.4-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:00:01 +00:00
4ac1e6dbbc Release 0.10.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:59:46 +00:00
22a99110e8 Show the accent, not words, past the last item when swiping
Asked for: swiping on from the last item in the reader showed "No more items" in faint text beside
it; the pane is now the theme's accent colour and says nothing, the colour saying it is the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:52:41 +00:00
cb92d5e355 Say nothing in an empty list or an empty reader
Asked for: "Nothing here yet — try Scan now." and "Pick an item to read it." are gone. A search
that finds nothing still says so, as the words typed are the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:50:22 +00:00
e3f0267814 Box the API tokens in Settings
Asked for: the API tokens section of Settings sits in the same rounded box as a feed's download
settings, set apart from the rest of the dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:43:35 +00:00
f4a8ba4ea8 Box the download settings instead of hanging them from a rule (#167)
Asked for after the redesign: the switch and what depends on it sit in one box with rounded
corners, in place of the rule down their left side. Drawn in --line rather than --edge, so it shows
on a phone too, where --edge draws no outlines; the indent the rule needed goes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:38:26 +00:00
4c1d230044 Put a feed's address at the top of its settings, and drop the shared box (#167)
Asked for after the redesign went out: the box of settings the same for everyone (address, folder,
Directory category) held what a person reading the feed cannot and should not change. The address
moves to the top, under the feed's name, with its copy button, read-only except to an admin, who
can still follow a feed that moved from its failing banner. The folder and the category leave the
dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:32:29 +00:00
ecbc253894 Redesign the feed settings dialog (#167)
One column of label, box and grey paragraph, eight times at the same weight, three screens long on a
phone with Save at the bottom of the third. Approved from the mockups on #167.

- Download automatically comes first, as a switch; how many, the keywords and explicit, which only
  ever applied to automatic downloads, hang beneath it and fold away when it is off.
- How many is Default, Newest or All, said as a sentence with the number in it, over a strip of the
  feed's own newest items, each as tall as it is long and lit where it downloads; dragging along
  it sets Newest. All is 0, which now means all (#166).
- Keywords and hidden words are chips: Enter or a comma adds one, its x or Backspace removes it.
- The feed's own settings, the same for everyone and an admin's to change, sit apart in a box.
- The body scrolls and Cancel and Save stay at the foot.
- Theme colours only; iOS switches, but checkboxes in Classic. openModal takes a class for a
  dialog with a frame of its own, set whole each time so it never outlives it.

A browser test covers the strip, chips, the fold and a save that holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:25:57 +00:00
b4c115d79a Show the iPodderX icon in the top bar in the Classic theme
Classic is drawn after the 2004 iPodderX app, so its top bar shows that app's icon (/icon.png) in
place of the iPX logo, on the app and admin pages. The favicon stays the iPX one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:14:54 +00:00
4e417389fe Catch the feed list up when the page comes back into view (#168)
All Subscriptions said 1 unread when the server had none. The badge is the page's own sum of its
feed list, loaded once and patched only by events the server sends when a scan or a download
changes a feed. Reading an item, marking everything read or unsubscribing sends none, so another
device's page kept its old counts, and an iPhone stops the event stream while the app is in the
background, missing everything meanwhile; the reconnect did not catch up.

The page now takes the feed list and the open list again when it comes back into view and when the
stream reconnects after a drop. A browser test reads an item behind the page's back and checks the
badge follows once the page is shown again; it fails without this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:13:16 +00:00
19f77a21d9 Take 0 as every item wherever the download limit is set (#166)
The feed settings dialog says 0 means every episode, the whole back catalogue, and as the server's
default it does. But merge_policy's cap() made only the server-wide 0 unlimited: a 0 set on a feed,
or on one person's subscription, became a budget of 0, hold_back held every file, and nothing
downloaded. Now 0 is unlimited at every level, as the dialog has always said.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:29:19 +00:00
65ca481525 Index the file rows with a file on disk (#165)
Counting a person's files through enclosures_entry, once the count was of their own feeds only, read
every file row of their 146 feeds to find the few with a file: 58 ms of a feed list, more than the
37-50 ms the count over every row of the table had taken. Production holds 15 files among many
thousand rows. A partial index on the rows with a path holds just those, and serves the healthcheck's
count and the Downloaded filter too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:15:17 +00:00
92cc0cf4ec Count only the person's own feeds in the feed list (#165)
The traces #164 added showed feed_list as nearly all of GET /api/feeds, and in it the item count at
184-316 ms, about 70% of the request: it counted every item of every feed in the database, 1534
feeds, for someone reading 146, on every feed list and every live update of one. The file count did
the same over every file row, 37-50 ms.

Both now join the person's subscriptions, as the unread count already did, and so read only their
feeds' rows, through the entries key and the enclosures_entry index, both on feed_id first. The
Directory also reads feed_list, for every feed's title and artwork, and uses no count; a feed the
person does not read is still listed, with none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:12:05 +00:00
6a69818ef0 Trace each step of the feed list (#164)
GET /api/feeds took 250-300 ms in production with nothing under it in its trace but feed_summary
spans of a millisecond, so there was no telling where the time went. feed_rows now wraps the
catalogue, the person's subscriptions, the subscriber counts, the pins and feed_list in a step
span each, and feed_list each of its five queries.

db::step makes a span only inside one already: the healthcheck's status every 30 s and the page's
live updates run the same code outside any trace, where each span would be a trace of its own, as
feed_summary's already are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:04:45 +00:00
2de5f81202 Ask for the subscription once when saving a feed's settings (#163)
patch_feed asked whether you subscribe, then asked again for the subscription, with a fallback for
none that could never run, since it only got there when there was one. One if-let does both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:55:33 +00:00
4db5ee5c44 Answer a file id that does not exist with 404, not 500 (#162)
POST /api/enclosures/{id}/download and DELETE /api/enclosures/{id} raised a missing row with
anyhow!, which ApiError makes a 500 and logs as an api error. A page left open after a feed was
forgotten asks for exactly that: the caller's mistake, a 404 like /media/{id} already answers, and
as a 500 it turned up as a server error in the production check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:54:17 +00:00
6ddd6444f9 Add a feed through one function, from the CLI and the page alike (#161)
`ipx add` and the page's Add feed were the same steps written twice: find the feed, look for it in
the catalogue by same_feed, add_one, store. They had drifted, and #153's lock had to be put into
both by hand. add_to_catalogue() now does the shared part, under the lock, and answers the id and
whether the feed was there already; each caller still finds the feed itself, so the page can call a
bad address the caller's mistake (400), and then does its own part: the CLI lists or categorises,
the page subscribes and scans. add_one is private to it now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:53:40 +00:00
e710103ba6 Check one feed is in the Directory without building the Directory (#160)
A feed's Directory page and subscribing from it checked the feed may be listed by building the whole
Directory, every feed's listing, every file row for Podcasts and Blogs, every subscription, and
looking for the id in it: 0.57 s for a feed's page in production, nearly all of it that.

The rule (not an OPML or creator itself, neither the feed nor its group private) is now listable(),
which the Directory filters by and the two ask of the catalogue directly. One rule, so the
Directory and the check cannot drift apart; a test covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:51:36 +00:00
dc340dd4e9 Look a group's title up once, not once per feed in it (#159)
subscriptions() names each feed derived from an OPML or a Patreon creator after its group when the
group has no folder, and asked feed_summary, three queries, for the group's title once for every
feed in it: the same title each time. Production's 32 Glass Cannon Network shows made that about 96
queries each time it ran, which is every feed list, every live update the page receives, every scan
and every wait between scans. It now asks once a group per call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:49:54 +00:00
ebecc12193 Play and delete a torrent that came as a folder of files (#158)
A torrent of several files is recorded as its folder. /media could not serve a folder, and the
reaper's remove_file failed on it, warned "could not delete" on every pass and freed nothing, so it
counted against the quota for good; Delete in the page failed the same way.

download::remove deletes a file or a whole folder, for the reaper and Delete alike, and the reaper
counts a folder by its recorded size, as a directory's own size is not its contents'.
download::playable answers the path itself, or for a folder the largest file in it, which is the
episode, and /media serves that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:49:03 +00:00
33e031dd13 Leave a removed account's feeds as unsubscribing does (#157)
Removing an account deleted the user, and the foreign keys took their subscriptions with them, but
nothing did what unsubscribing does next: a feed nobody subscribes to any more leaves the catalogue
(or stays, if listed, for the Directory). A feed only that person read stayed with no subscribers,
and a feed with none downloads by its own settings, so it went on fetching each new episode, daily,
for no one, until it had been quiet for a year.

remove_feed's body is now leave(), and remove_user() leaves each of the person's feeds with it before
deleting the account, from the page and from `ipx user rm` alike; a feed inside a group of theirs
goes with the group. `ipx user rm` now tells a running daemon to reload, as add and rm do (#120),
since it can change the catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:47:26 +00:00
60cbb7aff6 Count only subscribers' read marks and pins when freeing disk space (#156)
The reaper counted read marks and pins in entry_state from anyone and compared them with the feed's
subscribers. Unsubscribing leaves your rows behind, and the flags endpoint writes them for feeds you
do not subscribe to as well. So an episode read by someone who had left and by one subscriber of
two counted as heard by everyone, and went first under the quota though the other had not played
it; and a pin of a person who had left kept a file, and its item, for good, which can leave the
quota impossible to meet.

Both queries, the reaper's candidates and prune_entries, now count only the marks of people who
subscribe to the item's feed. The old prune test's pin had no subscriber behind it; it has one now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:45:14 +00:00
ec55682c13 Record an episode that turns out to be a torrent, and run it off the worker (#155)
An enclosure advertised as audio whose body is a .torrent was handed to torrent_one from inside
fetch_one, which returned without mark_downloaded, and in a scan nothing else recorded it. It
stayed pending: the page never showed it downloaded, and every scan of its feed fetched the
.torrent again and ran the torrent again, on the worker, for up to the stall limit and then the
seeding time (30 and 60 minutes in production), with no other feed checked meanwhile.

It now goes the way an advertised torrent does: in the daemon, marked downloading and handed to
spawn_torrent, which records it when it finishes; in a one-off CLI run, run there and recorded.
fetch_one answers None for the first, which a scan counts as a torrent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:43:04 +00:00
15291298aa Queue up to 1024 commands, so Download latest 100 does not hang (#154)
The worker's queue held 64 commands, and a web request that queues work waits inside itself for a
free place. Download latest lets you ask for up to 100 and sends one command a file, so past 64
the request waited for the worker to finish whole downloads, one after another, an hour for 100;
Cloudflare gave up on it after 100 s and the page said it failed, though most were queued. Until
it finished, adding a feed, Refresh, Download and socket commands all waited for a place too. A
command is a few bytes; the queue now has room for any one request's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:41:15 +00:00
c426ab6314 Keep two changes to the catalogue made at once from undoing each other (#153)
Each change to the catalogue or the server settings stored the whole thing from a copy it took
earlier, and nothing kept two from overlapping. Adding a feed copied it first and then spent up to
a minute finding and reading the feed, so another person's new feed, an unsubscribe, an admin's
settings, or the scan's own changes (a moved feed's address, a category) made in that minute were
stored over when it finished, with no error. A feed dropped that way left its subscriber's list.

Every change now takes its copy through Ctx::edit_cfg, which holds a lock until store_cfg keeps it,
and store_cfg takes nothing else, so no writer can skip it. Adding a feed takes its copy once it
has found the feed, and before the duplicate check, so two people adding one feed add it once.
reload takes the lock too. `ipx add`, `rm` and `import`, in a process of their own, are outside it,
as the comment on the lock says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:40:17 +00:00
d5edead011 Give up a download after two minutes without data (#152)
The download client had a connect timeout and nothing else, so that a long download is never cut
off (#108). But nothing bounded a read either: a server that stops sending partway without closing
the connection left the download waiting for good, on the one worker, with every scan and download
behind it, while the healthcheck, which the socket answers itself, went on reading healthy. A read
timeout fires only when no bytes arrive, so a long download is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:37:13 +00:00
c7eef3e516 Make Glass's top bar solid on a phone, so the strip above it matches (#147)
In a home-screen app iOS paints the strip behind the status bar and the Dynamic Island itself, in
one flat colour, from theme-color. Glass's bar is see-through, so theme-color fell back to Glass's
page background, #0b0d14 (#eef1f7 light), above a bar that showed #191e34 on average, bluer at the
left and purple at the right: a near-black band, or a white one in light mode.

No flat strip can match a see-through bar, so on a phone the bar is solid, in what it showed on
average (--glass-bar, measured in Chromium off the bar's top edge), and theme-color carries the
same colour. Everywhere else Glass is as it was. The browser test checks the bar is solid and
theme-color equals it in both modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:10:14 +00:00
a12aa59970 Shadow the feed list on a phone only while it is open (#151)
On a phone the feed list is a drawer slid off the screen to the left, and it kept its shadow
there, which fell back across the page: the left 30px or so of the top bar and everything under
it were darker, in every theme (Glass light 231,233,243 at the edge against 246,247,255). Beside
the flat strip iOS paints above the page it made a seam at the top-left corner (#147).

Hiding the closed drawer instead (visibility) would also have done it, but every phone test
starts by waiting for the feed list to be visible, which off the screen it still counts as.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:10:07 +00:00
a553931c76 Run the top bar's colour up behind the Dynamic Island on iOS 26 (#147)
46f9370 set theme-color, but Safari 26 ignores it. iOS 26 paints the strip behind the status bar
and the island in the background colour of a sticky or fixed element at the page's top edge, and
failing that the root's. The top bar was neither, and the root had no colour of its own (only
body did), so iOS used black or white: a band of another colour above the bar.

The bar is sticky now, though nothing scrolls under it, so iOS reads its colour; the root has the
page's background for screens with nothing at the top edge; Classic's metal bar gets a colour
under its gradient, the top of the metal, since iOS reads no gradient. theme-color stays, set to
the bar's colour, for iPhones before iOS 26 and for Android. Glass's bar is see-through, so iOS
frosts what is under it there, as the bar itself does.

Taken from developers' reports of iOS 26, not Apple's documentation, and not yet seen on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:50:28 +00:00
46f9370018 Paint the bar around the Dynamic Island in the theme's background (#147)
The page named no theme-color, so the status bar iOS draws around the Dynamic Island was its own
black or white, a band across the top of every theme. The page and the admin page now carry one,
set to the page's background whenever the theme or mode changes; Android's toolbar takes it too.
Not seen on a phone yet: the browser test checks the colour matches, not what iOS does with it.

A browser test at 360px covers this and #146's row of buttons. The stub DOM gains
getComputedStyle, which every browser has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:51:19 +00:00
70c49921ab Keep a feed's and an item's buttons on one row (#146)
A feed's page has seven 44px buttons, which fit a 390px phone with nothing to spare: on a 360px
one, Unsubscribe went onto a second row by itself. Below that width an icon button now narrows
rather than the row breaking. The reader's buttons were items of its meta line itself, so a long
feed name took them onto the next line one at a time; they are one group now, which moves as one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:51:11 +00:00
0f93d1bd0d Fill a pinned pin button with the accent (#148)
The reader's pin, a feed page's pin and the toolbar's showed pinned only by turning the glyph
solid, in the same grey as the button around it, and at a glance it read the same as not pinned.
Pinned, they are now filled with the theme's accent and the pin drawn in the ink the theme pairs
with it, the pair a pinned feed's disc and a pinned item's row already use, so no palette changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:51:05 +00:00
4f4f2e8d95 Try a feed again in minutes when its host could not be looked up for the moment (#143)
205 of the Directory's 208 feeds without a category had never been read: on 2026-10-05 at 03:24
UTC every one failed with "dns error: ... Temporary failure in name resolution" within a minute,
during an outage on Tower that also stopped iPX's log reaching Loki until Tower rebooted at 11:56.
They are working podcasts, NPR's, Megaphone's, Acast's, which resolve now. A feed nobody
subscribes to is read once a day and a failing one backs off up to a day (#99), so each sat
failing, unread and so uncategorised (#117) until the next day, and explain_failure called every
DNS error "This address no longer resolves; the site is gone.", on its page and since #142 on its
Directory page. A forced check of the 208 read 205; each has its items, its description and its
own category now.

A temporary lookup failure, EAI_AGAIN, is not recorded on the feed at all: no last_error, no
error_since to back off from or call it gone by. The daemon notes it in Ctx::retry and the feed
is due again in ten minutes, which the scan's due check and the sleep until the next scan both
honour; a restart forgets it, which only makes the retry sooner. A name that does not exist is
still the feed's error, explained as "may be gone", and the Directory page, like the feed's own,
names a cause only once a feed has been failing a day; before that its last check failed.

The page also drops "Nobody here subscribes yet", which said nothing worth a line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:18:42 +00:00
c1b73b3dbe Between releases, the version says a release is in progress: 0.10.3-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:53:35 +00:00
fc0d6bf98b Release 0.10.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:50:32 +00:00
a44e2b4235 Bring the docs up to what changed: reload, corrections, flags, durability
An audit after 0.10.1 found the docs behind the code. architecture.md named no reload command
(#120) and still said a changed title was picked up, false since #96 until #141; it now says
what a scan writes again, and what the Directory page's endpoint sends (#142). cli.md lacked
add's --list and --category, and that add, rm and import tell a running daemon to read the
catalogue again. users.md did not mention Currently Listening or its search (#127).
configuration.md says that ipx no longer waits for the disk on each write, on SQLite or Postgres
(#135, #140), and what that can lose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:50:15 +00:00
2098343621 Say on a feed's Directory page why it is failing, and who subscribes (#142)
daily-quests, one subscriber and no items, showed "Nothing read from it yet. A feed nobody here
subscribes to is checked once a day." on its page in the Directory: the second sentence was
written whatever the count, and the feed had been failing since #119 said so, which the page
never showed, /api/directory/{id} sending no error.

It sends `failing` now, the reason a feed's own page gives (explain_failure), or that the last
check failed, never the error itself, which can name the feed's address, and the Directory names
none. The page shows it under the header in the error colour, and says a feed is checked once a
day only when nobody subscribes and nothing is wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:50:15 +00:00
5684a02e79 Write again a stored item the feed now tells differently (#141)
"APPOFENIACS - A Technology Movie about Human Behavior" in Ain't It Cool News showed 66,170
characters: the feed's own HTTP headers and then the whole feed, tags stripped, and every other
item of the feed carried the text of the one before it. The publisher's feed had served it that
way, as only its XML could make a shift like that, and has since been fixed; ipx had read the
fixed feed and kept the broken text. Since 57ab419 (#96) a scan called record_entry only for a
guid it had not stored, so its UPDATE of title, description, image and duration was never
reached, and no correction, retitled episode or fixed length arrived.

Db::changed_items reads back what is stored for the items the feed lists, not the archive, and
names those it now gives a different title, text, artwork, length or number, a field it leaves
out counting as unchanged; the scan writes those again too. A corrected item keeps its read
state. A test serves a feed broken and then fixed from a socket of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:50:15 +00:00
50c3b9db64 Between releases, the version says a release is in progress: 0.10.2-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:58:40 +00:00
39ae43c80e Release 0.10.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:42 +00:00
c9964778cb Give SQLite several connections, so requests are answered at once (#136)
SeaORM gives a SQLite pool one connection unless told otherwise, and connect() did not say, so
every request, every scan write and every read went through it in turn. Twenty-five people
browsing in the load tests (#133) got 50 requests a second out of 12 cores, the fastest answers
in 3-30ms and the medians ten times that.

SQLite now gets eight. In WAL readers run beside the one writer. Every transaction here writes
first, so it takes the write lock or waits out the busy timeout for it; SQLite refuses at once
only a transaction holding a snapshot a write has moved past, and none here does. Db::memory
switches its file to WAL too, as Db::open does, so the tests share it as the daemon does. A test
holds a write open and reads beside it, and fails with one connection.

The same browsing: a feed's items at p95 10ms where it was 133, a search 22 where 119, the feed
list 89 where 242. The load tests' SQLite budgets are tightened to match; the Rust tests take
2.8s where they took 7.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:22 +00:00
a553d050ca Have ipx add, rm and import tell a running daemon to read the catalogue again (#120)
`docker exec iPX ipx rm make` printed "removed make" and took it out of the catalogue table, but
the web UI went on listing it. add, rm and import run in a process of their own and write the
whole catalogue there; the daemon kept its own copy in memory, and its next change of its own --
an admin's edit, a feed categorised (#117), a moved feed followed (#115) -- wrote the removed
feed back and dropped any the command line had added.

A new socket command, reload, has the daemon read the catalogue and settings again from the
database. The socket answers it at once, as it answers status, with the counts after it: queued
behind a scan, the web UI could write the old copy back in the meantime. add, rm and import send
it when a daemon is live, and say so if it does not answer. User commands need nothing: the
daemon reads accounts from the database on every request.

A browser test runs the real `ipx add --list` and `ipx rm` against the test daemon, with an
admin's edit after each, and fails without this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:22 +00:00
9c2f3eaf1b Search Currently Listening from the search box (#127)
Currently Listening's search box said "Search items…" and did nothing: loadEntries returns early
for a place with its own list. Typing now redraws the list with the search sent along, as
/api/entries already takes one, title and text, and says so when nothing in progress matches.
The keys list says / searches it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:21 +00:00
c63f70ad0c Show a feed that answers with nothing, having never posted, as failing (#119)
daily-quests and hots-dots had 0 items and no error, so their subscribers saw shows that had not
posted yet rather than feeds that are gone. Both domains have lapsed, and this network's DNS
filter answers for a parked domain with its block page, over http a 200 with an empty body.
scan_one took a body of nothing but whitespace for "nothing yet" and recorded no error.

It still does for a feed that has stored items, which is what the Antarctic Survey's empty 202
means, but a feed that has never stored one now fails with "the feed's address answered with
nothing at all", which the feed's page explains as an address that answers with nothing, the
site maybe gone. The next real read clears it. A test serves both from a socket of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:21 +00:00
bbdcbe213f Run the load tests on Postgres too, as production runs (#139)
node tests/load/run.js --postgres puts the scratch daemon in IPX_TEST_DATABASE_URL, the database
the Rust tests already use on Postgres, ipodderx_test on the databases project's server, in a
schema of its own, ipx_load, dropped and made again each run as /tmp/ipx-load is wiped. The URL
is in /src/.envrc, taken from ipx.env. It is production's server and role, so the harness refuses
a database whose name does not end in _test, and hands psql the password in its environment,
where no other process on Tower can read it. psql comes from /src/install.sh.

After seeding it runs ANALYZE: a schema filled seconds before has no planner statistics until
autovacuum gets there, which it did partway through a test, and the search's p95 swung between
40ms and 166ms from run to run. Analyzed first, three runs gave 37-38ms.

Each budget now has a value for each database, about twice what it measures on Tower: Postgres
answers the feed list at p95 54ms to SQLite's 242, having no one connection to queue for (#136).
Running it found #140.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:31:18 +00:00
8c4a5f396b Don't wait for the Postgres server's disk on every commit (#140)
Running the load tests on Postgres (#139) found a scan reading 1.2 feeds a second: after ten
minutes the first of 1,500 had reached 1,136, where SQLite does them in 27s since #135. On the
databases project's server, production's, 200 single-row inserts took 2,565ms as commits of their
own, 12.8ms each waiting for the WAL to reach the disk (synchronous_commit=on), 56ms with
synchronous_commit=off, and 67ms in one transaction. A feed's entries and enclosures are written
a row at a time, about fifty commits for 20 new items, and production's scans paid the same.

ipx's own sessions now set synchronous_commit=off, through sqlx's connect options, so it holds
on top of whatever options a URL sets, the tests' search_path included. A crash of the Postgres
server can lose the last commits, about the last 0.6s, and never corrupts anything; a crash of ipx
loses nothing; other databases on the server keep their setting. The same first scan takes 20s,
and the Rust tests on Postgres 3.3s instead of 7.1s. The test of SQLite's setting checks this one
too when it runs on Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:31:18 +00:00
b43ba89778 Load tests with k6, for what many people at once do to the server (#133)
The browser tests drive one person against a handful of feeds, one request at a time, and cannot
show what production's load does. node tests/load/run.js builds a release binary, serves 1,500
generated feeds from itself, starts a daemon of its own under /tmp/ipx-load, seeds a hundred
listeners with thirty feeds each and four downloaded files, and runs four k6 scripts, with
`ipx status` -- the healthcheck -- run every second and failing the run at its 5s timeout:

  browse     25 people at once: feed list, All Subscriptions, a feed, a search, the Directory,
             a feed's page in it
  listening  100 players saving positions every second and marking items read while scans write;
             each reads its own state back, which must be as it left it
  media      50 listeners seeking: every range checked byte for byte against the served file
  signin     a flood of wrong passwords while others browse, and the gap between refusing a
             known name and an unknown one

Each budget is about twice what it measures now. Getting here found #135 (SQLite waiting for the
disk after every write, a first scan of 1,500 feeds estimated at 50 minutes, now 27s), #136
(SQLite's single connection, left open), #137 and #138 (fixed in the commit before this). k6 is
installed from its own signed apt repository by /src/install.sh, outside this repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:38:20 +00:00
f1d360420c Check passwords a few at a time off the async workers, and check unknown names too (#137, #138)
The load tests (#133) sent forty clients' wrong passwords to /api/login, 54 attempts a second,
which takes no account. Everyone else's requests took 4s (median 3.96s for /api/feeds, about 20ms
otherwise) and `ipx status`, the healthcheck, 1.3s (#137): each attempt was an Argon2id check,
tens of milliseconds of CPU, run inside the handler on one of the runtime's workers, so a handful
at once held every worker the rest of the server answers on. And a wrong password for an
account's name was refused a median 31ms later than one for a made-up name (#138), since only a
name with a hash was checked: the answer read the same, the time said which names are accounts.

auth::check_password runs the check on the blocking pool, at most half the cores at once, so a
flood waits on itself, and checks a name with no account, or no password, against a fixed decoy
hash, false in the same time. Under the same flood the rest of the site answers at p95 57ms,
`ipx status` at most 90ms, the gap is 0.2ms, and twice as many attempts are answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:37:35 +00:00
697e907c86 Keep the feed list's icons when it redraws, rather than load each again (#134)
Checking every feed made all the feeds' icons in the feed list flash while it ran. A check sends
each feed's new row as it reads the feed, and renderFeeds, run once a frame while they come in,
emptied the list and built every row anew, every <img> with it: each icon went blank and was
loaded and drawn again, many times a second through a scan of 150 feeds.

The rows are still rebuilt, but the images already drawn go into the new ones in place of the
fresh copies, matched by their markup, so only an icon that has changed is made anew. A browser
test checks the rows are new and the images in them the same elements, and fails without this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:37:35 +00:00
894dbbe31d Between releases, the version says a release is in progress: 0.10.1-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:12:53 +00:00
552d20443e Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:09:51 +00:00
367f431e1f Sync SQLite at checkpoints, not after every write (#135)
The load tests (#133) found a daemon on SQLite scanning 1.3s a feed of 20 items served from the
same machine: 1,500 feeds would have taken about 50 minutes. On Tower's /tmp, a loop device, one
synchronous 4 KB write takes 52ms, and SQLite was in WAL mode with its default synchronous=FULL,
which waits for the disk on every commit; nearly every statement is a commit of its own, a
feed's entries and enclosures being written a row at a time. Subscribing the first admin to an
imported catalogue of 1,500 feeds took 39s, before the web server listened.

Every SQLite connection now sets synchronous=NORMAL, SQLite's own advice for WAL: it syncs at
checkpoints. A crash of ipx loses nothing; a power cut can lose the last transactions, and never
corrupts the file. The same scan takes 27s and the same subscribing 0.75s. Postgres, which
production runs on, is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:09:29 +00:00
ef8e2526f2 Show what a feed says it is on its page in the Directory (#130)
A feed's page in the Directory (#128) had its cover, category and latest items, but not the
feed's own description, which Apple's show page leads with: iPX read every item's description
and threw the channel's away, and the feeds table had nowhere to keep it.

It is parsed now, RSS's <description>, or iTunes' summary when that is empty, or Atom's
subtitle, kept in feeds.description (kept when a later read has none, as title and image are),
and sent, sanitized, with the items from /api/directory/{id}, which is now an object, not a
list. The page shows it as plain text under the header, three lines of it, with More when there
is more. A description that only repeats the title is left out.

feeds.description is the first column added to a table that already exists. create_missing
looks for it with a SELECT and runs the ALTER only when it is missing, since it runs on every
open, the healthcheck's included, and an ALTER's lock is what made that time out before. The
same once-only step forgets every feed's ETag and Last-Modified: a feed is read whole only when
it has changed, so its description would otherwise wait for its next item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:46:22 +00:00
5058adc93e Open a Directory category's fan of covers as one smooth motion (#132)
Hovering a category tile turned its outer two covers further out over 0.2s, and it looked
choppy. Traced in Chromium, the covers were given layers of their own only once the transition
began, so the browser drew them again on its first frames and again on the way back, 22 raster
tasks for a hover in and out, and the main thread produced 34 frames for 0.4s of motion.

Each cover now has its layer before the pointer arrives (will-change), all three move, rotate
and translate are animated as properties of their own rather than as one transform list, and
the fan takes 0.45s on an ease that settles. The same hover is 5 raster tasks, and the
compositor presents 116 frames to the main thread's 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:46:22 +00:00
9e39f2d756 Draw every Directory category tile on the panel, untinted (#131)
Each tile took a faint tint from a hash of its category's name, as an initials tile does, so
neighbours differed. Five of them, Comedy, Government, Kids & Family, Leisure and Music, drew
--good, and green means yours everywhere else on the page, the subscribed tick and a downloaded
file among them: those tiles read as holding something you subscribe to, when the tint meant
nothing. The fanned covers already give each tile its colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:33:57 +00:00
b86062b97a Keep a feed's items and files to the people who subscribe to it (#129)
Routes that take a feed or an enclosure id did not check who was asking. Anyone signed in could
read any feed's items through GET /api/feeds/{id}/entries, a paid feed's included, with the
addresses of its files, which can carry the subscriber's key: the Directory leaves such feeds
out for that reason, and this route handed them back to whoever guessed the id, a slug of the
title. In production it answered 25 items of a feed the asking account does not subscribe to.
/media/{id} served any downloaded file by its sequential id, POST /api/enclosures/{id}/download
and /api/feeds/{id}/download-latest queued any feed's downloads, and DELETE
/api/enclosures/{id}?force=true deleted any file.

Each now answers 404, "you do not subscribe to that feed", unless the person subscribes to it.
A feed inside an OPML has a subscription row of its own for everyone subscribed to the OPML, so
that holds for those feeds too. Found while adding the Directory's feed page (#128), which has
its own route that answers only for listed feeds and carries no files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:19:38 +00:00
95acde3046 Lay the Directory out as Apple's is, with the most subscribed in it (#124, #126, #128)
The Directory was one grid of every listed feed, 1,472 in production, under Podcasts and Blogs
tabs, a wall of about 30 category buttons and a sort menu, with a picked category's
subcategories appearing as a second row of buttons that looked like the first. It read as a
list to scroll, and the two rows were hard to tell apart.

It opens now on the ten most subscribed feeds, ranked, and the categories as tiles, each with
three of its shows' covers fanned in its corner on the tint its initials would get, so the
shows colour it and no theme's palette changes. A category has a page of its own: its most
subscribed, its subcategories with how many each holds, and its grid. See all is every feed,
as the grid was. Podcasts or Blogs holds across every page. Popular, the ten most subscribed,
had a place of its own in the feed list; it is the Directory's first section instead, and its
key, g p, is gone. /api/popular stays for scripts.

The search box finds a feed in the Directory by name (#126). It said "Search items…" there and
did nothing, since loadEntries returns early for a place that lists feeds.

A feed you do not subscribe to opens a page of its own (#128): its cover, category (a link to
that category's page), how many here subscribe, a subscribe button and its latest twenty items,
each with its title, linking to the post where it has one, its first lines, date and length.
Before, a click on it did nothing; only its + did. The items come from GET /api/directory/{id},
which answers only for a feed the Directory lists, so a guessed id reaches nothing private, and
carries no file or its address. The feed's own description would belong at the top, but the
database does not keep one.

Every tile and chart row takes the keyboard, as the feed list's rows do. Feed text made plain
keeps a space at a line break or a paragraph's end, which glued "2010)Recorded" together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:19:38 +00:00
0b4f5443b7 File Apple's Football and Soccer under Sports in the Directory (#125)
The Directory listed Football (6 feeds), Soccer (7) and "Soccer," (2) as categories of their
own beside Sports, and Software How-To (2) beside Technology. CATEGORIES spelt two of Apple's
Sports subcategories American Football and Football (Soccer), names no feed uses: Apple's list
says Football and Soccer, so a feed naming either fell through as a category of its own.

They are Apple's spelling now. The old spellings are aliases, since Jev picked from the list
while it had them and its answers are stored, and so is Software How-To, Apple's Technology
subcategory before its 2019 list. A comma or full stop at the end of a feed's category, as in
"Soccer,", is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:19:12 +00:00
e699baa22a Say plainly when a feed is given a category, in the changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:18:18 +00:00
00bd58ac9d API tokens a person makes for scripts and agents to act as them (#123)
The API took a session cookie, a proxy's word or the shared admin token, so a script or an
agent working for one person had to sign in with their password and carry the cookie, or be
given the admin token. Settings now makes named tokens, ipx_ and 256 random bits, sent as
Authorization: Bearer. A token is its owner and no more. Only its SHA-256 is kept, in the
new api_tokens table, with when it was made and last used; it is shown once and revoked from
the same list. An unknown or revoked one gets a 401 rather than falling through to a cookie.

Cloudflare Access still stands in front of the tunnel, so from outside a token needs an
Access service token beside it; docs/sso.md says how.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:15:17 +00:00
cc17b1ddab Sort the Directory by name either way or by subscribers (#122)
The Directory always listed A to Z, as the server sends it. A menu beside its filters now
sorts it A to Z, Z to A or by most subscribers (then by name), in the page, without asking
the server again, and the choice stays while the pane is redrawn, as the filters do. The
blurb no longer says A to Z, since it may not be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:54:32 +00:00
119336a20d Give a pinned item the pinned feed's disc (#121)
A pinned feed has a disc in the theme's accent, its pin in the ink the theme pairs with it;
a pinned item only turned its pin solid in the text colour. The item's pin button now draws
the same disc, as a background inside its 22px button so the row does not move when an
item is pinned. The colours are the ones .fpin already uses; no palette changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:50:48 +00:00
02a46bb500 Browse the Directory by category, then subcategory, as Apple does (#118)
The Directory had one row of chips holding whatever each feed's category was, a category
(Technology) or a subcategory (Tech News, Video Games) side by side: a podcast's own
<itunes:category> is stored as its subcategory, and Jev's answers (#117) are often
subcategories too, so after the first forced scan the row held 22 chips. /api/directory now
gives each feed's Apple category and subcategory, worked out from Apple's list, and the
page shows the categories, then a picked one's subcategories on a line of their own. A
category that is not Apple's, one an admin typed, stands as a category of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:33:26 +00:00
3e384f34f9 Give a feed with no category of its own one of Apple's (#117)
Most blogs and news sites name no <itunes:category>, so they sat under no chip in the
Directory unless an admin set one by hand. When TYPESAFE_KEY is set, a feed's first read, or
a forced refresh, asks TypeSafe's Jev to pick one of Apple's categories or subcategories from
the feed's title and up to 15 item titles, for a catalogue feed with neither its own category
nor an admin's. The answer is stored as the catalogue's category, so a feed is not asked
twice, and an admin can change it. Only on a first read or a forced refresh to keep the calls
down: feeds already subscribed are categorised by one `ipx fetch --force`.

Tried on eight subscribed feeds on 2026-10-04: XDA, Daring Fireball, Ars Technica and The
Verge as Tech News, The Old New Thing as Technology, John D. Cook as Mathematics,
BoardGameGeek as Games, Pluralistic as News Commentary (0.55). About 2,000 input tokens a
feed at $0.042 a million. The likeliest answer is kept however unsure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:20:00 +00:00
d238681ec1 Name an item without a title from its own text, not "(untitled)" (#116)
RSS 2.0 makes an item's title optional, and some blogs leave it out on purpose: Scripting News
titles almost none of its posts. Fifty rows of "(untitled)" said nothing about any of them.

entryName gives an item its title, or the opening of its text (HTML read through DOMParser, an
inert document that loads nothing; cut at a word near 120 characters), or its file's name, or
its show and date, with a flag for a name that is not a title. The list sets that one in the
regular weight, as the text it is rather than a heading; the reader leaves out the heading so
the post starts with itself; the player, the lock screen, Currently Listening, the native shell,
Share and the queued toast use the same name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:46:47 +00:00
0395717c14 Put the bytecode cache's ignore line on a line of its own
The previous commit appended it to a file without a final newline, joining it to
.claude/settings.local.json and un-ignoring both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:27:19 +00:00
8ce68a881a Ignore Python's bytecode cache from the prod-check script
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:27:07 +00:00
e7c59489ee Announce a followed move as a feed_moved event (#115)
A feed moved to its new address was only logged by follow_move. It is now an event, feed_moved
with the feed and its old and new addresses, so it goes where every other event goes: the log,
with from and to as fields, the admin page's Scans view, `ipx fetch`, and the page, which gets
the feed's new row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:22:28 +00:00
f7f4b466dc Follow a feed that has moved for good to its new address (#115)
A feed whose address answered with a permanent redirect was read through it on every check,
and the catalogue kept the old address: 28 of 147 feeds in production, most http to https, some
to a new path or domain.

Feeds are now fetched with a client of their own that follows no redirects (Ctx::feed_client),
and feed::fetch follows them itself, up to 10 hops, so it sees each one. When every hop was
permanent (301 or 308) it says where the feed ended up, and the scan moves the feed there in the
catalogue (follow_move). A temporary hop (302, 307) anywhere moves nothing. A feed an OPML lists
is left alone, as the OPML would put the old address back, and so is a move onto an address
another feed has. A password goes only to the feed's own host, never to a redirect elsewhere;
reqwest's own following dropped it the same way. Ten hops is a loop, worded as reqwest worded
it so it still reads as redirect_loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:05:43 +00:00
ec8fd5dd86 Sleep until the next feed is due instead of scanning every minute (#114)
The daemon ticked every 60 s and ran a scan pass each time: the sweep, then a check-state query
per feed (about 180) to find which were due. In the six hours before, 293 of 362 passes found
nothing due. Now, after each pass, it works out when the earliest feed is due (due_at, shared
with the scan's own check, over Db::http_states, one query) and sleeps until then: at least
30 s, so a feed that never gets a check time cannot spin it, and at most 10 minutes, so what no
command announces, ipx add or a shorter schedule, is picked up. Commands still wake it at once,
and the first pass after starting runs straight away, as the tick's did. The scan reads every
feed's state in one query too.

The prod-check skill says what to expect now: tens of scans in six hours, and pending as the
real queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:56:13 +00:00
a387012c69 Count as queued only what a scan will download on its own (#113)
Every file in state 'pending' counted as waiting to download: 4420 in production, across 12
shows. Since #97 a scan only downloads among a feed's newest max_new_per_check items, so those
were back-catalogue episodes no scan would take; the real queue was 0.

A new state, 'held': listed and downloadable by hand, but outside the feed's newest items, or of
a feed nothing downloads automatically. Db::hold_back moves a feed's waiting files between
'pending' and 'held' each time the feed is due, changed or not, and again after its items are
stored, so a new episode, a raised limit or auto-download turned on or off moves them. A held
file keeps its item's place among the newest, as a downloaded one does. 'pending' now means
queued, so ipx status, /api/status and the dashboard read true without changing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:47:52 +00:00
a00516687a Keep artwork on disk and serve every image from iPX (#111)
The page loaded artwork from each publisher's server, or through /api/art, fetched every time,
for http-only hosts. Nothing was kept, every visit asked every publisher, and artwork went when
a publisher's server did.

- The page draws every image a feed or item names from /api/art. The first time, iPX fetches
  it (only an address a feed or item names, only an image, up to 5 MB, within the feed timeout)
  and keeps it in art/ beside the database, under a hash of its address with its type beside
  it (src/art.rs). Later it comes from disk, which marks it as used.
- art_cache_mb, a server setting on the admin page, 500 by default, caps what is kept: the
  sweep before each scan drops the least recently shown until it fits. 0 keeps nothing, and
  artwork is fetched through iPX each time. Settings saved before it get the default.
- Served from iPX's own address, someone else's image must stay an image: nosniff, and a CSP
  with sandbox, so an SVG opened on its own runs no script as iPX.
- The fixture server sends .jpg as image/jpeg, which /api/art requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:50:46 +00:00
38ebc7b02b Move old items' http artwork to https too, for hosts the feed no longer names (#110)
The first pass only asked the hosts the feed's current body names. IGN's feed kept five 2009
items with artwork on assets1/assets2.ignimgs.com, which its feed no longer mentions, so they
stayed on http. A scan now also asks, once per host, the hosts of the feed's stored http
artwork (Db::http_images).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:35:51 +00:00
54d827f655 Time out a hung feed, serve the precomposed touch icon, and store http artwork on https (#108, #109, #110)
#108: the HTTP client had no timeout, and scans handle feeds in order, so a hung server held
every scan. Dreamwidth answered 504 after 60-67 s for a day and each scan took 70-80 s instead of
15. A feed fetch, and a Patreon creator's show list, now gets 30 s from connecting to the last
byte (feed::FEED_TIMEOUT); the client gets a 10 s connect timeout, which bounds a download's
start but not a long download.

#109: iOS asks for /apple-touch-icon-precomposed.png first when the site is added to a home
screen; it was a 404 and the only non-feed warning in the log. It serves the same icon.

#110: the page is https and loads no http. Artwork on http came through /api/art (#90) even
when its host serves https too. A scan now tries each http artwork host on https once per feed
(feed::prefer_https) and stores the https address where the host answers with an image,
rewriting that feed's stored items from the same host (Db::secure_images). 4 of the 5 hosts in
production do; cdn.thesecretcabal.com presents another name's certificate and stays on
/api/art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:31:50 +00:00
e2593eaa50 Put a pinned feed's pin on the corner of its artwork
The pin was a small accent-coloured icon before the feed's name. It is now a disc on the
artwork's corner, where a failing feed's mark is, in the accent and the ink the theme already
pairs with it for primary buttons (checked by tests/contrast.js), so no palette changes. A feed
both pinned and failing keeps the error mark at the bottom corner and the pin at the top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:23:40 +00:00
d52ecfcbd7 A listed feed nobody subscribes to downloads nothing (#107)
With no subscribers a feed falls back to its own settings, where auto_download is on, so a
listed feed with audio would have downloaded files for no one. The seeded news feeds carry
only images, which media_types already skips, so nothing was downloaded. Files skipped for it
are judged again, by the new subscriber's settings, on the next scan after someone subscribes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:20:09 +00:00
142610e8b8 ipx add --list or --category updates a feed the catalogue already has (#107)
It refused one already there ("already subscribed as ..."), so a feed added before listing
existed, such as CBC's, could not be put in the Directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:17:49 +00:00
43acc62259 List feeds in the Directory before anyone subscribes, and clear out dead ones (#107)
The Directory showed a catalogue feed only once someone subscribed, and a feed left the
catalogue with its last subscriber, so nothing could be put there for others to find.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:15:51 +00:00
e9f2832e1e Show a failing feed on its artwork, in words, and grey (#93)
The mark was a 12px "!" in the sidebar's margin, told apart by --bad alone; a dark theme's --bad
is a pale pink, and at that size it vanished. It is now a solid disc on the artwork's corner, the
subtitle says what is wrong in place of the counts, and a feed failing for a day or more has its
artwork greyed out. Lightness and words carry it, so no theme's palette changes.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:11:16 +00:00
66e40e3c71 A skill for checking production from Loki and Tempo
.claude/skills/ipx-prod-check: what production's JSON log and traces
carry, the queries that find trouble (warnings grouped, failing feeds and
downloads, 5xx and slow routes, whether the worker keeps up, slow and
failed traces), how to tell a publisher's dead feed from an ipx bug, and
filing what is found as issues per CLAUDE.md. query.py beside it runs the
LogQL and TraceQL through a throwaway container on the monitoring
network, since Loki and Tempo publish no query port.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:55:50 +00:00
8ce0a4cb27 A Grafana dashboard for iPX
Built on what the monitoring project already collects: the container's
log in Loki (through Alloy) and the traces in Tempo. It parses the
access log and the event log's wire JSON, so there are no metrics to
add to ipx: what is waiting and downloaded (from the healthcheck's
status), new items, downloads and bytes, failing feeds and downloads,
requests by status and response time, the slowest and busiest paths,
recent and slow traces, and the log. Provisioned from a file, so it is
regenerated here, not edited in Grafana.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:38:29 +00:00
549ae33f06 Keep test daemons out of production's traces and database (#87)
The browser suite's daemon took its environment from the shell running
it, so a shell with OTEL_EXPORTER_OTLP_ENDPOINT set would have sent its
fixture scans to production's Tempo, and one with IPX_DATABASE_URL set
would have run the suite against production's database. Both are now
blanked for it. Production's traces carry
deployment.environment.name=production, which the dashboard filters on,
so a daemon run by hand stays out as well.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:41:57 +00:00
00f6293b95 The refresh button turns while its feed is checked (#78)
The feed events already put a spinner on the sidebar row, which a phone
hides. The same state now sets scan-this (the open feed, or a feed in the
open folder) and scan-any (any of your feeds) on <body>, and the refresh
icons turn under them. On <body> because the feed page is redrawn as items
come in.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:47:08 +00:00
7d55d99fac A feed's refresh button looks like its neighbours (#76)
The check-now button on a feed's page, a folder's page and All
Subscriptions was class primary, drawn filled in the accent colour among
plain buttons. Primary stays for a dialog's confirm button; the rules
that only the feed pages used go with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:42:58 +00:00
a5de4ae06c Toggle state in the icon's shape, not the theme's colours (#74)
4ed2d59 drew a pressed toggle in each theme's accent colour; the themes'
colours were not to change. Back as they were, pinned rows included. The
read button carries its state in its shape instead, as the pin does with
outline and solid: a tick when read, the envelope when not, where before
it showed the action (the envelope on a read item).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:38:59 +00:00
4ed2d59311 Toggles show their state the same way everywhere (#74, #75)
A sweep of all 24 theme palettes, measuring each icon's drawn colour,
found pinned in three colours: accent in the feed list, the text colour
on an item's row, and uncoloured on the toolbar, beside the title and on
the feed page. The read button showed the action (an envelope on a read
item) beside a pin showing the state. Now each toggle shows what is, with
aria-pressed, and a pressed one is the accent colour; Classic needs its
own rule, as its buttons set their colour at higher specificity. The
contrast test checks the accent on the button grounds, where it now draws.

Directory's Subscribe button carried the Subscribed tick; it is a plus.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:27:20 +00:00
7490a3a9ac A spinner after pulling to refresh (#68)
Letting go of a pull removed its note at once and showed nothing else;
the sidebar's scanning spinner is hidden on a phone. With no sign the
check had started, people pulled again, and again. A "Checking for new
items" pill with a spinner now sits under the top bar until the request
is sent and two seconds have passed, and a pull meanwhile does nothing.
It lives outside #list, which a feed's render rebuilds.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:10:05 +00:00
bf785299b0 No logo in the phone's top bar (#66)
The logo moved into the top bar beside the add-feed button (#60). On a
phone that bar is tight: the logo squeezed the search box down to a few
letters, and with no hover there its version tooltip showed nothing.
Below the phone breakpoint it is left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:53:34 +00:00
5967aa1e57 Drop Outline from the SSO notes; it is gone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:21:39 +00:00
9e238bfc75 Say in the SSO notes that Authentik's tile cache needs clearing after an edit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:20:14 +00:00
10b7ccd424 Name the Authentik tile iPX in the SSO notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:18:32 +00:00
d8db785681 The tab's icon follows light and dark mode (#64)
The logo on the page switched with the mode (#63), but the tab's icon
was always favicon.png, the light logo. web/favicon-dark.png is
logo-dark.svg at 128px, served beside it, and the theme script points
the icon link at whichever matches data-mode, so it follows the theme
the account chose, not only the system. The sign-in page, with no
account, picks by the system's with two media-bound links.
/favicon.ico, which a browser asks for on its own, stays the light one.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:05:44 +00:00
3cb36ab8b7 Between releases, the version says a release is in progress (#62)
Production ran four commits past v0.9.0 while the logo's tooltip said
0.9.0, because Cargo.toml's version only moved at a release. It is now
0.9.1-dev, and CLAUDE.md's release steps end by moving to the next -dev
version. No commit hash: the name says there is newer work, and git says
which.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:54:26 +00:00
65b5eacdb4 Settings no longer shows the server's download folder (#61)
The Settings dialog ended with the server's download_dir, read-only and
the same for everyone: it is set in config.toml, so nobody can act on it
from there. The admin page still shows it.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:45:35 +00:00
70e0341348 A more vivid orange for the logo's needle (#59)
The needle was #f7931e (#ff9f2e dark), a soft orange close enough in
lightness to the tallest blue bar that the two ran together where they
touch, and weak at favicon size. It is now #ff5500 (#ff6a1a on the dark
background), fully saturated and pushed towards red, away from the bars'
blue. favicon.png and apple-touch-icon.png are re-rendered from logo.svg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:32:07 +00:00
e16dace9c0 On the Unread tab, a swipe back goes to the item just read (#50)
selectEntry took each read item out of the list the moment you moved on
from it, so the item was not there for the back swipe (or k) to reach: it
went to the one before, or to the list if the item had been first.

Items read while turning from one to the next (a swipe, j and k) now stay
in the list until the reader closes or another item is picked from the
list, and a background refresh keeps them as it keeps the open one.
Picking a row still drops the item left behind at once, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:36:29 +00:00
9f56436033 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:42:21 +00:00
ae900b82ca Name the icons by their contents in the pages (#57)
/favicon.png, /apple-touch-icon.png and /logo.svg are kept a day under
fixed names, so after the new logo went out, curl through the tunnel and
browsers still got the old one. The pages now ask for them as
/favicon.png?v=<hash>, the way they already ask for app.js and app.css,
so a changed icon is a new URL for every cache on the way. /favicon.ico
cannot carry a query, as browsers ask for it on their own; it keeps the
day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:11:03 +00:00
40c92ad08d Rename iPodderX to iPX (#56)
The app, the repository (rays/ipx), the image, the compose service and
container, and the data folders on Tower take the new name. What stays:
the 2004 iPodderX and ipodderx-core, which are history; the Postgres
database and login, and ipodderx.sdf1.net with its Access and Authentik
apps, which would each need moving outside this repo; and the first-start
password, as ipx is shorter than the eight characters a password needs.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:16 +00:00
162665b010 Phone layout drawn the way iOS draws its apps (#53), clear of the Dynamic Island (#54)
On a phone every control was outlined, hairlines ruled off the bars, corners were 7-9px and
dialogs were centred cards: a desktop page, shrunk. Now, below 820px and in every theme, controls
are filled and round (40px circles and capsules, pills for groups), the feed's name is a 26px
large title, the tabs are a segmented control, rows are 44px with 17px text and an inset hairline,
the reader's type is 17px, and dialogs are sheets from the bottom. --edge is the one switch for the
outlines, left on for the high-contrast theme and for prefers-contrast:more. Glass's desktop radii
moved into a min-width query, because at their specificity they outranked the phone's shapes, and
on a phone its #51 rim only catches the top edge: all round a small capsule it read as an outline.

Text boxes were 13.5px, and Safari zooms the page in on focus below 16px, so tapping search zoomed
it; they are 16-17px on a phone now.

Nothing read safe-area-inset-top, so opened from the home screen the top bar, the reader's back
button and the drawer's head sat under the Dynamic Island. --safe-t pads them, and the phone's own
top-bar rule, a shorthand, had also been discarding the side insets for the notch in landscape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:48:15 +00:00
f2fde8cc75 Swipe between items like turning pages (#52)
On a phone the reader is fixed over the item list, and the swipe from #49 moved it alone and
faded it to 40%, so the list showed through it and in the strip it uncovered, and again as the
next item slid in from the far side. Now a layer beside the reader, #dpeek, holds what is really
there: the next or previous item, drawn by the same detailHtml the reader uses, with 16px of the
page's background between them; "No more items" past the last; or, swiping right from the
first, a dimmer over the list that lifts as the reader, shadowed along its edge, is drawn off.
On release the swipe carries on from where the finger left it, over 120-250ms by how far is
left, and the neighbour becomes the reader in place; with reduced motion it switches at once.

A touch starting within 14px of the left edge is kept from Safari, which otherwise takes it as
Back and leaves the page mid-swipe. 14px because the back button starts at 16.

Offsets are rounded to whole pixels: at a fractional offset the seam between the reader and its
neighbour drew a stray light line.

The design had the list shift a third of the way across as it is uncovered, as iOS does. #detail
lives inside #shell, and a transform there makes it the containing block for the fixed reader,
so that part is left out; the dimmer and shadow carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:46:07 +00:00
a573a07ed5 Glass panels catch the light at their edges (#51)
The glass surfaces had one 1px highlight along the top, which read as flat. They now have a rim
lit from the top-left and a fainter one on the far edges, as box-shadows, and a sheen from the
top-left corner as a background layer. A pseudo-element would have been the usual way, but the
detail pane, file list and dialogs scroll, and an absolutely placed layer in a scroller scrolls
away with the content.

Refraction was looked at and left out again: bending the live page needs backdrop-filter: url(),
which Firefox and Safari lack, and the WebGL libraries (liquid-glass-js, liquidGL) bend a
snapshot of the page that goes stale on a list that scrolls in its own pane.

The sheen lightens a dark panel under its text, so tests/contrast.js now checks every text colour
on a panel under the sheen at full strength. That capped it at 7% in dark mode; 9% put --faint,
--accent and --bad under AA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:25:24 +00:00
380a552913 Share a feed, an item or a file (#48)
A share button in the feed's header, beside an item's "Open the original", and on each file.
It uses the Web Share API where the browser has it, which is the share sheet on a phone, and
copies the link where it does not. A file is shared by the publisher's address, not /media/,
which only someone signed in here can open.

Paid feeds carry the subscriber's access in their address, Patreon's in a token, so sharing one
gives the subscription away. An address that looks like that asks first.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:13:03 +00:00
868dd673f3 Swipes take a longer drag and slide the reader across (#49)
At a flat 60px, a thumb scrolling slightly on the diagonal moved on to the next item by
accident. A swipe now has to cover a quarter of the reader's width, and never less than 100px,
and the reader follows the finger while it is down, so it is plain before letting go whether it
will move on. It slides off on a swipe and the next item slides in from the other side; a short
one springs back. The CHANGELOG also gets back the [0.8.3] link a stray edit had broken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:04:30 +00:00
2930be37ad Release 0.8.4
The Glass theme (#43), playback handed to a native shell (#45), and the
bottom bar kept clear of the home indicator (#46), which had no changelog
entry of its own. The unreleased compare link had been left at v0.6.1 since
0.7.0; it points at the new tag now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 23:29:10 +00:00
Ray Slakinski
f9225f0d21 Keep the bottom bar clear of the home indicator (#46)
body is a grid of topbar / main / status / player, and nothing in the page
accounted for a display's own intrusions. Mobile Safari hides that by
insetting the layout viewport to the safe area, so the site was fine in a
browser -- but a full-screen shell, the native app or the site added to an
iOS home screen, hands the page the whole display, and the last row landed
under the home indicator with its seek bar and times half cut off.

viewport-fit=cover asks for the whole screen deliberately, and the bars
along the edges now pay for the insets in padding: the bottom for the
indicator, left and right for the notch in landscape. A browser with its
own chrome reports nought and nothing moves.

The padding has to be longhand, and there is a comment saying so, because
the minifier drops the space between a calc() and the value after it in a
shorthand -- padding:7px calc(12px + var(--safe-r))7px ... -- and a browser
then throws the whole declaration away. The bars lost all their padding,
which moved the item list far enough that the pull-to-refresh browser test
stopped finding it; nothing reported an error, and the page still loaded.
buildStyle now fails the build on a calc() run into its neighbour rather
than trusting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:10:39 -04:00
Ray Slakinski
9d1492b388 Hand playback to a native shell (#45)
CarPlay and Android Auto cannot render a web view. Both are template
surfaces, and the only audio they will control is the host's own AVPlayer
or ExoPlayer -- so an app that is "the web UI plus CarPlay" is really "the
web UI whose audio engine is native", and the page had no way to give
playback away.

web/src/native.ts replaces the playback surface of the page's media element
with one that forwards to the host and synthesises the events back. Nothing
in player.ts changes: it only ever speaks to the element, so the player bar,
the row buttons, the EQ bars and the keyboard shortcuts keep working as they
did. Video stays in the page, since CarPlay is audio-only and a native video
layer under a web view buys nothing. In a browser none of it installs.

Position and read are the host's to write. player.ts has been bitten before
by a stale position -- one left paused in another tab saved its older place
over where you had got to -- and a backgrounded web view is exactly that
tab: frozen, holding a time from minutes ago, while the host plays on. So
the beacon becomes a request for the host to save its own clock.

tests/native-bridge.js is what holds the two ends together, and it earned
its place immediately: the src setter called removeAttribute('src'), which
the shim's own override turned into a stop() that switched it back off one
line after enabling it. Silent, and only visible in a car. The stub DOM
moved to tests/dom-stub.js so that test and page-smoke share one harness
rather than two copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:49:53 -04:00
ad15ae3dfe Glass theme, after Apple's Liquid Glass (#43)
Translucent panels with backdrop-filter blur and saturation over a soft
coloured wash, light and dark, going solid under prefers-reduced-transparency
and prefers-contrast: more. The sticky filter bar and column headings are
frosted, since the list scrolls under them.

Text on a see-through panel lands on whatever the wash is behind it, so the
palette's hex values alone no longer say whether it clears AA. contrast.js
now samples the wash as the browser composites it on three viewport shapes.
It caught the first light palette at 3.7:1 for faint text, and a tinted
selection that failed everywhere; both were changed.

Left out: SVG displacement-map refraction, which Chromium alone applies to a
backdrop and only on fixed-size shapes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:45:01 +00:00
aedbe89654 CLAUDE.md: the tea example uses the token issue's real number, #40
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:16:33 +00:00
321c9e014e CLAUDE.md: run tea with a closed stdin and a timeout (#39)
tea comment, run without a terminal, waits on stdin and never exits. The example this file gave had the same problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:15:52 +00:00
73092e6ad7 Stop a hand-run daemon by its PID, not pkill -x ipx (#38)
On Tower the host sees the processes inside containers, so the
pkill -x ipx that CLAUDE.md recommended would have killed production's
daemon in the iPodderX container along with the test one. CLAUDE.md now
says to stop a hand-run daemon by its own PID; docs/cli.md keeps
pkill -x for a plain install and warns about the container case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:12:08 +00:00
1a3c8a6d4f CLAUDE.md: every problem found gets a Gitea issue, closed with a comment once fixed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:11:47 +00:00
b81d44cfb6 docs/sso.md: production checks Cloudflare's token
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:00:19 +00:00
72 changed files with 9842 additions and 1298 deletions

View File

@@ -1,14 +0,0 @@
{
"permissions": {
"allow": [
"Bash(rtk grep *)",
"Bash(rtk read *)",
"Bash(rtk git *)"
],
"additionalDirectories": [
"/config/.claude/skills/security-audit",
"/config/security-audit-skill",
"/config/.cargo/registry"
]
}
}

View File

@@ -0,0 +1,236 @@
---
name: ipx-prod-check
description: Look for problems in production ipx (the iPX container on Tower) from its logs in Loki, its traces in Tempo and its metrics in Prometheus, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site.
---
# Checking production ipx
Production logs one JSON object a line (`IPX_LOG_FORMAT=json`), which Alloy ships to Loki under
`{container="iPX"}`, and sends traces to Tempo tagged
`resource.deployment.environment.name="production"`. Anything without that tag is a daemon run by
hand, not production. Loki and Tempo publish no query port, so use the script beside this file:
```sh
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 trace <trace id> # one trace as a tree, with its log lines
$Q prom '<PromQL>' [since] # Prometheus, now; $range becomes `since`
```
`since` is `30m`, `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
"Since the last deploy" is since the container started:
```sh
S=$(docker inspect iPX -f '{{.State.StartedAt}}'); echo $(( ($(date +%s) - $(date -d "$S" +%s)) / 60 + 1 ))m
```
## What the log carries
Every line has `timestamp`, `level`, `message` and `target`; lines inside a span have `span` (the
innermost: `{"name":"feed","feed":"x"}`). Loki's `| json` flattens it to `span_name`, `span_feed`.
Lines inside a traced span, requests and scans, also carry `trace_id` and `span_id`: give the
`trace_id` to `$Q trace` to see the whole request or scan. (From 2026-09-29 16:30 UTC; before that,
lines had no trace id, the access log's time was `ms`, and each event was logged twice, words under
`ipx::scan` and fields under `ipx::io`.)
| target | fields | what |
|---|---|---|
| `ipx::http` | `method`, `path`, `route`, `status`, `duration_ms` | one per web request; `route` is the pattern, empty for an unrouted path |
| `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 |
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
updated), `download_done` (bytes), `download_error` (msg, url),
`torrent_deferred`, `reaped`, `scan_done` (feeds checked), `reap_done`, `status` (feeds, pending,
downloaded: the healthcheck's, every 30s), `deadair_done` (feed, enclosure, `saved_s` at the
default pause), `error` (msg). DeadAir also logs `DeadAir found the silences` (`cuts`, `saved_s`,
`took_ms`) under `ipx::deadair`, and warns `DeadAir could not read a file` when ffmpeg fails on
one, which it then passes over until the next restart.
`error.type` is the HTTP status (`404`, `503`) or one of `dns`, `redirect_loop`, `timeout`, `tls`,
`not_a_feed`, `site_message`, `connect`, `parse`, `other`; Loki's `| json` names it `error_type`.
Filter on the text before `| json` where you can (`|= "\"ev\":\"feed_error\""`): it is much
cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not JSON, and
`| json | __error__=""` drops them.
## What Prometheus carries
The daemon's own state between requests, which no log line or trace holds (#194): ipx serves
`/metrics` on `[web] metrics_bind` (production: `192.168.1.130:9187`, LAN only), on a thread and
port of its own, from memory alone, and Prometheus scrapes it every 15 s as `job="ipx"`. Kept
from 2026-10-11 15:15 UTC. All gauges:
| metric | what |
|---|---|
| `ipx_db_connections{state="in_use"\|"idle"}`, `ipx_db_connections_max` | the database pool: in use reaching the max means requests and scans wait for a connection |
| `ipx_worker_queue` | commands waiting for the sequential worker (scans, downloads, adds) |
| `ipx_event_listeners` | open pages and socket clients on the live-update stream |
| `ipx_torrent_slots_free` | of 2 |
| `ipx_deadair_playing` | episodes being played through DeadAir's playlist |
| `ipx_runtime_workers`, `ipx_runtime_tasks`, `ipx_runtime_queue` | tokio: threads, live tasks, tasks waiting for a thread |
| `ipx_process_resident_bytes`, `ipx_process_open_fds` | memory and open files and sockets |
| `ipx_uptime_seconds`, `ipx_build_info{version}` | since the daemon started; what runs |
| `ipx_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; the healthcheck passes while it is stuck |
| `ipx_jobs_total{kind}`, `ipx_job_seconds_total{kind}` | counters: jobs finished, and their seconds all told |
| `ipx_last_scan_age_seconds` | since a scan last finished; absent until one has since starting |
| `ipx_http_requests_in_flight`, `ipx_scan_feeds_fetching`, `ipx_downloads_active` | work in progress |
| `ipx_download_bytes_total` | counter; `rate()` is the download speed |
| `ipx_event_queue`, `ipx_events_missed_total` | live updates unread by some listener; counter of those dropped for one that fell behind |
| `ipx_deadair_backlog`, `ipx_deadair_busy_seconds` | files of shows with DeadAir on not yet looked at (episodes everyone heard through stay counted); how long it has been on its file |
| `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_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
## The checks
Run these, then read the lines behind whatever stands out. Most of the time is in the reading:
a count says something happened, the lines and traces say why.
1. **Warnings and errors, grouped.** What went wrong, how often, and since when.
```
$Q metric 'sum by (target, message) (count_over_time({container="iPX"} | json | __error__="" | level=~"WARN|ERROR" [$range]))'
```
Feed and download failures name the feed in the message; group them in the next check instead.
2. **Failing feeds and downloads.**
```
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"feed_error\"" | json | __error__="" [$range]))' 7d
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"download_error\"" | json | __error__="" [$range]))' 7d
```
Tell the publisher's problems from ipx's. A 404, 410, DNS failure or 503 from the feed's own
server is the publisher (worth saying, since the feed may have moved; one issue for a feed
that has been dead for days, not for a 503 once). A parse error on a feed that loads in a
browser, a redirect loop ipx should follow, or the same failure on many feeds at once is ipx.
3. **Server errors and slow requests.**
```
$Q metric 'sum by (method, route, status) (count_over_time({container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | status >= 500 [$range]))'
$Q metric 'topk(10, quantile_over_time(0.95, {container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | route != "" | route != "/api/events" | unwrap duration_ms [$range]) by (method, route))'
```
Any 5xx is worth a look. 401s are people signing in, not a problem unless one address is
hammering. For a slow route, find its traces (check 5) and see which span holds the time.
4. **Is the worker keeping up?** Scans should finish regularly, the queue should drain, and the
daemon should not be restarting on its own.
```
$Q metric 'sum(count_over_time({container="iPX"} |= "\"ev\":\"scan_done\"" [$range]))' 6h
$Q logs '{container="iPX"} |= "\"ev\":\"status\"" | json | line_format "{{.timestamp}} pending={{.pending}} downloaded={{.downloaded}}"' 6h
$Q logs '{container="iPX"} |= "daemon started"' 7d
```
A `daemon started` not matched by a deploy (see `git log` and the image's build time) is a
crash or an OOM kill: check `docker inspect iPX -f '{{.State.OOMKilled}} {{.RestartCount}}'`
and the lines just before it. A pending count that only grows means downloads are not
keeping up or not running.
Since 2026-10-02 the daemon sleeps until the next feed is due (at most 10 minutes) instead of
scanning every minute (#114), so expect tens of scans in 6 hours, not 360, nearly all with
`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.**
```
$Q traces '{resource.deployment.environment.name="production" && duration > 5s}'
$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.
Steps are `step` spans named by `of` (#187): an item list's `entries_in`, `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`
span. DeadAir's work on a file is a `deadair` trace (feed, enclosure, cuts) with `probe`,
`silencedetect`, `frame_bytes`, `store_plans`: seconds each, more is a file ffmpeg struggles
with. A trace with no name in `$Q traces` started in the page (#190, #192): its root is the tap
in the browser, which sends no span, so it holds the requests that tap made (API calls, the
playlist and audio of a play, position saves, the page's own report) side by side.
6. **The daemon's state, from Prometheus.** Pressure and leaks that requests and traces only
hint at.
```
$Q prom 'up{job="ipx"}'
$Q prom 'max(max_over_time(ipx_db_connections{job="ipx",state="in_use"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_db_connections_max{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_worker_queue{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_runtime_queue{job="ipx"}[$range]))' 24h
$Q prom 'max(resets(ipx_uptime_seconds{job="ipx"}[$range]))' 24h
$Q prom 'ipx_process_resident_bytes{job="ipx"} - ipx_process_resident_bytes{job="ipx"} offset $range' 24h
$Q prom 'ipx_process_open_fds{job="ipx"} - ipx_process_open_fds{job="ipx"} offset $range' 24h
$Q prom 'ipx_runtime_tasks{job="ipx"} - ipx_runtime_tasks{job="ipx"} offset $range' 24h
$Q prom 'max(max_over_time(ipx_worker_job_seconds{job="ipx"}[$range])) by (kind)' 24h
$Q prom 'max(max_over_time(ipx_last_scan_age_seconds{job="ipx"}[$range]))' 24h
$Q prom 'sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$range]))' 24h
$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
```
`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
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
is a leak (a file or socket not closed, a task never ending); a step after a deploy is not. A
reset of the uptime is a restart: match it with a deploy, or it is a crash (check 4).
The worker: one job over 30 minutes (`ipx_worker_job_seconds`), or a scan last finished over
15 minutes ago, is the worker stuck, which the healthcheck does not notice; its trace and the
log lines of that time say on what. A kind whose share of `ipx_job_seconds_total` jumps is
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.
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
```
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
`stall`, is worth a trace: was it the server, Cloudflare, or the network.
Also check the container itself, since Loki cannot see a daemon that is not running:
```sh
docker ps --filter name=iPX --format '{{.Status}}'
docker logs --since 10m iPX 2>&1 | tail -5
```
`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.
## What to do with what you find
Follow the repository's rules in CLAUDE.md: **every problem found gets a Gitea issue**, with a
closed stdin and a timeout on `tea`. Before filing, list the open issues and do not file one
twice; comment on the existing issue with the new evidence instead.
```sh
R="--login git.sdf1.net --repo rays/ipx"
t() { timeout 30 /src/tea "$@" < /dev/null; }
t issues list $R --state open
t issues create $R -t "<what is wrong, as the user would notice it>" -L bug -d "<what, where, since when, how often, the query or trace id that shows it>"
```
Put in each issue what would let someone pick it up cold: the LogQL or TraceQL that shows it, a
trace id, the first time it was seen and how often. A publisher's dead feed is worth one issue
saying so (the user may want to unsubscribe or find its new address); a single 503 is not.
Finish with a short report to the user: what is healthy, what is wrong (with the issue numbers),
and anything you could not tell from logs and traces alone. Do not fix things unless asked; the
check is for finding them.
## When the checks come back empty
Check that there is data before concluding all is well: `$Q metric 'sum(count_over_time({container="iPX"} [1h]))' 1h`
should be in the hundreds or more. Nothing at all means Alloy is not shipping (it can take a few
minutes to pick up a container after a deploy), or the container is down.

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Ask production's Loki, Tempo or Prometheus a question, from anywhere that can run docker on Tower.
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 trace <trace id> one trace's spans, as a tree
query.py prom '<PromQL query>' [since] one value per series, now; $range is `since`
`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
NET = "monitoring_default"
def fetch(url):
out = subprocess.run(["docker", "run", "--rm", "--network", NET, "alpine", "wget", "-qO-", url],
capture_output=True, text=True, timeout=120)
if out.returncode:
sys.exit(f"query failed: {out.stderr.strip() or out.stdout.strip()}\n{url}")
return json.loads(out.stdout)
def seconds(since):
return int(since[:-1]) * {"m": 60, "h": 3600, "d": 86400}[since[-1]]
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
kind, q = sys.argv[1], sys.argv[2]
since = sys.argv[3] if len(sys.argv) > 3 else "24h"
now = time.time()
start = now - seconds(since)
enc = urllib.parse.quote
if kind == "logs":
r = fetch(f"http://loki:3100/loki/api/v1/query_range?query={enc(q)}&limit=500"
f"&start={int(start * 1e9)}&end={int(now * 1e9)}&direction=backward")
lines = [(ts, line) for s in r["data"]["result"] for ts, line in s["values"]]
for ts, line in sorted(lines, reverse=True):
print(line)
print(f"-- {len(lines)} line(s){' (limit reached)' if len(lines) >= 500 else ''}", file=sys.stderr)
elif kind == "metric":
r = fetch(f"http://loki:3100/loki/api/v1/query?query={enc(q.replace('$range', since))}&time={int(now * 1e9)}")
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
for s in rows:
labels = {k: v for k, v in s["metric"].items() if k not in ("container", "compose_project", "service_name")}
print(f"{s['value'][1]:>12} {json.dumps(labels) if labels else ''}")
print(f"-- {len(rows)} series", file=sys.stderr)
elif kind == "traces":
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=100")
traces = sorted(r.get("traces", []), key=lambda t: -t.get("durationMs", 0))
for t in traces:
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(int(t["startTimeUnixNano"]) / 1e9))
# No root name: its root span is a tap in the page, which the browser never sends.
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 == "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}")
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
for s in rows:
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 == "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"]]
kids = {}
for sp in spans:
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)
def show(sp, depth):
ms = (int(sp["endTimeUnixNano"]) - int(sp["startTimeUnixNano"])) / 1e6
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])
if not a["key"].startswith(("code.", "thread.")) and a["key"] not in ("busy_ns", "idle_ns", "target")}
err = " ERROR" if sp.get("status", {}).get("code") in (2, "STATUS_CODE_ERROR") else ""
print(f"{' ' * depth}{sp['name']} {ms:.0f}ms{err} {json.dumps(attrs) if attrs else ''}")
for e in sp.get("events", []):
msg = next((a["value"].get("stringValue") for a in e.get("attributes", []) if a["key"] == "message"), e.get("name"))
# A scan logs a skip for every feed not due; they bury what happened.
if '"ev":"feed_skip"' in (msg or ""):
continue
print(f"{' ' * depth} - {msg}")
for k in sorted(kids.get(sp["spanId"], []), key=lambda s: int(s["startTimeUnixNano"])):
show(k, depth + 1)
ids = {sp["spanId"] for sp in spans}
for root in [sp for sp in spans if sp.get("parentSpanId", "") not in ids]:
show(root, 0)
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()

3
.gitignore vendored
View File

@@ -3,3 +3,6 @@
/test-results
/playwright-report
/web/dist
.claude/settings.local.json
__pycache__/

View File

@@ -7,6 +7,268 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.12.0] - 2026-10-11
### Added
- With `[web] metrics_bind` set, ipx serves Prometheus metrics on a port and thread of their own: the database pool, the worker's job and queue, jobs done and their time, scans, downloads and requests in progress, download speed, live-update listeners and missed updates, DeadAir's backlog, the artwork cache's fill, hit rate and evictions, memory, open files and the library's counts. The iPX dashboard graphs them in a Daemon row (#194, #195, #198, #199).
- The page reports its errors, the player's, how long play takes to start and how long it stalls to the server's log, and the requests one tap sets off share a trace, the playlist, audio and saved place of what it played included (#190, #192).
- Saving an episode from a show with DeadAir on saves it without its dead air, at your breathing room (#186).
- Choose the pause DeadAir leaves where it cuts a silence, from 0.2 to 0.5 seconds, for each show in its settings, as its breathing room, on a slider from brisk to relaxed. It is 0.3 seconds unless you change it, up from 0.2, which made speech sound rushed (#182, #183).
### Changed
- DeadAir no longer makes a shortened copy of each episode. It plays the original with its long silences left out, ready a few seconds after the download, with no gap where a silence was cut and no extra disk space; the copies already made are removed (#179, #181, #196).
- DeadAir is in the same box as a show's download settings, below them, and stays there when automatic downloads are off (#185).
- The container is built on Debian 13 (trixie) instead of 12, so DeadAir uses ffmpeg 7.1 instead of 5.1 (#177).
- On Postgres, ipx keeps up to 20 connections to the database instead of 10, for the bursts of a page load asking for many covers at once (#200).
- Traces of the disk sweep, episode lists and DeadAir's work on each file show each step, and the sweep's trace says what it removed (#187).
### Fixed
- All Subscriptions, and every item list, comes up faster, most of all the first time after a quiet spell: it took half a second to two (#188).
- Checking for new episodes no longer waits up to 12 seconds each time on a clear-out of dead feeds from the Directory, which now runs once a day (#193).
- A page left open no longer misses updates after a check for new episodes: the note that each feed not due was skipped, one per feed, filled the queue they share (#197).
### Security
- Cloudflare no longer keeps a copy of the audio you play, which it served to anyone it let in without checking they subscribe (#189).
## [0.11.0] - 2026-10-09
### Added
- DeadAir: turn it on for a show in its settings, or with the button beside the speed while it plays, and its episodes play with long silences shortened, so podcasts finish sooner without sounding sped up. Your place carries over when you turn it on or off, each episode says how much shorter it is ("Cutting dead air…" until it is done), and Settings shows the time it has saved you. It is off for every show until you turn it on (#171, #172, #173, #174, #176).
### Fixed
- A downloaded file's play, save, share and delete buttons stay on one row in the Files pane, instead of delete wrapping underneath (#175).
- Cover art up to 20 MB is shown, instead of nothing for anything over 5 MB, as for five of the Glass Cannon Network's feeds.
- Seeking in an episode while many people listen answers in about 10 ms instead of 40, and files stream about four times as fast: each connection now sends what is ready without waiting for the browser to acknowledge the last part.
- Artwork that could not be fetched is not fetched again for an hour, instead of on every page load, and the server's log says which image it was and why.
## [0.10.3] - 2026-10-09
### Changed
- Swiping past the last item shows the theme's accent colour, without the words "No more items".
- An empty list and the reader with nothing picked say nothing, instead of "Nothing here yet — try Scan now." and "Pick an item to read it."; a search that finds nothing still says so.
- In Settings, the API tokens sit in a box of their own.
- A feed's settings are redesigned: downloading on its own is a switch with what depends on it beneath, how many is Default, Newest or All shown on a strip of the show's own episodes, words to pick or hide are chips, the feed's address is at the top with a copy button, and Save is always in reach. Its folder and Directory category are no longer in it.
- The feed list's traces show each of its database queries, so it can be told where its time goes.
- Saving a feed's settings asks the database for your subscription once, not twice.
- `ipx add` and Add feed in the page add a feed the same way, through one piece of code.
- A pinned item or feed's pin button is filled with the theme's accent, in the reader, on the feed's page and in the toolbar, so pinned no longer looks like not pinned.
- A feed's page in the Directory says how many people here subscribe only when someone does, without "Nobody here subscribes yet".
### Fixed
- The unread counts catch up when you come back to the page: an item read on another device, or while the phone app was in the background, no longer stays counted as unread until a reload.
- A feed set to download 0 of its newest items downloads all of them, the whole back catalogue, as the server's 0 does; it downloaded nothing.
- The feed list loads faster: it counts the items and files of your own feeds, not of every feed on the server, and asks for a group's title once, not once for every feed in it.
- Downloading or deleting a file the server no longer has answers "not found" instead of a server error.
- A feed's page in the Directory, and subscribing from it, no longer wait while the whole Directory is put together to check that the one feed is in it.
- An episode that came as a torrent of several files plays its largest file, and can be deleted, by hand or to free space, instead of staying on disk for good.
- Removing an account takes the feeds only that person read off the server, as unsubscribing does, instead of leaving them downloading every new episode for no one.
- Freeing disk space counts only the people who subscribe to a feed: an episode is no longer taken as heard by everyone because someone who has since unsubscribed heard it, and their pins no longer keep files for good.
- An episode whose file turns out to be a torrent shows as downloaded once it is, instead of being fetched again on every check of its feed, and no longer holds up the checking of other feeds while it downloads and seeds.
- Downloading the latest 65 to 100 items of a feed queues them at once, instead of hanging until most had downloaded and holding up adding feeds and Refresh meanwhile.
- Changes to the feed list made at the same moment, such as two people adding feeds, or a feed added while the server settings change, no longer undo one another.
- A download that stops partway is given up after two minutes without data, instead of holding up every scan and download until a restart.
- On a phone, the closed feed list no longer shades the left edge of the page with its shadow.
- On an iPhone, the top bar's colour runs all the way up behind the status bar and the Dynamic Island, with no band of another colour above it. In Glass the bar is solid on a phone, in the colour its glass showed.
- A feed's buttons stay on one row on a narrow phone, and the reader's buttons stay together instead of breaking across lines under a long feed name.
- A moment's DNS failure no longer marks a feed as failing for a day or calls it gone: iPX tries it again in ten minutes. One failed lookup one morning left 205 podcasts unread, uncategorised and labelled gone until the next day. A name that does not resolve is said to be maybe gone, and only once it has been failing a day, on its Directory page as on its own.
## [0.10.2] - 2026-10-05
### Fixed
- A publisher's correction to an item already in iPX, a retitled episode, mended text, new artwork or a fixed length, arrives on the next read. Since 0.9.1 an item kept what it first said: Ain't It Cool News's items each showed the text of the one before, long after the feed was fixed.
- A feed's page in the Directory says when the feed is failing, and why, and no longer says nobody here subscribes to a feed someone does.
## [0.10.1] - 2026-10-05
### Fixed
- A feed whose address answers with nothing, as a lapsed domain does behind a DNS filter's block page, shows as failing, with what to do about it, instead of as a show that has not posted yet.
- The search box finds episodes in Currently Listening. It did nothing there.
- `ipx add`, `ipx rm` and `ipx import`, run while the daemon runs, reach it at once. It kept its old list of feeds and wrote it back at its next change, undoing them.
- On SQLite, several requests are answered at once instead of one at a time. With 25 people browsing, a page of a feed's items comes back in 10ms rather than 130.
- On Postgres, a write no longer waits for the database server's disk. A scan read about one feed a second and now reads 1,500 in 20 seconds.
- Checking every feed no longer makes every icon in the feed list flash while it runs: the list keeps the icons it has already drawn.
### Security
- A flood of sign-in attempts no longer stalls the site for everyone else. Passwords are checked a few at a time, away from the threads that answer every other request; forty wrong passwords at a time made everything else take four seconds.
- A wrong password is refused in the same time whether or not the name is an account here, so how long it takes no longer tells anyone which names are.
## [0.10.0] - 2026-10-05
### Added
- API tokens: in Settings, make a named token that lets a script or an agent use iPX as you, sent as `Authorization: Bearer`, and revoke it there. Each shows when it was last used.
- In the Directory, a feed you do not subscribe to opens a page of its own: its cover, its category, how many people here subscribe, what the feed says it is, and its latest episodes or posts, with a button to subscribe. Before, only its subscribe button did anything.
- Feeds that don't set a category, as most blogs don't, now get one automatically, so they show up under a category in the Directory. iPX picks the closest of Apple's podcast categories from the feed's title and latest headlines. This happens once per feed: the first time a new feed is read, or for a feed you already had, the next time you force a refresh. It needs `TYPESAFE_KEY` set. An admin can change the category in the feed's settings.
- Artwork is kept on disk once shown and loads from iPX, not from each publisher's server: fast after the first time, and still there when the publisher's server is not. The admin page sets how much is kept (500 MB by default).
- `ipx add --list --category News <url>` puts a feed in the Directory for anyone to subscribe to,
and it stays there when its last subscriber leaves. Run for a feed already in the catalogue,
it lists it or sets its category.
- Feeds nobody subscribes to that are dead for a month or quiet for a year are removed, so the
Directory lists feeds worth taking.
### Changed
- The Directory is laid out as Apple's is. It opens on the ten most subscribed feeds, ranked, and the categories as tiles showing some of their shows' covers. A category opens a page of its own with its most subscribed feeds, its subcategories (Tech News under News, Video Games under Leisure) and its feeds; See all lists every one, A to Z, Z to A or by most subscribers. This replaces the grid of every feed under rows of category buttons, and Popular, which is gone from the feed list with its key, g p.
- A pinned item in a list wears the same disc as a pinned feed, in the theme's accent, instead of a plain solid pin.
- An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text.
- A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing.
- The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once.
- A pinned feed's pin sits on the corner of its artwork, as a failing feed's mark does, instead of before its name.
- The Directory lists feeds nobody subscribes to yet; its most subscribed still lists what people subscribe to.
A feed nobody subscribes to is checked once a day, and at once when someone subscribes. A
listed feed downloads nothing until someone subscribes.
- The Directory loads faster: it asked the database three questions per feed.
### Fixed
- A database on SQLite, the default, no longer waits for the disk after nearly every write. On a slow disk a scan of 1,500 feeds took most of an hour and now takes half a minute. Postgres is unaffected.
- The search box finds a feed in the Directory by name. It did nothing there.
- Feeds that file themselves under Apple's Football or Soccer show under Sports, and those under the old Software How-To under Technology, instead of as categories of their own.
- The download queue, in `ipx status`, `/api/status` and the dashboard, counts only what iPX will download on its own: older episodes beyond a show's limit, and files of feeds that do not download automatically, are listed but no longer counted as waiting.
- Artwork published on http is stored on https when its host serves it there, so the page loads it directly; the rest still comes through iPX.
- An iPhone adding the site to its home screen finds the icon at the first address it tries.
- A feed whose server hangs no longer holds up every scan: a feed gets 30 seconds, and connecting anywhere 10.
### Security
- A feed's items and files reach only the people who subscribe to it. Anyone signed in could read any feed's items, a paid feed's included, with the addresses of its files, which can carry the subscriber's key, and could play, download or delete its files.
## [0.9.1] - 2026-09-29
### Added
- `IPX_LOG_FORMAT=json` logs one JSON object a line, for Loki and the like: a request's method,
route, status and `duration_ms`, a scan's or download's details, a failure's kind in `error.type`
(and its HTTP status in `http.response.status_code`), and each line's `trace_id` and `span_id`.
- A Grafana dashboard for iPX, from its log in Loki and its traces in Tempo (`grafana/dashboard.py`).
- With `OTEL_EXPORTER_OTLP_ENDPOINT` set, the daemon sends traces of its scans, downloads and web
requests to a collector such as Tempo; a scan's shows the time each feed spends fetching, on its
artwork and in the database.
- A feed with no artwork of its own, or whose artwork is missing, shows its website's icon, or the
site's `/favicon.ico`, and follows the site when it changes. It is asked for again only when the
artwork changes or you refresh the feed.
- The refresh button turns while its feed is being checked, and the check-every-feed buttons
while any of yours is.
- Adding a website's address, with or without `https://`, subscribes to the feed that site links.
An address is checked before it is added, and anything that is neither a feed nor a page linking
one is refused with the reason, instead of being added and failing on every check.
- `/api/status` gives the number of feeds, items waiting to download and files downloaded, and
the version, for a dashboard such as Homepage.
### Changed
- The start-up line about the web UI being reachable off the machine says what guards it, and is a warning only when there is no Cloudflare Access in front of it.
- The feed list updates just the feed that changed, as a scan checks it or you read an item, instead of reloading the whole list every minute.
- A scan fetches several feeds at once, so a refresh no longer waits on every site in turn.
- A feed that was failing when its last subscriber left is forgotten, rather than kept with its error for good. One that worked, or has files on disk, is kept as before.
- A feed that keeps failing is checked less and less often, waiting as long as it has been failing, up to once a day; it goes back to its schedule as soon as it works. Refreshing it still checks it at once.
- `ipx list` shows each feed's id on a line of its own, labelled, under its title.
- Each browser keeps its own theme, so a phone and a desktop can differ. A browser that has not
chosen one yet starts from the theme your account had.
- The Modern theme takes its colours from the new logo: its navy, the blue of its bars and the
orange of its needle.
- The logo is in the top bar beside the add-feed button, in place of the name at the top of the
feed list, and left out on a phone, for the search box. It follows the page, dark in a dark theme
and light in a light one, as the browser tab's icon does, and hovering it shows iPX's version,
which says so between releases: 0.9.1-dev, not 0.9.0.
- The logo's needle is a brighter, redder orange that stands apart from the blue bars.
### Removed
- `ipx copy-db`, the one-off move from SQLite to Postgres. Going back needs only the old `state.db`.
- Starting up no longer looks for files downloaded twice before 0.6.0 read WordPress's player
links correctly; that clean-up has run.
### Fixed
- A limit on new downloads per check means a show's newest episodes: with it set to 3, ipx no longer works back through the show's history three at a time. Set to 0, it still takes the whole back catalogue.
- With no limit on new downloads per check (`max_new_per_check = 0`), downloads work on Postgres; they failed.
- Checking a feed with a long history is much quicker: items already stored are no longer written again on every check.
- The feed list loads several times faster: it was asking the database six questions per feed.
- Artwork a feed offers only over plain http shows on the https site too.
- A failing feed is easy to spot in any theme: a mark on its artwork, what is wrong in place of
its counts, and its artwork greyed out once it has failed for a day.
- The log has no terminal colour codes when it is not going to a terminal, as in `docker logs`.
- The read button shows whether an item is read, as the pin beside it shows whether it is
pinned: a tick when read, an envelope when not. It used to show the opposite.
- In Directory and Popular, Subscribe is a plus again, not the tick that marks a feed you have.
- A feed's refresh button looks like the buttons beside it, instead of standing out filled in.
- Refreshing reads your feeds in full, instead of only asking each one whether it changed, so
a feed that has not changed is still read again.
- Pulling the item list down to check for new items shows a spinner for a couple of seconds, and
a second pull meanwhile does nothing, instead of no sign at all that the check started.
- Settings no longer lists the server's download folder, which only an admin can change, on
the admin page.
- On the Unread tab, a swipe back (or k) goes to the item you just read, instead of past it or
back to the list. The items read on the way leave the Unread tab once you close the reader.
## [0.9.0] - 2026-09-28
### Added
- A new logo, on every page, in the browser tab and on the home screen: the old radio's screen,
its tuning scale and orange needle, drawn flat to Apple's app icon guidelines, with a dark
version. The 2004 icon is still at /icon.png.
- Words that hide items. Settings has a list for every feed you read, and each feed's settings a
list of its own; an item with one of those words or phrases in its title or text is hidden
from you and is not downloaded on your account. Other people's lists never hide anything from
you.
- Share buttons for a feed, an item and a file. On a phone they open the system's share sheet;
elsewhere they copy the link. A link that looks like it carries your own access to a paid
feed asks before it goes anywhere.
### Changed
- iPodderX is now called iPX, on every page and in the tab. The repository is rays/ipx.
- On a phone, iPodderX looks like an iOS app: filled round buttons and capsules instead of
outlined boxes, no rules between the bars and the page, the feed's name as a large title, a
segmented control for the tabs, taller rows with 17px text, and dialogs as sheets from the
bottom. Tapping a text box no longer zooms the page in. High contrast, and Increase Contrast,
keep their outlines.
- The Glass theme's panels catch the light: a bright rim along the top and left edges, a fainter
one along the far edges, and a soft sheen from the top-left corner.
### Fixed
- A new tab or home-screen icon shows up as soon as it is deployed, instead of a day later.
- Opened from the home screen on an iPhone, the top bar, the reader's back button and the top
of the feed list keep clear of the Dynamic Island and the status bar instead of sitting under
them.
- Swiping between items on a phone takes a quarter of the screen, so a diagonal scroll no longer
jumps ahead by accident, and no longer shows the item list through the reader: the next or
previous item sits beside the one you are reading and comes across with it, following your
finger, the end of the list says so, and swiping right from the first item draws the reader off
the list, dimmed until it is uncovered. A swipe from the screen's left edge stays with the page,
rather than Safari taking it as Back.
## [0.8.4] - 2026-09-19
### Added
- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It
goes solid when the system asks for less transparency or more contrast.
- The page can hand playback to a native app. Opened inside an iOS or Android shell, an episode
plays through the host's own player instead of the page's, so it keeps going when the screen
locks and the car can control it; the player bar, the row buttons and the keyboard shortcuts
work as they always did. Video still plays in the page. In a browser nothing changes.
### Fixed
- On a phone, the bottom bar keeps clear of the home indicator, so the seek bar and the times are
no longer cut off when the page has the whole screen: in a native shell, or added to the iOS
home screen. In landscape the bars keep clear of the notch as well.
## [0.8.3] - 2026-09-19
### Security
@@ -95,6 +357,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Touch gestures: pull the item list down from its top to check the feed for new items, and
swipe the item you are reading left for the next one and right for the one before, or back
to the list from the first.
- A favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on their own,
and on white for an iPhone's home screen.
### Changed
@@ -109,17 +373,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- On a phone, an item's files, with play and delete, sit above its show notes rather than below
them, where long notes left them looking missing.
- On a phone, an item with no files goes straight to its text, without a box saying "No files".
- The page is served minified, about a quarter smaller. Its script is now TypeScript in
`web/src`, type-checked, and built with swc; building ipx needs node.
- The script is its own file, `/app.js`, rather than inside the page. Your browser keeps it
between visits and fetches it again only when an update changes it.
- The page is served minified, about a quarter smaller, with its script in a file of its own,
`/app.js`, which your browser keeps between visits and fetches again only when an update changes
it. The script is TypeScript in `web/src`, type-checked and built with swc; building ipx needs
node.
### Fixed
- Switching tabs straight after marking everything read no longer shows the previous tab's
items: of two lists asked for at once, only the later one is shown.
- ipx has a favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on
their own, and on white for an iPhone's home screen.
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
higher than a downloaded one's.
- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's,
@@ -130,8 +392,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
unread dot back until the next refresh.
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next
one, rather than a few read items lingering until a refresh cleared them.
- The Log button no longer shows for a moment on every load for anyone but an admin; the server
leaves it out of their page.
- An image or link in a post given relative to the post, such as The Observation Deck's, now
points at the post's site rather than at ipx, and shows.
@@ -201,10 +461,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
connection", now shows that message and is flagged as the publisher's problem, instead of two
parser errors about reaching the end of input.
### Removed
- An unused icon glyph (`minus`) left over from before Unsubscribe settled on `circleMinus`.
### Fixed
- Add a feed opened over Directory or Popular now shows its Popular list instead of staying on
@@ -373,13 +629,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A Classic theme after the 2004 Mac app, beside Dark and Light: brushed-metal toolbar, Aqua
blue selection, red unread badges, a striped table and Lucida Grande. The theme button steps
through all three and remembers the choice.
- A toolbar across the top, after the original iPodderX: add and unsubscribe, play, mark read
and keep for the selected item, scan, a search box for what is showing, and Settings and Log.
- A toolbar across the top, after the original iPodderX: add, unsubscribe and scan; play, mark
read and keep for the selected item, showing its state; a search box for what is showing; and
Settings and Log.
- Directory, Popular and All Subscriptions at the top of the feed list, opening in the main pane.
Directory lists every feed anyone here subscribes to, A to Z (`GET /api/directory`). All
Subscriptions lists every item from every feed you subscribe to (`GET /api/entries`).
- Items show as a table (unread, kept, title, feed, file, size, published) with a Files pane beside
it, and a status bar with the totals.
it, and a status bar with the totals. A file's type is an icon (audio, video, image, PDF, torrent,
other), green once it is downloaded and red when the download failed, with the details in its
tooltip; its size is in KB for small files instead of "0 MB".
- Mark everything read from All Subscriptions, across every feed you subscribe to
(`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header.
- Click a column heading in the item table to sort by it (kept, title, feed, file type, size,
@@ -394,28 +653,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
items show without pressing Scan.
- A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped"
label, just the Download button. The retention summary says "deleted", not "reaped".
- Buttons are icons, with the words in their tooltips: the Files pane (save, delete, view,
download), an item's own buttons (mark read, keep, open the original), the feed header (scan,
download latest, mark all read, settings, unsubscribe), and the Settings, feed settings and
Download latest dialogs (save, download, cancel). The icons are Font Awesome Free, embedded as
SVG: only the ones used, no font to download, and nothing fetched from anyone else. They
replace font characters such as ⟳ ⤓ ↗, which came out thin and tiny and differed from font to
font. Keep is a flag everywhere, as it was in the original, and mark unread is an envelope.
- 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. The feed header's unsubscribe was an x
and read as closing the page. The remaining word buttons are icons too:
- Log, Add feed, Users, Unsubscribe and OPML.
- Popular's Subscribe, Copy and Sign out.
- The Subscribed label in Popular, the Directory and Add feed, which is now a green check.
- The player's back, play, forward and close, which were font characters, and the folder arrow.
- The toolbar's read and keep buttons show the selected item's state, with the same icons as the
item's own buttons. Play, read and keep sit together, and Scan sits with add and unsubscribe.
- Buttons are icons, with the words in their tooltips, one meaning per icon: minus unsubscribes,
x closes or cancels, plus adds or subscribes, a dialog's confirm button carries the icon of what
it does, Keep is a flag, as it was in the original, mark unread is an envelope, and Subscribed is
a green check. The icons are Font Awesome Free, embedded as SVG: only the ones used, no font to
download, and nothing fetched from anyone else. They replace words and font characters such as
⟳ ⤓ ↗, which came out thin and tiny and differed from font to font, and the feed header's x for
unsubscribe, which read as closing the page.
- An OPML subscription's page has the same header as a feed's, with its buttons in the same places.
- The item table's size has its own column, apart from the file's type, and shows KB for small
files instead of "0 MB". The Item heading is now Title.
- A file's type is an icon (audio, video, image, PDF, torrent, other), green once it is
downloaded and red when the download failed, with the details in its tooltip. One icon per
row keeps the column lined up. The DOWNLOADED and PENDING labels are gone.
### Fixed
@@ -466,11 +711,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Importing another account's OPML export subscribed nobody and reported "Imported 0 feed(s)".
Feeds it added had no subscriber, so they were never scanned.
- Importing something that is not OPML answered `500`. It is now `400` "that is not an OPML file",
refused before anything changes.
- Starring stopped protecting a file from the quota and age sweeps once read state became per-user.
### Security
@@ -484,7 +726,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Web UI served by the daemon: plain HTML and JS compiled into the binary, with feeds, items,
filters, search, sanitised show notes and live progress over SSE.
filters, search, sanitised show notes and live progress over SSE. Opening an item marks it
read; playing it marks it read only at the end or past 90%.
- Player bar with resume, speed, keyboard shortcuts and lock-screen controls.
- Three-pane layout: feeds beside, items above, and the selected item's text and files below.
- Phone layout.
@@ -492,7 +735,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
trusted proxy header for Cloudflare Zero Trust or Authentik (`docs/sso.md`).
- Subscribing to an OPML: it is re-read every scan and its feeds show as a folder. A feed dropped
from it is removed unless it has downloads.
- Scheduling: a global interval with per-feed overrides (`every 30m`, `4h`, `1d`, `2w`).
- Scheduling: a global interval with per-feed overrides (`every 30m`, `4h`, `1d`, `2w`), set by an
admin, as are the global settings.
- `[general] media_types`, default audio and video, and `max_new_per_check`, default 3.
- Every enclosure of an item, a View link for files that are not audio or video, and per-item
artwork.
@@ -505,33 +749,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Global settings and scan schedules are admin-only. The per-feed schedule picker is gone.
- Feeds from an OPML live in the database, not `config.toml`.
- Opening an item marks it read. Playing it marks it read only at the end or past 90%.
- "Episodes" became "items", since half the library is text.
- A burst of scan events causes one refresh, not one per feed.
- All is the default filter. OPML import and export, Settings and Log moved out of the header.
- Torrents run detached, two at a time.
### Fixed
- Download fetched the next queued episodes instead of the one clicked.
- Pressing play made an item vanish from the Unread list.
- Clearing a folder, schedule or cap from the UI did nothing.
- The daemon ignored SIGTERM until the current download finished.
- A missing function stopped the page script and left the whole UI dead.
- One download painted progress on every pending row.
- A torrent could freeze scanning for up to an hour.
- Downloading from an OPML feed failed with "belongs to unsubscribed feed".
- Image enclosures were downloaded, counted as episodes and given a play button.
- Image enclosures were downloaded as episodes.
- A feed whose entries had been deleted stayed empty, because the server kept answering `304`.
- An item with several enclosures kept only the last.
- OPML folders rendered open by default.
- Mark read in the text pane recursed until the stack overflowed.
- The Unread, Downloaded and Flagged filters answered `500` without a search term.
- An OPML subscription always showed 0 unread.
- Folder names kept doubled spaces where separators were stripped.
- Sidebar rows had four different left edges.
### Security
@@ -555,21 +782,30 @@ 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/ipodderx-rs/compare/v0.6.1...main
[0.8.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.2...v0.8.3
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3
[0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2
[0.5.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...v0.5.1
[0.5.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.4.0...v0.5.0
[0.4.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.3.0...v0.4.0
[0.3.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.2.0...v0.3.0
[0.2.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.sdf1.net/rays/ipodderx-rs/releases/tag/v0.1.0
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...main
[0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0
[0.11.0]: https://git.sdf1.net/rays/ipx/compare/v0.10.3...v0.11.0
[0.10.3]: https://git.sdf1.net/rays/ipx/compare/v0.10.2...v0.10.3
[0.10.2]: https://git.sdf1.net/rays/ipx/compare/v0.10.1...v0.10.2
[0.10.1]: https://git.sdf1.net/rays/ipx/compare/v0.10.0...v0.10.1
[0.10.0]: https://git.sdf1.net/rays/ipx/compare/v0.9.1...v0.10.0
[0.9.1]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...v0.9.1
[0.9.0]: https://git.sdf1.net/rays/ipx/compare/v0.8.4...v0.9.0
[0.8.4]: https://git.sdf1.net/rays/ipx/compare/v0.8.3...v0.8.4
[0.8.3]: https://git.sdf1.net/rays/ipx/compare/v0.8.2...v0.8.3
[0.8.2]: https://git.sdf1.net/rays/ipx/compare/v0.8.1...v0.8.2
[0.8.1]: https://git.sdf1.net/rays/ipx/compare/v0.8.0...v0.8.1
[0.8.0]: https://git.sdf1.net/rays/ipx/compare/v0.7.0...v0.8.0
[0.7.0]: https://git.sdf1.net/rays/ipx/compare/v0.6.1...v0.7.0
[0.6.1]: https://git.sdf1.net/rays/ipx/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.sdf1.net/rays/ipx/compare/v0.5.5...v0.6.0
[0.5.5]: https://git.sdf1.net/rays/ipx/compare/v0.5.4...v0.5.5
[0.5.4]: https://git.sdf1.net/rays/ipx/compare/v0.5.3...v0.5.4
[0.5.3]: https://git.sdf1.net/rays/ipx/compare/v0.5.2...v0.5.3
[0.5.2]: https://git.sdf1.net/rays/ipx/compare/v0.5.1...v0.5.2
[0.5.1]: https://git.sdf1.net/rays/ipx/compare/v0.5.0...v0.5.1
[0.5.0]: https://git.sdf1.net/rays/ipx/compare/v0.4.0...v0.5.0
[0.4.0]: https://git.sdf1.net/rays/ipx/compare/v0.3.0...v0.4.0
[0.3.0]: https://git.sdf1.net/rays/ipx/compare/v0.2.0...v0.3.0
[0.2.0]: https://git.sdf1.net/rays/ipx/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.sdf1.net/rays/ipx/releases/tag/v0.1.0

119
CLAUDE.md
View File

@@ -1,4 +1,4 @@
# Working on ipodderx-rs
# Working on ipx
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
thing is built; this file is about working on it without repeating mistakes that have already been
@@ -6,45 +6,65 @@ made here.
## Where things are
Production is the `iPodderX` container on Tower (192.168.1.130), the `ipodderx` service of the
Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
| | Host | In the container |
|---|---|---|
| Image | `192.168.1.130:5000/ipodderx:latest` | |
| Config | `/mnt/fast/appdata/ipodderx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
| 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 `ipodderx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipodderx.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/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
| Image | `192.168.1.130:5000/ipx:latest` | |
| Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
| 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/content/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` |
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once the fix is on
`main` and pushed, comment on the issue with what changed and the commit, then close it:
```sh
R="--login git.sdf1.net --repo rays/ipx"
t() { timeout 30 /src/tea "$@" < /dev/null; }
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
t issues close $R 40
```
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
for later stays open, and that is how it gets picked up again.
Deploying a change is: build and push the image, then pull it and recreate the container.
```sh
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx
docker logs --tail 20 iPodderX
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipx
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipx
docker logs --tail 20 iPX
```
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
included. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
**A build that fails with `429 Too Many Requests` on a base image** is Docker Hub rate-limiting
this host. There is no Docker Hub login here, and the build asks about `debian:bookworm-slim` and
`rust:1-slim-bookworm` every time unless they are already stored locally. Pull them from Google's
this host. There is no Docker Hub login here, and the build asks about `debian:trixie-slim` and
`rust:1-slim-trixie` every time unless they are already stored locally. Pull them from Google's
mirror and tag them; the build then uses the local copies without asking Docker Hub:
```sh
docker pull mirror.gcr.io/library/debian:bookworm-slim
docker tag mirror.gcr.io/library/debian:bookworm-slim debian:bookworm-slim
docker pull mirror.gcr.io/library/rust:1-slim-bookworm
docker tag mirror.gcr.io/library/rust:1-slim-bookworm rust:1-slim-bookworm
docker pull mirror.gcr.io/library/debian:trixie-slim
docker tag mirror.gcr.io/library/debian:trixie-slim debian:trixie-slim
docker pull mirror.gcr.io/library/rust:1-slim-trixie
docker tag mirror.gcr.io/library/rust:1-slim-trixie rust:1-slim-trixie
```
Run those again now and then, or the local copies go stale.
@@ -57,8 +77,10 @@ container restarts on its own after a reboot.
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches
the shell running the command and kills the session (exit 144). This has happened more than once.
a daemon by hand for testing, **stop it by its own PID**: start it with `& echo $! > pid` and
`kill $(cat pid)`. Never `pkill -x ipx`: Tower sees the container's processes, so it kills
production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell
running the command and kills the session (exit 144), which has happened more than once.
## Before you touch the page
@@ -100,8 +122,10 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
npx tsc -p . # type-checks web/src
node tests/page-smoke.js
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 # 40 browser tests against a real daemon on fixture feeds
npx playwright test # 66 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
```
Things about the browser suite that have cost time:
@@ -118,6 +142,31 @@ Things about the browser suite that have cost time:
* `webServer` starts **before** `globalSetup`, which is why the fixture config is written at
config-load time instead.
The load tests (`tests/load`, issue #133) are for what one browser cannot show: twenty-five people
browsing at once, a hundred players saving positions while a scan writes, fifty listeners
seeking through files, a flood of wrong passwords. Things about them:
* They need **k6**, which `/src/install.sh` installs from k6's own signed apt repository, and they
build and run a **release** binary: Argon2 in a debug build takes about a second a sign-in,
which would measure the build.
* `run.js` serves the feeds itself, generated, and starts **its own daemon** under
`/tmp/ipx-load`, wiped each run, on ports 8793 and 8794, so it can run beside the browser suite.
It stops that daemon by its PID. People sign in by the `X-Load-User` header, trusted from
127.0.0.1, as production trusts Cloudflare Access's; only the sign-in test uses a password.
* Each threshold is a budget well above what the request takes now: it is there to catch a query
that has started asking once per feed, or writers queueing on a lock, not a slow minute.
* `ipx status`, the Docker healthcheck, runs every second throughout, and a run fails if one takes
the healthcheck's 5s.
* One at a time: `node tests/load/run.js signin`. All four take about six minutes.
* The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in
a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
`ipodderx_test` on the `databases` project's server, production's, as `ipodderx`, taken from
`ipx.env`; the harness refuses a database whose name does not end in `_test`. Postgres's
numbers are what production would see, and a run loads the server production's database is on,
so not while someone is using iPX in earnest. Each budget has a value for each database.
* The Rust tests run on Postgres with the same URL: `. /src/.envrc && cargo test`. Each test
makes a schema `ipxt_<pid>_<n>`, and the next run drops those an earlier one left.
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
`matches_keywords`, `parse_interval`) are the easiest place to put it.
@@ -147,6 +196,10 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
* Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons
fighting over the database, with the stale one still holding the port.
* **The Grafana dashboard reads the log's fields** (`grafana/dashboard.py`). Production logs JSON
(`IPX_LOG_FORMAT=json`); the access log's `method`, `path`, `route`, `status`, `duration_ms` and
the events' `ev`, `feed`, `new`, `bytes`, `msg` (`log_event` in ipc.rs) are what the panels query.
Rename one and its panels go blank without an error; regenerate the dashboard to match.
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
different task. `ipx status` checks the control socket and the database; to see the worker
getting through its jobs, watch for `scan complete` in the log.
@@ -155,7 +208,8 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
command, the healthcheck's `ipx status` included, so it must never write when nothing is
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you.
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you. Look for
the column first and alter only when it is missing, as `feeds.description` does.
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`
parameters, bound only if used; `ON CONFLICT`, not `INSERT OR IGNORE`; yes/no columns tested
as themselves (`NOT coalesce(s.read, false)`) and written as `true`/`false`, never compared to
@@ -174,16 +228,25 @@ say, such as what was wrong before or what it cost to find out, it goes in the c
body, where `git log` and `git blame` find it beside the change. (There was a long-form
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
compare links at the bottom of the changelog.
Cutting a release: first read `[Unreleased]` against the last release, not line by line, and rewrite
it as what changed between the two. Lines go in as the work happens, so they record the way there:
a feature added and then reworked in the same release is one line saying what it is now, under
Added; something added and taken out again before the release, or a fix for a bug no release ever
had, is not there at all; and "instead of" and "no longer" compare with the last release, not with
last week. Issue numbers stay, all of them, on the line that survives. Then rename `[Unreleased]` to
`## [X.Y.Z] - YYYY-MM-DD` and open a new empty `[Unreleased]` above it, set `version` in `Cargo.toml` to `X.Y.Z` (dropping `-dev`), tag the commit
`vX.Y.Z`, and update the compare links at the bottom of the changelog. Then, in the next commit,
set `version` to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
in the logo's tooltip instead of claiming to be the last release. The release that follows can
still be a minor or major one; `-dev` only says the work comes after `X.Y.Z`.
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
## Known gaps
* Nothing at the moment. Add one here when a limitation is known and left in place.
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
issue left open.
<!-- rtk-instructions v2 -->
# Command output

128
Cargo.lock generated
View File

@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "ipx"
version = "0.8.3"
version = "0.12.0"
dependencies = [
"ammonia",
"anyhow",
@@ -1832,6 +1832,9 @@ dependencies = [
"futures-util",
"jsonwebtoken",
"librqbit",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
"opml",
"percent-encoding",
"quick-xml 0.42.0",
@@ -1840,11 +1843,13 @@ dependencies = [
"sea-orm",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"toml",
"tower",
"tower-http 0.7.1",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"url",
]
@@ -2710,6 +2715,76 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "opentelemetry"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cdb0b1b267eb9db3331b434ed9ddab10d50e280a9adf9d13e5233e2002b61b5"
dependencies = [
"futures-core",
"futures-sink",
"js-sys",
"pin-project-lite",
"thiserror 2.0.20",
]
[[package]]
name = "opentelemetry-http"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee2c3625b8aa04209f7e01e513bc8044da9687fa38a9eacf59628ca5f3b87300"
dependencies = [
"async-trait",
"bytes",
"http",
"opentelemetry",
"reqwest",
]
[[package]]
name = "opentelemetry-otlp"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
dependencies = [
"http",
"httpdate",
"opentelemetry",
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
"prost",
"reqwest",
"thiserror 2.0.20",
]
[[package]]
name = "opentelemetry-proto"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25da1ac11a0aeccf38d7f77ee0348715adaf8340f65ad46c94a02c6b20e2f65d"
dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"prost",
]
[[package]]
name = "opentelemetry_sdk"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb39533d9d1c912123efd7d41d7e0c29d16917b60ce15b4c8d87cb1af7f67520"
dependencies = [
"futures-channel",
"futures-executor",
"futures-util",
"opentelemetry",
"percent-encoding",
"portable-atomic",
"rand 0.9.5",
"thiserror 2.0.20",
]
[[package]]
name = "opml"
version = "1.1.6"
@@ -2925,6 +3000,29 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "prost"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "quanta"
version = "0.12.6"
@@ -3225,6 +3323,7 @@ dependencies = [
"base64 0.23.1",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",
@@ -4563,6 +4662,30 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-opentelemetry"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe"
dependencies = [
"js-sys",
"opentelemetry",
"tracing",
"tracing-core",
"tracing-subscriber",
"web-time",
]
[[package]]
name = "tracing-serde"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
dependencies = [
"serde",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
@@ -4573,12 +4696,15 @@ dependencies = [
"nu-ansi-term",
"once_cell",
"regex-automata",
"serde",
"serde_json",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
"tracing-serde",
]
[[package]]

View File

@@ -1,6 +1,6 @@
[package]
name = "ipx"
version = "0.8.3"
version = "0.12.0"
edition = "2024"
[dependencies]
@@ -14,6 +14,9 @@ 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_sdk = { version = "0.33", default-features = false, features = ["trace"] }
opml = "1.1.6"
percent-encoding = "2.3.2"
quick-xml = { version = "0.42.0", features = ["escape-html"] }
@@ -22,10 +25,12 @@ 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"] }
serde_json = "1.0.151"
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
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"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tracing-opentelemetry = { version = "0.34", default-features = false }
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
url = "2.5.8"

View File

@@ -1,7 +1,7 @@
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
# build.rs builds the web pages from TypeScript with swc, which needs node.
FROM rust:1-slim-bookworm AS build
FROM rust:1-slim-trixie AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential nodejs npm \
&& rm -rf /var/lib/apt/lists/*
@@ -23,9 +23,11 @@ COPY web ./web
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
RUN touch src/main.rs && cargo build --release --locked
FROM debian:bookworm-slim
FROM debian:trixie-slim
# ffmpeg is DeadAir's (#171): it finds the silences and copies the frames kept. Debian's adds
# about 480 MB, once: the layer stays cached until the base image changes.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates gosu \
ca-certificates gosu ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/ipx /usr/local/bin/ipx
COPY docker-entrypoint.sh /usr/local/bin/

View File

@@ -1,4 +1,4 @@
# ipodderx-rs
# ipx
A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves
a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of
@@ -9,7 +9,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
## What it does
- **The web UI.** It has a toolbar, and a feed list that opens with Directory, Popular and All
- **The web UI.** It has a toolbar, and a feed list that opens with the Directory, Currently Listening and All
Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It
comes in Dark, Light and Classic themes, and works on a phone.
- **Several people, one copy.** Each person has their own subscriptions and their own read, pinned
@@ -17,8 +17,8 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
manage accounts and settings.
- **Scanning.** Feeds are checked on a schedule, globally or per feed, and a feed's own TTL is
honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a cap
on new downloads per scan.
honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a limit
on how many of a feed's newest episodes are downloaded.
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
an item someone has pinned.
@@ -30,7 +30,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
With Docker:
```sh
docker build -t ipodderx .
docker build -t ipx .
docker compose up -d
```
@@ -70,6 +70,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
```sh
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
node tests/page-smoke.js # the page script loads without throwing
node tests/native-bridge.js # the page hands playback to a native shell
npx playwright test # a real browser against a real daemon on fixture feeds
```

View File

@@ -1,24 +1,31 @@
services:
ipodderx:
image: 192.168.1.130:5000/ipodderx:latest
container_name: iPodderX
ipx:
image: 192.168.1.130:5000/ipx:latest
container_name: iPX
restart: unless-stopped
environment:
PUID: "99"
PGID: "100"
TZ: "America/Toronto"
IPX_LOG: "ipx=info"
# One JSON object a line, which Loki (through Alloy) and the Grafana dashboard read.
IPX_LOG_FORMAT: "json"
# Traces to Tempo, in the monitoring project; ipx sends none without it.
OTEL_EXPORTER_OTLP_ENDPOINT: "http://192.168.1.130:4318"
# What the dashboard filters on, so a daemon run by hand for testing stays out of it.
OTEL_RESOURCE_ATTRIBUTES: "deployment.environment.name=production"
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
env_file:
- ipodderx.env # relative: Arcane resolves it inside its own container
- ipx.env # relative: Arcane resolves it inside its own container
ports:
- "8099:8099" # web UI
- "192.168.1.130:9187:9187" # /metrics for Prometheus, LAN only (ipx#194)
- "6881:6881/tcp" # BitTorrent peers
- "6881:6881/udp" # DHT
volumes:
- /mnt/fast/appdata/ipodderx:/config # config.toml, and the web token
- /mnt/user/ipodderx/:/data # state.db
- /mnt/user/ipodderx/downloads:/downloads
- /mnt/fast/appdata/ipx:/config # config.toml, and the web token
- /mnt/user/ipx/:/data # state.db
- /mnt/user/ipx/downloads:/downloads
healthcheck:
test: ["CMD", "ipx", "status"]
interval: 30s

View File

@@ -38,7 +38,9 @@ build needs node and `npm ci` run once.
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
shows come from Patreon's web API and each becomes a derived feed.
4. Record entries. A changed title or description flips the item back to unread.
4. Record entries: the new ones, and those the feed now gives a different title, text, artwork,
length or number, compared with what is stored for the items it lists (#141). A corrected item
keeps its read state.
5. Record enclosures. `enclosures.url` is `UNIQUE`, which is the dedupe key and subsumes the
original's `history.dat` pickle: a reaped file keeps its row so it is never fetched twice.
6. Apply the merged policy (see [users.md](users.md)) and mark anything rejected as `skipped` with
@@ -51,8 +53,8 @@ build needs node and `npm ci` run once.
## Data model
```
feeds id, url, title, image, etag, last_modified, last_checked, ttl_mins,
last_error, orphaned, group_id, managed
feeds id, url, title, image, category, description, etag, last_modified,
last_checked, ttl_mins, last_error, orphaned, group_id, managed
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,
@@ -71,7 +73,8 @@ before accounts; two bugs came from queries still reading them, and they were dr
Schema changes: the tables are the entities in `src/entity.rs`, and `Db::open` creates whatever
table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
never alters a table that exists, so a new column on one needs its own `ALTER` in
`create_missing`, or `sea-orm-migration` once there are several. `Db::memory()` builds its
`create_missing`, looked for first and run only when it is missing, as `feeds.description`'s is,
or `sea-orm-migration` once there are several. `Db::memory()` builds its
database the same way, so the tests run on the schema production gets. A database from before
0.7 takes its last columns from the old `migrate()`, so it upgrades through a 0.7 release first.
@@ -89,13 +92,15 @@ printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/
```
**Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download`
(`enclosure`), `status`.
(`enclosure`), `status`, `reload` (read the catalogue and settings again from the database, which
`ipx add`, `rm` and `import` send after changing them in a process of their own; answered with
`status`).
**Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,
`download_error`, `torrent_deferred`, `reaped`, `reap_done`, `scan_done`, `status`, `error`.
`scan_done`, `reap_done` and `status` are terminal: a client that asked for work stops reading
there. Commands run one at a time, in the order they arrive, except `status`: the socket answers it
straight away, so the Docker healthcheck is never left waiting behind a scan or a download, and
there. Commands run one at a time, in the order they arrive, except `status` and `reload`: the
socket answers them straight away, so the Docker healthcheck is never left waiting behind a scan or a download, and
answers only the client that asked, since `status` would end any other client's session.
Progress carries the enclosure id, without which a UI cannot tell one download from another and
@@ -110,7 +115,8 @@ so a long download had to be able to notice the signal itself.
## HTTP API
Everything below `/api` needs a signed-in user; the browser gets a redirect to `/login`, anything
else a `401`.
else a `401`. A feed's items and files (its entries, `download-latest`, `/api/enclosures/{id}` and
`/media/{id}`) answer only someone who subscribes to the feed; anyone else gets a `404`.
| Route | |
|---|---|
@@ -126,6 +132,7 @@ else a `401`.
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
| `POST /api/fetch` | |
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
| `GET /api/directory/{id}` | a listed feed's description, why its last check failed when it did (in words, never its error, which can name its address), and latest twenty items, for its page before you subscribe: title, link, date, length and text, never a file or its address, and only for a feed the directory lists |
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours, the feed's iTunes category, whether it carries audio or video; never a URL, never a private feed); subscribe by id |
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
@@ -141,7 +148,9 @@ before they reach the page.
```sh
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
node tests/native-bridge.js # the page hands playback to a native shell
npx playwright test # a real browser against a real daemon on fixture feeds
node tests/load/run.js # k6: many people at once against a daemon with 1,500 generated feeds
```
The Rust tests cannot see a wrong selector, a handler that runs and does nothing, or a page that

View File

@@ -13,13 +13,17 @@ processes must never download the same thing. `--local` forces the work to happe
| `ipx list` | Subscriptions and their state |
| `ipx status` | Counts: feeds, pending, downloaded |
| `ipx fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL |
| `ipx add <url> [--folder X] [--keywords a,b]` | Subscribe; the id comes from the feed title |
| `ipx add <url> [--folder X] [--keywords a,b] [--list] [--category C]` | Subscribe; the id comes from the feed title. `--list` puts it in the Directory for anyone to subscribe to instead, and keeps it there when its last subscriber leaves; `--category` files it under one of the Directory's categories. Run for a feed already in the catalogue, these list it or set its category |
| `ipx rm <feed>` | Unsubscribe; downloads and history are kept |
| `ipx import <file.opml>` / `ipx export <file.opml>` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in |
| `ipx reap [--dry-run]` | Run retention now |
| `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI |
| `ipx daemon [--web ADDR]` | Scheduler, control socket and web UI |
`add`, `rm` and `import` change the catalogue in a process of their own. With a daemon running they
then tell it to read the catalogue again; without that it kept its own copy and wrote it back at
its next change, undoing them. If it does not answer they say so: restart it.
## Accounts
Passwords are read from **stdin**, so they miss the shell history and any `ps` listing.
@@ -80,7 +84,9 @@ To kill it, match the binary exactly:
pkill -x ipx
```
`pkill -f ipx` matches the shell running the command too, and kills your own session.
`pkill -f ipx` matches the shell running the command too, and kills your own session. On a machine
that also runs ipx in a container, `pkill -x ipx` stops that one as well, since the host sees a
container's processes: stop the one you started by its PID instead (`kill <pid>`).
## Talking to it directly

View File

@@ -4,7 +4,7 @@ Two places. **config.toml** holds what ipx needs before it reaches its database,
who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
`media_types`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
`media_types`, `art_cache_mb`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
take effect without a restart.
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
@@ -25,8 +25,13 @@ config.toml's default location is `$XDG_CONFIG_HOME/ipx/config.toml`
`~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`. `ipx copy-db <state.db>` copies a
SQLite database into the empty Postgres one `IPX_DATABASE_URL` names.
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`.
ipx does not wait for the disk to confirm each write: SQLite syncs at checkpoints
(`synchronous=NORMAL`), and ipx's own Postgres sessions set `synchronous_commit=off`, which leaves
other databases on the server as they are. Waiting made a scan take most of a second a feed. A
crash of ipx loses nothing; a power cut, or a crash of the Postgres server, can lose the last moment
of changes, a read mark or a saved position, and never corrupts the database.
## `[general]`
@@ -40,21 +45,31 @@ max_total_gb = 50 # 0 = unlimited
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
```
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check` and `media_types` move into the
`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.
* **`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. Admin-only from the UI.
be polled *less* often, and a per-feed `schedule` overrides both. 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.
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
read it. `0` disables it entirely.
* **`max_age_days`** — items older than this with no file on disk are pruned from the database.
Kept ones stay. `0` disables it.
* **`max_new_per_check`** — the cap that stops a new subscription pulling a whole back catalogue.
`0` means unlimited, which is rarely what you want: subscribing to an OPML of 80 feeds with no cap
* **`art_cache_mb`** — how much show and episode artwork iPX keeps on disk, in `art/` beside the
database, so the page loads it from iPX rather than from every publisher's server. Over this,
what has gone longest unshown goes first. `0` keeps none: artwork still comes through iPX, fetched
each time. 500 by default.
* **`max_new_per_check`** — how many of a feed's newest episodes are downloaded; older ones stay
listed to download by hand. It stops a new subscription pulling a whole back catalogue.
`0` means every episode, for an archive; set it on the feeds you want archived, since on the
global default it applies to every feed: subscribing to an OPML of 80 feeds with no limit
fetched 216 files and 22 GB in one scan.
* **`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
@@ -88,6 +103,7 @@ access_aud = "" # the Access application's AUD tag
auto_create_users = true
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
session_days = 30
metrics_bind = "" # e.g. "0.0.0.0:9187"; empty serves no metrics
```
* **`token`** — the shared secret, which signs in as the **admin**. `?token=…` sets a cookie, so
@@ -104,6 +120,9 @@ session_days = 30
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
the proxy signs them straight back in.
* **`session_days`** — sign a session out after this long without a request.
* **`metrics_bind`** — where to serve `/metrics` for Prometheus: database connections in use, the
worker's queue, the runtime's tasks, memory and open files. It has a thread and a port of its own
and no sign-in, so publish it to the LAN only. Empty, the default, serves none.
It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can
itself carry a credential. Put TLS in front of it if that matters.
@@ -121,6 +140,7 @@ folder = "Accidental Tech Podcast" # default: the feed title
schedule = "every 6h" # overrides [general] for this feed
media_types = ["audio"] # overrides [general] for this feed
category = "Technology" # the Directory's, if the feed names none
listed = true # in the Directory with no subscribers
username = "ray" # HTTP basic auth
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
```
@@ -130,6 +150,12 @@ 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).
**The Directory** lists every feed in the catalogue, whether or not anyone subscribes to it yet.
To put one there for others to find, `ipx add --list --category News <url>`: it subscribes no one,
and a listed feed stays when its last subscriber leaves, where another is dropped. A feed nobody
subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing
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.
@@ -142,5 +168,7 @@ and they are re-derived on every scan. Editing one in the UI promotes it to a ca
| `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 |
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
| `IPX_LOG_FORMAT` | `json` for one JSON object a line, with each request's and event's fields as its own (for Loki and the like); text otherwise |
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | An OTLP/HTTP collector, such as Tempo at `http://host:4318`: the daemon sends it traces of scans, downloads and web requests. The other `OTEL_EXPORTER_OTLP_*` variables apply too |
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |

View File

@@ -31,7 +31,7 @@ request it forwards through the tunnel, and ipx signs that person in.
| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses |
| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` |
| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied |
| ipx | `/mnt/fast/appdata/ipodderx/config.toml`, `[web]` | below |
| ipx | `/mnt/fast/appdata/ipx/config.toml`, `[web]` | below |
```toml
[web]
@@ -39,13 +39,19 @@ enabled = true
bind = "0.0.0.0:8099"
trusted_header = "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
access_team = "rays-sdf1.cloudflareaccess.com"
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
auto_create_users = true
sign_out_url = "/cdn-cgi/access/logout"
session_days = 30
```
The last two turn on the token check described under [Verifying Cloudflare's token](#verifying-cloudflares-token),
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
restart ipodderx`.
restart ipx`.
### What was missing
@@ -87,7 +93,7 @@ ordinary user with no feeds. An account made before the proxy can be given the n
send:
```sh
docker exec iPodderX ipx user rename <old name> <email address>
docker exec iPX ipx user rename <old name> <email address>
```
### Signing out
@@ -103,11 +109,15 @@ feeds.
### The tile in Authentik's library
Authentik's library lists Authentik's own applications, and ipodderx signs in through the one
called `Cloudflare Access`, so ipodderx needs a bookmark of its own to show up there. It is
Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and
the iPodderX icon. Like Outline's, it has no policy bindings, so everyone in Authentik sees the
tile. Who actually gets in is still up to the Access policy.
Authentik's library lists Authentik's own applications, and ipx signs in through the one
called `Cloudflare Access`, so ipx needs a bookmark of its own to show up there. It is
Applications → Applications → `iPX` (slug `ipodderx`): no provider, launch URL
`https://ipodderx.sdf1.net`, and web/logo.svg at 512px as its icon, uploaded again when the logo
changes. The library keeps each person's list of tiles in a cache that an edit to the application
does not clear, so after one, clear it (System → Policies → Clear cache, or
`POST /api/v3/policies/all/cache_clear/`) or the old name and icon stay up. It has no policy
bindings, so everyone in Authentik sees the tile. Who actually gets in is still up to the Access
policy.
### Check it
@@ -216,12 +226,26 @@ echo -n 'newsecret' | ipx user passwd sam # change a password
ipx user rm sam # remove the account
```
In the container, put `docker exec iPodderX` in front, and `docker exec -i iPodderX` for the ones
In the container, put `docker exec iPX` in front, and `docker exec -i iPX` for the ones
that read a password.
Set `auto_create_users = false` once everyone who should have an account has one. After that the
proxy vouching for an unknown name is logged and refused. Make people ahead of time instead, with
the exact name the header will carry.
## API tokens for scripts and agents
Anyone can make API tokens for their own account in Settings. A token is sent as
`Authorization: Bearer ipx_...` and acts as the person who made it, admin only if they are. Only
its SHA-256 is kept; it is shown once, when made, and revoked there too.
Through the tunnel, Cloudflare Access turns a request with no Access sign-in towards Authentik
before ipx sees it, so a token alone does not get in that way. On the LAN, `http://192.168.1.130:8099`
takes it directly. From outside, make an Access service token, add a Service Auth policy for it to
the `ipodderx` application, and send `CF-Access-Client-Id` and `CF-Access-Client-Secret` beside the
`Authorization` header: Access lets the request through, vouches for no name, and ipx takes the
API token as who is asking. Do not bypass Access for `/api/*` instead; the token would then be the
only thing between the internet and the API.
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.

View File

@@ -70,11 +70,15 @@ list with their own read state. Unsubscribing removes it from their list alone;
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
re-subscribing does not pull the back catalogue again.
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
included. Directory shows every one of them A to Z as a grid of cover art. Above it, a filter
picks Podcasts (anything with audio or video) or Blogs (the rest), and chips pick the category
each show gives itself in iTunes; the two combine. Your own feeds are marked Subscribed.
**Directory** sits at the top of the feed list, above your own feeds. It opens on the ten feeds
with the most subscribers on this server, you included, ranked, then the categories, each a tile
showing a few of its shows' covers. A category has a page of its own: its most subscribed feeds,
then all of them as a grid of cover art, with its subcategories along the top (Tech News under
News, Video Games under Leisure) and a choice of order. See all lists every feed, and the search
box finds one by name. A feed you do not subscribe to opens a page of its own, with what it says it
is and its latest episodes or posts, so you can see what it is first. Every page can be narrowed to Podcasts (anything with audio or video) or
Blogs (the rest). A show's category is the one it gives itself in iTunes, or for one that names
none, one an admin or iPX picked for it. Your own feeds are marked Subscribed.
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
@@ -83,6 +87,10 @@ Patreon or Supercast, which put the key in the path, and any feed inside an OPML
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
they pay for.
**Currently Listening**, under the Directory, is every episode you started and have not finished,
across all your feeds, with how much is left. A click picks one up where you left off. The search
box looks through it, as it does a feed's items, and through the Directory by name.
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or

246
grafana/dashboard.py Normal file
View File

@@ -0,0 +1,246 @@
"""The iPX dashboard in Grafana, from Loki (the container's log, shipped by Alloy) and Tempo.
python3 grafana/dashboard.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/dashboards/ipx.json
Grafana reads that file on its own within a minute; edits made in Grafana are refused. The
Daemon row reads ipx's /metrics from Prometheus (job ipx, #194); the rest read the fields of
ipx's JSON log (IPX_LOG_FORMAT=json): renaming a field in web.rs access_log or ipc.rs log_event
has to be matched here. `dashboard.py queries` prints each
query, to try against Loki.
"""
import json, sys
LOKI = {"type": "loki", "uid": "${loki}"}
TEMPO = {"type": "tempo", "uid": "${tempo}"}
PROM = {"type": "prometheus", "uid": "${prometheus}"}
SEL = '{container="iPX"}'
# ipx logs one JSON object a line (IPX_LOG_FORMAT=json). Each scan and download event carries its
# fields (ev, feed, new, bytes, msg, error.type, ...); each request its method, path, route, status
# and duration_ms. Events are logged under ipx::scan, the healthcheck's status under ipx::io, so
# the filter is on the ev field, not the target.
EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""'
HTTP = SEL + ' |= "\\"target\\":\\"ipx::http\\"" | json | __error__="" | path != "/api/events"'
BAD = SEL + ' | json | __error__="" | level =~ "WARN|ERROR"'
TEXT = ' | line_format "{{.level}} {{.target}}: {{.message}}"'
TRACES = '{resource.service.name="ipx" && resource.deployment.environment.name="production"'
def status(field):
return f'max(max_over_time({EV} | ev = "status" | unwrap {field} [10m]))'
QUERIES = {}
panels, y = [], 0
pid = 0
def panel(kind, title, w, h, x, targets, **extra):
global pid
pid += 1
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
"datasource": targets[0].get("datasource", LOKI), "targets": targets}
p.update(extra)
panels.append(p)
return p
def loki(expr, ref="A", legend=None, instant=False, kind=None):
QUERIES[expr] = instant
t = {"refId": ref, "datasource": LOKI, "expr": expr, "queryType": "instant" if instant else "range"}
if legend:
t["legendFormat"] = legend
return t
def prom(expr, ref="A", legend=None):
# ipx's /metrics (#194), scraped by Prometheus as job ipx.
t = {"refId": ref, "datasource": PROM, "expr": expr}
if legend:
t["legendFormat"] = legend
return t
def row(title):
global y, pid
pid += 1
panels.append({"id": pid, "type": "row", "title": title, "collapsed": False,
"gridPos": {"x": 0, "y": y, "w": 24, "h": 1}, "panels": []})
y += 1
def stat(title, expr, x, unit="short", color="blue", thresholds=None, desc=None):
steps = thresholds or [{"color": color, "value": None}]
return panel("stat", title, 4, 4, x, [loki(expr, instant=True)], description=desc or "",
fieldConfig={"defaults": {"unit": unit, "color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": steps}}, "overrides": []},
options={"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
"colorMode": "value", "graphMode": "none", "textMode": "value"})
def ts(title, targets, x, w=12, h=8, unit="short", bars=False, stack=False, desc=""):
custom = {"drawStyle": "bars" if bars else "line", "fillOpacity": 60 if bars else 10,
"lineWidth": 1, "showPoints": "never", "stacking": {"mode": "normal" if stack else "none"}}
return panel("timeseries", title, w, h, x, targets, description=desc,
fieldConfig={"defaults": {"unit": unit, "custom": custom}, "overrides": []},
options={"legend": {"displayMode": "list", "placement": "bottom"},
"tooltip": {"mode": "multi", "sort": "desc"}})
def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc=""):
return panel("table", title, w, h, x, targets, description=desc,
transformations=[{"id": "labelsToFields", "options": {"mode": "columns"}},
{"id": "organize", "options": {
"excludeByName": {"Time": True, "container": True, "compose_project": True,
"service_name": True},
"renameByName": rename or {}}}],
options={"showHeader": True, "sortBy": sort or []},
fieldConfig={"defaults": {}, "overrides": []})
# ---- Now
row("Now")
stat("Feeds", status("feeds"), 0, desc="From the healthcheck's status answer, every 30 seconds.")
stat("Waiting to download", status("pending"), 4)
stat("Downloaded", status("downloaded"), 8, color="green")
stat("Feed failures", f'sum(count_over_time({EV} | ev="feed_error" [$__range])) or vector(0)', 12,
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}],
desc="Failed feed checks in the time range.")
stat("Download failures", f'sum(count_over_time({EV} | ev="download_error" [$__range])) or vector(0)', 16,
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}])
stat("Warnings and errors", f'sum(count_over_time({BAD} [$__range])) or vector(0)', 20,
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}, {"color": "red", "value": 50}])
y += 4
# ---- Scans
row("Scans and downloads")
ts("New items found", [loki(f'sum(sum_over_time({EV} | ev="feed_done" | unwrap new [$__interval]))', legend="new items")],
0, bars=True)
ts("Downloads", [loki(f'sum(count_over_time({EV} | ev="download_done" [$__interval]))', legend="saved"),
loki(f'sum(count_over_time({EV} | ev="download_error" [$__interval]))', ref="B", legend="failed")],
12, bars=True)
y += 8
ts("Bytes downloaded", [loki(f'sum(sum_over_time({EV} | ev="download_done" | unwrap bytes [$__interval]))', legend="bytes")],
0, unit="bytes", bars=True)
ts("Feeds checked per scan", [loki(f'sum(sum_over_time({EV} | ev="scan_done" | unwrap feeds [$__interval]))', legend="feeds checked")],
12, bars=True, desc="Feeds that were due and fetched; the rest were skipped as not due.")
y += 8
table("Failing feeds", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="feed_error" [$__range]))', instant=True)],
0, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="download_error" [$__range]))', instant=True)],
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
y += 8
# ---- Web
row("Web")
ts("Requests by status", [loki(f'sum by (status) (count_over_time({HTTP} [$__interval]))', legend="{{status}}")],
0, bars=True, stack=True, desc="The event stream the page keeps open is left out.")
ts("Response time", [loki(f'quantile_over_time(0.5, {HTTP} | unwrap duration_ms [$__interval]) by ()', legend="median"),
loki(f'quantile_over_time(0.95, {HTTP} | unwrap duration_ms [$__interval]) by ()', ref="B", legend="95th percentile"),
loki(f'max_over_time({HTTP} | unwrap duration_ms [$__interval]) by ()', ref="C", legend="slowest")],
12, unit="ms")
y += 8
table("Slowest routes", [loki(f'topk(15, avg_over_time({HTTP} | route != "" | unwrap duration_ms [$__range]) by (method, route))', instant=True)],
0, rename={"method": "Method", "route": "Route", "Value": "Average ms"}, sort=[{"displayName": "Average ms", "desc": True}])
table("Busiest routes", [loki(f'topk(15, sum by (method, route) (count_over_time({HTTP} | route != "" [$__range])))', instant=True)],
12, rename={"method": "Method", "route": "Route", "Value": "Requests"}, sort=[{"displayName": "Requests", "desc": True}])
y += 8
# ---- Daemon, from Prometheus: what the daemon holds between requests (#194)
row("Daemon")
# 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"}}]}]
ts("Waiting", [prom('ipx_worker_queue{job="ipx"}', legend="commands for the worker"),
prom('ipx_runtime_queue{job="ipx"}', ref="B", legend="tasks for a thread"),
prom('ipx_downloads_pending{job="ipx"}', ref="C", legend="downloads")],
8, w=8, desc="Scans and downloads run one at a time; a queue that only grows means the worker is stuck.")
ts("Listeners and tasks", [prom('ipx_event_listeners{job="ipx"}', legend="live-update listeners"),
prom('ipx_runtime_tasks{job="ipx"}', ref="B", legend="runtime tasks"),
prom('ipx_deadair_playing{job="ipx"}', ref="C", legend="playing through DeadAir")],
16, w=8)
y += 8
ts("Memory", [prom('ipx_process_resident_bytes{job="ipx"}', legend="resident")], 0, w=12, unit="bytes")
ts("Open files and sockets", [prom('ipx_process_open_fds{job="ipx"}', legend="open")], 12, w=12)
y += 8
# Jobs and queues (#195)
ts("Worker", [prom('max by (kind) (ipx_worker_job_seconds{job="ipx"})', legend="on a {{kind}} for"),
prom('max(ipx_last_scan_age_seconds{job="ipx"})', ref="B", legend="since the last scan finished")],
0, w=8, unit="s", desc="A job's time that only climbs, or no scan for over 10 minutes, means the worker is stuck; the healthcheck still passes.")
ts("Jobs done", [prom('sum by (kind) (increase(ipx_jobs_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
8, w=8, bars=True, stack=True)
ts("Time on jobs", [prom('sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
16, w=8, unit="s", bars=True, stack=True, desc="Seconds the worker spent on each kind of job in each interval.")
y += 8
ts("In flight", [prom('max(ipx_http_requests_in_flight{job="ipx"})', legend="web requests"),
prom('max(ipx_scan_feeds_fetching{job="ipx"})', ref="B", legend="feeds fetching"),
prom('max(ipx_downloads_active{job="ipx"})', ref="C", legend="downloads"),
prom('2 - max(ipx_torrent_slots_free{job="ipx"})', ref="D", legend="torrents")],
0, w=8)
ts("Download speed", [prom('sum(rate(ipx_download_bytes_total{job="ipx"}[$__rate_interval]))', legend="downloading")],
8, w=8, unit="Bps")
ts("Backlogs", [prom('max(ipx_deadair_backlog{job="ipx"})', legend="DeadAir to look at"),
prom('max(ipx_feed_retries{job="ipx"})', ref="B", legend="feeds to retry a lookup"),
prom('max(ipx_event_queue{job="ipx"})', ref="C", legend="live updates unread"),
prom('sum(increase(ipx_events_missed_total{job="ipx"}[$__rate_interval]))', ref="D", legend="live updates missed")],
16, w=8)
y += 8
ts("DeadAir on a file", [prom('max(ipx_deadair_busy_seconds{job="ipx"})', legend="for")], 0, w=12, unit="s",
desc="How long DeadAir has been on the file it is analysing; a few seconds each.")
ts("Artwork", [prom('max(ipx_art_cache_bytes{job="ipx"}) / max(ipx_art_cache_limit_bytes{job="ipx"})', legend="full"),
prom('sum(increase(ipx_art_requests_total{job="ipx", result="hit"}[$__rate_interval])) / '
'sum(increase(ipx_art_requests_total{job="ipx"}[$__rate_interval]))', ref="B", legend="hit rate"),
prom('sum(increase(ipx_art_evicted_total{job="ipx"}[$__rate_interval]))', ref="C", legend="images evicted")],
12, w=12, unit="percentunit",
desc="Full sits near 100% once the cache has filled, since each sweep trims it to fit. Hit rate is the share served from disk: "
"the rest was fetched from the publisher. Evicted, on the right, is images each sweep threw out to stay under the "
"limit: many at every sweep, with a falling hit rate, means the limit is too small.")["fieldConfig"]["overrides"] = [
{"matcher": {"id": "byName", "options": "images evicted"},
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"},
{"id": "custom.drawStyle", "value": "bars"}, {"id": "custom.fillOpacity", "value": 60}]}]
y += 8
# ---- Traces
row("Traces")
for x, title, q in [(0, "Recent traces", TRACES + "}"),
(12, "Slow traces (over 2s)", TRACES + " && duration > 2s}")]:
panel("table", title, 12, 10, x,
[{"refId": "A", "datasource": TEMPO, "queryType": "traceql", "query": q, "limit": 50,
"tableType": "traces"}],
fieldConfig={"defaults": {}, "overrides": []})
y += 10
# ---- Log
row("Log")
panel("logs", "Warnings and errors", 24, 10, 0, [loki(BAD + TEXT)],
options={"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True})
y += 10
panel("logs", "Log", 24, 12, 0,
[loki(SEL + ' | json | __error__="" | path != "/api/events" | ev != "feed_skip" | ev != "status"'
' | message != "-> {\\"cmd\\":\\"status\\"}"' + TEXT)],
description="Without the event stream's requests, not-due skips and healthcheck status calls.",
options={"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True})
dash = {
"uid": "ipx", "title": "iPX", "tags": ["ipx"], "timezone": "browser", "schemaVersion": 39,
"time": {"from": "now-24h", "to": "now"}, "refresh": "1m", "editable": True,
"templating": {"list": [
{"name": "loki", "label": "Logs", "type": "datasource", "query": "loki", "current": {}, "hide": 0},
{"name": "tempo", "label": "Traces", "type": "datasource", "query": "tempo", "current": {}, "hide": 0},
{"name": "prometheus", "label": "Metrics", "type": "datasource", "query": "prometheus", "current": {}, "hide": 0},
]},
"links": [{"title": "iPX", "type": "link", "url": "https://ipodderx.sdf1.net", "targetBlank": True}],
"panels": panels,
}
if sys.argv[1:] == ["queries"]:
for q, instant in QUERIES.items():
print(json.dumps([q, instant]))
else:
print(json.dumps(dash, indent=2))

9
package-lock.json generated
View File

@@ -7,7 +7,8 @@
"name": "ipx-web",
"dependencies": {
"@swc/core": "^1.16.2",
"@swc/html": "^1.16.2"
"@swc/html": "^1.16.2",
"hls.js": "1.6.19"
},
"devDependencies": {
"@playwright/test": "^1.56.0",
@@ -871,6 +872,12 @@
"node": ">=16.20.0"
}
},
"node_modules/hls.js": {
"version": "1.6.19",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.19.tgz",
"integrity": "sha512-A//aZeaAgM5rgUCbTURD4gkfyw5Uag2/4Jky7CfDOukL/DN+P5DjbgK2BYteyP3/KnCkPfkGVkbLxQG2T0AbUQ==",
"license": "Apache-2.0"
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",

View File

@@ -7,7 +7,8 @@
"typecheck": "tsc -p .",
"smoke": "node tests/page-smoke.js",
"test": "playwright test",
"test:headed": "playwright test --headed"
"test:headed": "playwright test --headed",
"load": "node tests/load/run.js"
},
"devDependencies": {
"@playwright/test": "^1.56.0",
@@ -15,6 +16,7 @@
},
"dependencies": {
"@swc/core": "^1.16.2",
"@swc/html": "^1.16.2"
"@swc/html": "^1.16.2",
"hls.js": "1.6.19"
}
}

View File

@@ -37,6 +37,10 @@ module.exports = defineConfig({
IPX_CONFIG: `${setup.root}/config/config.toml`,
IPX_DATA_DIR: `${setup.root}/data`,
IPX_LOG: 'ipx=info',
// Blank, whatever the shell running the suite has: the test daemon sends no traces to
// production's Tempo and never opens production's database.
OTEL_EXPORTER_OTLP_ENDPOINT: '',
IPX_DATABASE_URL: '',
},
},
],

138
src/art.rs Normal file
View File

@@ -0,0 +1,138 @@
//! 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::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
pub fn dir() -> PathBuf {
crate::config::data_dir().join("art")
}
/// Where an image's bytes are kept; its content type is beside it, in `<name>.type`.
// ponytail: std's hasher, 64 bits, keyed by the URL. Its algorithm may change with Rust, which
// only makes the cache miss once and refill; a collision among a few thousand images is about
// one in 10^12. Move to a SHA if either ever matters.
pub fn path(url: &str) -> PathBuf {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut h);
dir().join(format!("{:016x}", h.finish()))
}
/// A kept image and its type, marked as just used, which is what keeps it from being trimmed.
pub fn read(file: &Path) -> Option<(String, Vec<u8>)> {
let kind = std::fs::read_to_string(file.with_extension("type")).ok()?;
let body = std::fs::read(file).ok()?;
if let Ok(f) = std::fs::File::options().write(true).open(file) {
let _ = f.set_modified(std::time::SystemTime::now());
}
Some((kind, body))
}
/// Keeps an image, written aside and renamed into place, so a page asking for it meanwhile
/// never reads half of one.
pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> {
std::fs::create_dir_all(file.parent().unwrap_or(Path::new(".")))?;
let tmp = file.with_extension("part");
std::fs::write(&tmp, body)?;
std::fs::write(file.with_extension("type"), kind)?;
std::fs::rename(&tmp, file)
}
/// How long an address that could not be fetched is answered from memory before it is tried again.
const RETRY: Duration = Duration::from_secs(60 * 60);
/// Addresses that could not be fetched lately, and why. Without it a page shown again fetched
/// them again: five covers too big to keep were downloaded from Patreon on every load (#169).
// ponytail: in memory, and an entry goes only when it is asked for after RETRY. It holds at most
// one per artwork address a feed or item names; move it to disk or prune it on trim if that grows.
static FAILED: LazyLock<Mutex<HashMap<String, (Instant, String)>>> = LazyLock::new(Default::default);
/// Why `url` could not be fetched, if it failed less than an hour ago.
pub fn failed(url: &str) -> Option<String> {
failed_as_of(url, Instant::now())
}
fn failed_as_of(url: &str, now: Instant) -> Option<String> {
let mut map = FAILED.lock().unwrap_or_else(|e| e.into_inner());
match map.get(url) {
Some((at, why)) if now.duration_since(*at) < RETRY => Some(why.clone()),
Some(_) => {
map.remove(url);
None
}
None => None,
}
}
pub fn fail(url: &str, why: &str) {
FAILED.lock().unwrap_or_else(|e| e.into_inner()).insert(url.to_owned(), (Instant::now(), why.to_owned()));
}
/// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it.
/// Returns how many went.
pub fn trim(dir: &Path, limit: u64) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_none())
.filter_map(|p| {
let m = p.metadata().ok()?;
Some((m.modified().ok()?, m.len(), p))
})
.collect();
let mut total: u64 = kept.iter().map(|(_, n, _)| n).sum();
kept.sort();
let mut gone = 0;
for (_, n, p) in kept {
if total <= limit {
break;
}
let _ = std::fs::remove_file(p.with_extension("type"));
if std::fs::remove_file(&p).is_ok() {
total -= n;
gone += 1;
}
}
crate::metrics::STATS.art_cache_bytes.store(total, std::sync::atomic::Ordering::Relaxed);
crate::metrics::STATS.art_evicted.fetch_add(gone as u64, std::sync::atomic::Ordering::Relaxed);
gone
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_least_recently_used_go_first_until_it_fits() {
let d = std::env::temp_dir().join(format!("ipx-art-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for (name, age) in [("old", 300), ("mid", 200), ("new", 100)] {
let f = d.join(name);
write(&f, "image/png", &[0; 1000]).unwrap();
let t = std::time::SystemTime::now() - std::time::Duration::from_secs(age);
std::fs::File::options().write(true).open(&f).unwrap().set_modified(t).unwrap();
}
// Shown just now: no longer the oldest.
assert_eq!(read(&d.join("old")).unwrap().0, "image/png");
assert_eq!(trim(&d, 2000), 1);
assert!(!d.join("mid").exists() && !d.join("mid.type").exists());
assert!(d.join("old").exists() && d.join("new").exists());
assert_eq!(trim(&d, 0), 2);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_failure_is_remembered_for_an_hour() {
let u = "https://example.com/a-failure-is-remembered.png";
assert_eq!(failed(u), None);
fail(u, "larger than 5 MB");
assert_eq!(failed(u).as_deref(), Some("larger than 5 MB"));
assert_eq!(failed_as_of(u, Instant::now() + RETRY), None);
// Asked for after the hour, it is forgotten, not just ignored.
assert_eq!(failed(u), None);
}
}

View File

@@ -30,6 +30,35 @@ pub fn verify_password(password: &str, stored: &str) -> bool {
.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)))
});
/// What a name that is not an account is checked against, so that refusing it takes as long as
/// refusing a wrong password does. Refused without a check, it came back 31ms sooner, and the
/// time told anyone which names are accounts here (#138).
static DECOY: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| hash_password("no account here has this password").expect("hashing a fixed password"));
/// A sign-in's password against the account's hash, or against `DECOY` when there is no account
/// 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 {
Some(h) => verify_password(&password, &h),
None => {
verify_password(&password, &DECOY);
false
}
})
.await
.unwrap_or(false)
}
/// A session id: 256 bits of urandom, hex. Long enough that guessing is not a strategy.
pub fn new_session_token() -> String {
let mut bytes = [0u8; 32];
@@ -40,6 +69,19 @@ pub fn new_session_token() -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// A new API token: `ipx_` and a session's 256 random bits, the prefix so one found in a log
/// or a file says what it opens (#123).
pub fn new_api_token() -> String {
format!("ipx_{}", new_session_token())
}
/// What is kept of an API token. A fast hash is enough: the token is 256 random bits, not a
/// password, so there is nothing to guess from a stolen hash.
pub fn api_token_hash(token: &str) -> String {
use sha2::Digest;
sha2::Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
}
fn getrandom(buf: &mut [u8]) -> std::io::Result<()> {
use std::io::Read;
std::fs::File::open("/dev/urandom")?.read_exact(buf)
@@ -63,6 +105,16 @@ pub fn name_from_header(raw: &str) -> Option<String> {
mod tests {
use super::*;
#[tokio::test]
async fn a_sign_in_without_an_account_is_checked_all_the_same() {
let h = hash_password("correct horse battery").unwrap();
assert!(check_password("correct horse battery".into(), Some(h.clone())).await);
assert!(!check_password("wrong".into(), Some(h)).await);
assert!(!check_password("correct horse battery".into(), None).await);
// A decoy that does not parse is refused before any hashing, and the time says so (#138).
assert!(PasswordHash::new(&DECOY).is_ok());
}
#[test]
fn a_password_verifies_only_against_itself() {
let h = hash_password("correct horse battery").unwrap();
@@ -74,6 +126,21 @@ mod tests {
assert!(hash_password("short").is_err());
}
#[test]
fn api_tokens_are_prefixed_and_hash_to_hex() {
let t = new_api_token();
assert!(t.starts_with("ipx_") && t.len() == 68);
let h = api_token_hash(&t);
assert_eq!(h.len(), 64);
assert_eq!(h, api_token_hash(&t), "the same token, the same hash");
assert_ne!(h, api_token_hash(&new_api_token()));
assert_eq!(
api_token_hash("abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"SHA-256"
);
}
#[test]
fn session_tokens_are_long_and_distinct() {
let a = new_session_token();

View File

@@ -39,6 +39,9 @@ pub struct General {
/// image in an <enclosure>, so taking everything filled the disk with artwork and
/// counted it as episodes. Empty means take anything.
pub media_types: Vec<String>,
/// How much artwork iPX keeps on disk, in MB, so the page loads it from iPX rather than from
/// every publisher; the least recently shown goes first. 0 keeps none.
pub art_cache_mb: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
@@ -94,6 +97,9 @@ pub struct Web {
pub sign_out_url: String,
/// Sign a session out after this long without a request.
pub session_days: i64,
/// Where to serve /metrics for Prometheus (#194), on a thread of its own, without sign-in:
/// `0.0.0.0:9187`. Empty serves none.
pub metrics_bind: String,
}
impl Default for Web {
@@ -109,6 +115,7 @@ impl Default for Web {
auto_create_users: true,
sign_out_url: String::new(),
session_days: 30,
metrics_bind: String::new(),
}
}
}
@@ -163,6 +170,10 @@ pub struct Feed {
/// Preferred over `password`: name of an env var holding the password.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_env: Option<String>,
/// Put in the Directory by an admin (`ipx add --list`): listed with no subscribers, and kept
/// in the catalogue when the last one leaves, where anyone else's feed is dropped.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub listed: bool,
}
fn yes() -> bool {
@@ -180,6 +191,7 @@ impl Default for General {
max_age_days: 0,
max_new_per_check: 3,
media_types: vec!["audio".into(), "video".into()],
art_cache_mb: 500,
}
}
}
@@ -290,10 +302,18 @@ pub struct Stored {
pub max_age_days: u64,
pub max_new_per_check: usize,
pub media_types: Vec<String>,
/// Settings saved before it existed have none: they get the default.
#[serde(default = "art_cache_mb")]
pub art_cache_mb: u64,
}
fn art_cache_mb() -> u64 {
General::default().art_cache_mb
}
/// `[general]` keys that live in the database once it holds the configuration.
const STORED_KEYS: [&str; 5] = ["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types"];
const STORED_KEYS: [&str; 6] =
["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types", "art_cache_mb"];
impl Stored {
pub fn of(cfg: &Config) -> Self {
@@ -304,6 +324,7 @@ impl Stored {
max_age_days: g.max_age_days,
max_new_per_check: g.max_new_per_check,
media_types: g.media_types.clone(),
art_cache_mb: g.art_cache_mb,
}
}
@@ -314,6 +335,7 @@ impl Stored {
g.max_age_days = self.max_age_days;
g.max_new_per_check = self.max_new_per_check;
g.media_types = self.media_types;
g.art_cache_mb = self.art_cache_mb;
}
}
@@ -571,6 +593,12 @@ mod tests {
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
}
#[test]
fn settings_saved_before_the_art_cache_keep_the_default() {
let old = r#"{"schedule":"every 1h","max_total_gb":0.0,"max_age_days":0,"max_new_per_check":3,"media_types":["audio"]}"#;
assert_eq!(serde_json::from_str::<Stored>(old).unwrap().art_cache_mb, 500);
}
#[test]
fn slugs_are_readable_and_unique() {
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
@@ -584,7 +612,7 @@ mod tests {
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,
password: None, password_env: None, category: None, listed: false,
});
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
}
@@ -605,6 +633,7 @@ mod tests {
password: Some("literal".into()),
password_env: None,
category: None,
listed: false,
};
assert_eq!(f.password().as_deref(), Some("literal"));

1473
src/db.rs

File diff suppressed because it is too large Load Diff

755
src/deadair.rs Normal file
View File

@@ -0,0 +1,755 @@
//! DeadAir (issue #171): podcasts play with their long silences shortened, so they finish
//! sooner without sounding sped up.
//!
//! Nothing is copied (#181). DeadAir finds a file's silences once, in the background, and keeps a
//! `Plan` for each pause length a person can choose (#182): the frames kept, as byte ranges of
//! the original. The player gets an HLS playlist of those ranges (`playlist`), which Safari plays
//! itself and other browsers through hls.js, with no gap at the cuts. Seeking past each silence
//! instead left about 1.3 s of nothing at every one (#178); a copy made with ffmpeg cost as much
//! disk again as the original and could only have one pause length.
//!
//! Positions stay in the original's time. What a plan leaves out is stored as `Cut`s of the
//! original's time, and the server converts both ways (`to_copy`, `to_original`): the iOS and
//! Android apps save positions themselves, from their own player, so converting in the page
//! would miss them.
use std::collections::{BTreeMap, HashMap};
use std::fmt::Write as _;
use std::path::Path;
use std::sync::Arc;
use tracing::Instrument;
use crate::db::step;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::Ctx;
/// Quieter than this for longer than `MIN_SILENCE` is a silence: what the measurements on the
/// issue used. A podcast's room tone sits well below it and speech well above.
const THRESHOLD: &str = "-38dB";
const MIN_SILENCE: f64 = 0.35;
/// The pauses a person can have DeadAir leave where it cuts a silence, in ms (#182). Half of it
/// is kept at each end of the silence, so the frames on both sides of a cut are silent. Decoding
/// blends a frame with the one before it, and an MP3 frame can begin in the bytes of the frame
/// before (the bit reservoir): the first frame after a cut decodes against the wrong neighbour,
/// which nobody hears between two silent frames and everyone hears at the start of a word.
pub const PAUSES: [i64; 4] = [200, 300, 400, 500];
/// 0.2 s, the first DeadAir's, made speech sound rushed.
pub const DEFAULT_PAUSE: i64 = 300;
/// The longest piece of a playlist, in seconds: about what HLS players expect, so a long stretch
/// of talk is not one request for megabytes.
const MAX_PIECE: f64 = 10.0;
/// How long the maker waits when there is nothing to do: a download or a feed turned on is
/// picked up this long after, at most.
const IDLE: std::time::Duration = std::time::Duration::from_secs(120);
/// A span of the original, in milliseconds, that DeadAir leaves out.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cut(pub i64, pub i64);
/// What one pause length leaves of a file: what it cuts, and what it keeps as pieces of the
/// original, (byte offset, bytes, seconds).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Plan {
pub cuts: Vec<Cut>,
pub pieces: Vec<(u64, u64, f64)>,
}
impl Plan {
pub fn saved_ms(&self) -> i64 {
saved(&self.cuts)
}
}
/// The length of the ID3v2 tag a file starts with, 0 if it has none.
pub fn id3_len(head: &[u8]) -> u64 {
if head.len() < 10 || &head[..3] != b"ID3" {
return 0;
}
// Seven bits a byte, so no byte of the size looks like the start of a frame.
let size = head[6..10].iter().fold(0u64, |n, b| n << 7 | u64::from(b & 0x7f));
let footer = if head[5] & 0x10 != 0 { 10 } else { 0 };
10 + size + footer
}
/// A file as `plan` plays it, to save (#186): its ID3 tag, so the title and artwork come too, then
/// the pieces one after another. MP3 and ADTS frames each stand alone, so that is a file any
/// player plays. Read as it is sent and never stored, as the playlist is: (its length, its bytes).
pub async fn kept(
path: &Path,
plan: &Plan,
) -> std::io::Result<(u64, impl futures_util::Stream<Item = std::io::Result<axum::body::Bytes>> + use<>)> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = tokio::fs::File::open(path).await?;
let mut head = [0u8; 10];
let n = file.read(&mut head).await?;
// Only up to the first frame kept: past the tag can be the Xing frame, which gives the
// original's length.
let first = plan.pieces.first().map_or(0, |p| p.0);
let tag = id3_len(&head[..n]).min(first);
let ranges: std::collections::VecDeque<(u64, u64)> =
std::iter::once((0, tag)).chain(plan.pieces.iter().map(|&(o, n, _)| (o, n))).filter(|r| r.1 > 0).collect();
let len = ranges.iter().map(|r| r.1).sum();
let body = futures_util::stream::try_unfold((file, ranges), |(mut f, mut left)| async move {
let Some((off, n)) = left.pop_front() else { return Ok(None) };
let mut buf = vec![0; n.min(1 << 16) as usize];
f.seek(std::io::SeekFrom::Start(off)).await?;
f.read_exact(&mut buf).await?;
let got = buf.len() as u64;
if got < n {
left.push_front((off + got, n - got));
}
Ok(Some((axum::body::Bytes::from(buf), (f, left))))
});
Ok((len, body))
}
/// The silences silencedetect reported, as (start, end) seconds. One still open at the end of
/// the file has no end and is left alone.
pub fn parse_silences(log: &str) -> Vec<(f64, f64)> {
let mut out = Vec::new();
let mut start = None;
for line in log.lines() {
if let Some(v) = after(line, "silence_start: ") {
start = Some(v);
} else if let Some(v) = after(line, "silence_end: ")
&& let Some(s) = start.take()
{
out.push((s, v));
}
}
out
}
fn after(line: &str, key: &str) -> Option<f64> {
let rest = &line[line.find(key)? + key.len()..];
rest.split_whitespace().next()?.parse().ok()
}
/// Which frames to drop for these silences, as inclusive ranges of frame numbers, and the same
/// as `Cut`s. Frame `n` plays from `first + n * frame` seconds. Only frames wholly inside a
/// silence, `keep` seconds in from each end, go.
pub fn plan(silences: &[(f64, f64)], first: f64, frame: f64, keep: f64) -> (Vec<(u64, u64)>, Vec<Cut>) {
let spans: Vec<(u64, u64)> = silences
.iter()
.filter(|(s, e)| e - s >= MIN_SILENCE)
.filter_map(|&(s, e)| {
let a = ((s + keep - first) / frame).ceil().max(0.0) as u64;
let b = ((e - keep - first) / frame).floor() as i64 - 1;
(b >= a as i64).then_some((a, b as u64))
})
.collect();
let ms = |n: u64| ((first + n as f64 * frame) * 1000.0).round() as i64;
let cuts = spans.iter().map(|&(a, b)| Cut(ms(a), ms(b + 1))).collect();
(spans, cuts)
}
/// The frames kept, as pieces of the file: runs of frames lying end to end in it, none past
/// `MAX_PIECE`. `frames` is each frame's (byte offset, bytes); `drop` is from `plan`.
pub fn pieces(frames: &[(u64, u64)], drop: &[(u64, u64)], frame: f64) -> Vec<(u64, u64, f64)> {
let most = ((MAX_PIECE / frame).floor() as usize).max(1);
let mut out = Vec::new();
let push = |out: &mut Vec<(u64, u64, f64)>, (first, n): (usize, usize)| {
let (start, (pos, size)) = (frames[first].0, frames[first + n - 1]);
out.push((start, pos + size - start, n as f64 * frame));
};
let mut drops = drop.iter().peekable();
let mut run: Option<(usize, usize)> = None;
for (n, &(pos, _)) in frames.iter().enumerate() {
while drops.peek().is_some_and(|&&(_, b)| (b as usize) < n) {
drops.next();
}
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);
if !joins && let Some(r) = run.take() {
push(&mut out, r);
}
if !dropped {
run = Some(run.map_or((n, 1), |(f, c)| (f, c + 1)));
}
}
if let Some(r) = run {
push(&mut out, r);
}
out
}
/// The HLS playlist for a plan: each piece a byte range of `uri`, the original.
pub fn playlist(plan: &Plan, uri: &str) -> String {
let longest = plan.pieces.iter().map(|p| p.2).fold(0.0, f64::max);
let mut s = format!(
"#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:{}\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n",
longest.ceil() as u64
);
for &(off, len, secs) in &plan.pieces {
let _ = write!(s, "#EXTINF:{secs:.6},\n#EXT-X-BYTERANGE:{len}@{off}\n{uri}\n");
}
s + "#EXT-X-ENDLIST\n"
}
/// A time in the original, in ms, as the same moment in the shortened version. A time inside a
/// cut is where the cut was made.
pub fn to_copy(cuts: &[Cut], ms: i64) -> i64 {
let mut out = ms;
for &Cut(s, e) in cuts {
if ms >= e {
out -= e - s;
} else {
if ms > s {
out -= ms - s;
}
break;
}
}
out
}
/// A time in the shortened version, in ms, as the same moment in the original.
pub fn to_original(cuts: &[Cut], ms: i64) -> i64 {
let mut out = ms;
for &Cut(s, e) in cuts {
if out >= s {
out += e - s;
} else {
break;
}
}
out
}
pub fn saved(cuts: &[Cut]) -> i64 {
cuts.iter().map(|Cut(s, e)| e - s).sum()
}
/// Whether someone with these settings plays a feed shortened. Off for every feed until they
/// turn it on for that one, or for the group it came in (an OPML or a Patreon creator), which a
/// feed of the group can still say otherwise to.
pub fn wants(prefs: &HashMap<String, bool>, feed: &str, group: Option<&str>) -> bool {
prefs.get(feed).or_else(|| group.and_then(|g| prefs.get(g))).copied().unwrap_or(false)
}
/// The settings of each person with DeadAir on for something, and each managed feed's group,
/// which is what `wants` and `pause` need.
pub struct Wanting {
pub people: HashMap<i64, HashMap<String, bool>>,
pub pauses: HashMap<(i64, String), i64>,
pub groups: HashMap<String, String>,
}
impl Wanting {
pub async fn load(ctx: &Ctx, only: Option<i64>) -> Result<Self> {
let mut people: HashMap<i64, HashMap<String, bool>> = HashMap::new();
let mut pauses = HashMap::new();
for (user, feed, wanted, pause) in ctx.db.deadair_prefs(only).await? {
if let Some(p) = pause {
pauses.insert((user, feed.clone()), p);
}
people.entry(user).or_default().insert(feed, wanted);
}
people.retain(|_, p| p.values().any(|&w| w));
let groups = if people.is_empty() {
HashMap::new()
} else {
ctx.db.managed_feeds().await?.into_iter().map(|m| (m.id, m.group_id)).collect()
};
Ok(Self { people, pauses, groups })
}
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)))
}
/// The pause this person has DeadAir leave in a feed: the feed's, else its group's.
pub fn pause(&self, user: i64, feed: &str) -> i64 {
let group = self.groups.get(feed);
self.pauses
.get(&(user, feed.to_owned()))
.or_else(|| group.and_then(|g| self.pauses.get(&(user, g.clone()))))
.copied()
.unwrap_or(DEFAULT_PAUSE)
}
}
/// The plan someone plays a file with: their pause's, for a file in a feed they have DeadAir on
/// for, once its silences are found. None if it cuts nothing.
fn chosen(wanting: &Wanting, user: i64, feed: &str, plans: &BTreeMap<i64, Plan>) -> Option<Plan> {
if !wanting.wants(user, feed) {
return None;
}
plans.get(&wanting.pause(user, feed)).filter(|p| !p.cuts.is_empty()).cloned()
}
/// What each person is playing shortened, by (person, feed, guid): set when they are handed the
/// playlist and cleared when `/media` serves them the original, so a saved position is converted
/// from the clock it was measured in, whether the page saved it or a native app did.
// shortcut: in memory, so a restart forgets it. A position saved before the player next asks
// for the playlist is then taken as the original's, which resumes a little early, never late.
pub type Playing = Arc<std::sync::Mutex<HashMap<(i64, String, String), Arc<Plan>>>>;
/// The plan for one file as this person would play it, for the playlist route.
pub async fn plan_for(ctx: &Ctx, user: i64, feed: &str, enclosure: i64) -> Result<Option<Plan>> {
let wanting = Wanting::load(ctx, Some(user)).await?;
let plans = ctx.db.plans(&[enclosure]).await?;
Ok(plans.get(&enclosure).and_then(|p| chosen(&wanting, user, feed, p)))
}
/// Puts a page of entries in the clock of what this person will play: for each file they play
/// shortened, `deadair` is the seconds it saves, and the entry's position and length are the
/// shortened version's. The page plays `/media/{id}/deadair.m3u8` for a file that has it.
pub async fn for_reader(ctx: &Ctx, user: i64, rows: &mut [crate::db::EntryRow]) -> Result<()> {
let wanting = Wanting::load(ctx, Some(user)).await?;
if wanting.people.is_empty() {
return Ok(());
}
let ids: Vec<i64> = rows
.iter()
.filter(|r| wanting.wants(user, &r.feed_id))
.flat_map(|r| r.enclosures.iter().filter(|e| e.path.is_some()).map(|e| e.id))
.collect();
let plans = ctx.db.plans(&ids).await?;
for r in rows.iter_mut() {
// Heard through already: the maker passes it over, so nothing is coming.
let finished = r.duration.is_some_and(|d| d > 0 && r.position * 10 >= d * 9);
let mut converted = false;
for e in &mut r.enclosures {
e.deadair_pending = ids.contains(&e.id) && e.state == "done" && !plans.contains_key(&e.id) && !finished;
let Some(p) = plans.get(&e.id).and_then(|p| chosen(&wanting, user, &r.feed_id, p)) else { continue };
e.deadair = Some(p.saved_ms() / 1000);
// One position an item, however many files: the first played shortened sets the clock.
if !std::mem::replace(&mut converted, true) {
r.position = to_copy(&p.cuts, r.position * 1000) / 1000;
r.duration = r.duration.map(|d| to_copy(&p.cuts, d * 1000) / 1000);
}
}
}
Ok(())
}
/// A position and length a player saved, in the clock of what it was playing, as the
/// original's. Past 75% of the shortened version, the episode also counts towards the time
/// DeadAir has saved this person.
pub async fn on_save(
ctx: &Ctx,
playing: &Playing,
user: i64,
feed: &str,
guid: &str,
secs: i64,
duration: Option<i64>,
) -> Result<(i64, Option<i64>)> {
let plan = playing.lock().unwrap().get(&(user, feed.to_owned(), guid.to_owned())).cloned();
let Some(p) = plan else { return Ok((secs, duration)) };
let original = |s: i64| to_original(&p.cuts, s * 1000) / 1000;
let (secs, duration) = (original(secs), duration.map(original));
let len = match duration {
Some(d) => Some(d),
None => ctx.db.entry_duration(user, feed, guid).await?,
};
if let Some(len) = len
&& len > 0
&& secs * 4 >= len * 3
{
ctx.db.count_deadair(user, feed, guid, p.saved_ms()).await?;
}
Ok((secs, duration))
}
/// Finds silences until stopped: in the next file anyone wants shortened, or a wait of `IDLE`
/// when there is none.
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
if !have_ffmpeg().await {
tracing::warn!("ffmpeg is not installed, so DeadAir shortens nothing");
return;
}
// Files ffmpeg failed on, passed over until the next start. Not stored: a failure recorded
// as the file having nothing to cut was never tried again, even once a fix was deployed (#174).
let mut failed = std::collections::HashSet::new();
loop {
// Dropping the pass on a signal kills the ffmpeg it is running (`kill_on_drop`).
let done = tokio::select! {
_ = stop.changed() => return,
done = next(&ctx, &mut failed) => done,
};
match done {
Ok(true) => continue,
Ok(false) => {}
Err(e) => tracing::warn!(error = %format!("{e:#}"), "DeadAir could not read a file"),
}
tokio::select! {
_ = stop.changed() => return,
_ = ctx.deadair.notified() => {}
_ = tokio::time::sleep(IDLE) => {}
}
}
}
async fn have_ffmpeg() -> bool {
tokio::process::Command::new("ffmpeg")
.arg("-version")
.stdout(std::process::Stdio::null())
.status()
.await
.is_ok_and(|s| s.success())
}
/// Plans one file someone wants shortened, if there is one. A file everyone who would play it
/// shortened has already heard through gets none: finished episodes are rarely played again.
// shortcut: those stay on the list and are looked at again each pass, one query each; record
// them as passed over if a large library makes the pass slow.
async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<bool> {
// Nobody with DeadAir on for anything, which is most passes: nothing more to ask the database.
use std::sync::atomic::Ordering::Relaxed;
let st = &crate::metrics::STATS;
let wanting = Wanting::load(ctx, None).await?;
if wanting.people.is_empty() {
st.deadair_backlog.store(0, Relaxed);
return Ok(false);
}
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));
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();
if users.is_empty() || !ctx.db.subscribed_any(&users, &t.feed_id).await? {
continue;
}
let finished = ctx.db.finished(&t.feed_id, &t.guid).await?;
if users.iter().all(|u| finished.contains(u)) {
continue;
}
let src = crate::download::playable(Path::new(&t.path));
let started = std::time::Instant::now();
// A trace of its own for each file looked at, not for the passes that find nothing to do,
// which are most of them.
let span = tracing::info_span!("deadair", feed = t.feed_id, enclosure = t.id, cuts = tracing::field::Empty);
st.deadair_busy_since.store(crate::db::now(), Relaxed);
let analysed = analyse(&src).instrument(span.clone()).await;
st.deadair_busy_since.store(0, Relaxed);
let plans = match analysed {
Ok(p) => p.unwrap_or_default(),
Err(e) => {
tracing::warn!(path = %src.display(), error = %format!("{e:#}"), "DeadAir could not read a file");
failed.insert(t.id);
return Ok(true);
}
};
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 });
// 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,
path = %src.display(),
cuts = usual.map_or(0, |p| p.cuts.len()),
saved_s = saved_ms / 1000,
took_ms = started.elapsed().as_millis() as u64,
"DeadAir found the silences"
);
return Ok(true);
}
Ok(false)
}
/// A plan for each of `PAUSES`, for one file. None for a file DeadAir does not apply to: video,
/// anything but MP3 or AAC on its own (ADTS; AAC in MP4 has no byte ranges a playlist can
/// use), or one with no silence long enough to cut.
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", "-",
])
.instrument(step("silencedetect"))
.await
.context("finding the silences")?;
let silences = parse_silences(&log);
let frames = frame_bytes(&path).instrument(step("frame_bytes")).await?;
let plans: BTreeMap<i64, Plan> = PAUSES
.iter()
.map(|&pause| {
let (drop, cuts) = plan(&silences, first, frame, pause as f64 / 2000.0);
(pause, Plan { cuts, pieces: pieces(&frames, &drop, frame) })
})
.collect();
Ok(plans.values().any(|p| !p.cuts.is_empty()).then_some(plans))
}
/// 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,
])
.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())
};
field("pos").zip(field("size")).with_context(|| format!("reading ffprobe's frame: {l}"))
})
.collect()
}
/// When the first audio frame starts and how long each lasts, in seconds, for a file DeadAir
/// 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,
])
.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 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,
])
.await?;
let mut f = out.trim().split(',').map(|x| x.trim().parse::<f64>());
match (f.next(), f.next()) {
(Some(Ok(first)), Some(Ok(frame))) if frame > 0.0 => Ok(Some((first, frame))),
_ => bail!("no audio frame in {path}"),
}
}
/// Runs ffmpeg and gives back what it wrote to stderr, where silencedetect reports.
async fn ffmpeg(args: &[&str]) -> Result<String> {
let out = command("ffmpeg").args(["-nostdin", "-threads", "1"]).args(args).output().await?;
let log = String::from_utf8_lossy(&out.stderr).into_owned();
if !out.status.success() {
bail!("ffmpeg failed: {}", log.lines().rev().take(3).collect::<Vec<_>>().join(" / "));
}
Ok(log)
}
async fn run_out(prog: &str, args: &[&str]) -> Result<String> {
let out = command(prog).args(args).output().await.with_context(|| format!("running {prog}"))?;
if !out.status.success() {
bail!("{prog} failed: {}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn command(prog: &str) -> tokio::process::Command {
let mut c = tokio::process::Command::new(prog);
c.kill_on_drop(true).stdin(std::process::Stdio::null());
c
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn silences_are_read_from_the_log_and_an_open_one_is_left() {
let log = "[silencedetect @ 0x1] silence_start: 1\n\
[silencedetect @ 0x1] silence_end: 3.00002 | silence_duration: 2.00002\n\
[silencedetect @ 0x1] silence_start: 10.5\n";
assert_eq!(parse_silences(log), vec![(1.0, 3.00002)]);
}
#[test]
fn cuts_leave_silent_frames_either_side_and_skip_short_silences() {
// MP3 frames, 26.122 ms; one silence of 2 s, one too short to touch; a 0.3 s pause.
let frame = 1152.0 / 44100.0;
let (frames, cuts) = plan(&[(1.0, 3.0), (5.0, 5.3)], 0.0, frame, 0.15);
assert_eq!(frames, vec![(45, 108)]);
let Cut(s, e) = cuts[0];
assert!(s >= 1150 && e <= 2850, "{cuts:?}");
assert!(s - 1150 < 27 && 2850 - e < 27, "no more than a frame kept beyond half the pause: {cuts:?}");
// AAC starts a frame early, for the encoder's priming.
let (frames, _) = plan(&[(1.0, 3.0)], -0.02322, 1024.0 / 44100.0, 0.15);
assert_eq!(frames, vec![(51, 122)]);
// A silence the margins use up entirely is left alone.
assert!(plan(&[(1.0, 1.35)], 0.0, frame, 0.15).0.is_empty());
// A longer pause cuts less of the same silence.
assert!(saved(&plan(&[(1.0, 3.0)], 0.0, frame, 0.25).1) < saved(&cuts));
}
#[test]
fn pieces_are_the_frames_kept_end_to_end_and_never_too_long() {
// 1000 frames of 100 bytes after a 50-byte tag, an ID3 tag of 7 bytes before frame 600.
let frames: Vec<(u64, u64)> =
(0..1000u64).map(|n| (50 + n * 100 + if n >= 600 { 7 } else { 0 }, 100)).collect();
let frame = 0.026;
let p = pieces(&frames, &[(100, 199), (900, 999)], frame);
let at: Vec<u64> = p.iter().map(|x| x.0).collect();
// 0-99; 200-583 and 584-599, split at 384 frames (10 s); 600-899 after the tag.
assert_eq!(at, [50, 20_050, 58_450, 60_057]);
assert_eq!(p[0], (50, 10_000, 100.0 * frame));
assert_eq!(p[2].1, 1600, "frames 584-599");
assert!(p.iter().all(|x| x.2 <= MAX_PIECE));
let kept: u64 = p.iter().map(|x| x.1).sum();
assert_eq!(kept, 800 * 100, "every kept frame once, the tag in none");
}
#[test]
fn the_playlist_lists_each_piece_as_a_range_of_the_original() {
let plan = Plan { cuts: vec![Cut(1000, 2000)], pieces: vec![(50, 400, 1.0), (500, 4000, 9.5)] };
assert_eq!(
playlist(&plan, "deadair.mp3"),
"#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:10\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n\
#EXTINF:1.000000,\n#EXT-X-BYTERANGE:400@50\ndeadair.mp3\n\
#EXTINF:9.500000,\n#EXT-X-BYTERANGE:4000@500\ndeadair.mp3\n#EXT-X-ENDLIST\n"
);
}
#[test]
fn positions_convert_both_ways() {
let cuts = [Cut(10_000, 20_000), Cut(30_000, 35_000)];
assert_eq!(to_copy(&cuts, 5_000), 5_000);
assert_eq!(to_copy(&cuts, 15_000), 10_000, "inside a cut is where it was made");
assert_eq!(to_copy(&cuts, 25_000), 15_000);
assert_eq!(to_copy(&cuts, 40_000), 25_000);
for t in [0, 5_000, 10_000, 15_000, 19_999, 20_000, 25_000, 40_000] {
let back = to_original(&cuts, to_copy(&cuts, t));
// A time inside a cut comes back as the cut's end: the shortened version has nothing between.
let expect = if (10_000..20_000).contains(&t) { 20_000 } else { t };
assert_eq!(back, expect, "{t}");
}
assert_eq!(saved(&cuts), 15_000);
}
#[test]
fn off_for_every_feed_until_turned_on_for_it_or_its_group() {
let p = |v: &[(&str, bool)]| v.iter().map(|(k, w)| (k.to_string(), *w)).collect::<HashMap<_, _>>();
assert!(!wants(&p(&[]), "a", None), "off until turned on for the feed");
assert!(!wants(&p(&[("b", true)]), "a", None), "another feed's says nothing of this one");
assert!(wants(&p(&[("a", true)]), "a", None));
assert!(wants(&p(&[("g", true)]), "a", Some("g")), "on for its group");
assert!(!wants(&p(&[("g", true), ("a", false)]), "a", Some("g")), "kept out of its group's");
}
#[test]
fn the_pause_is_the_feeds_then_its_groups_then_the_default() {
let w = Wanting {
people: HashMap::new(),
pauses: [((1, "a".to_string()), 500), ((1, "g".to_string()), 400)].into(),
groups: [("b".to_string(), "g".to_string())].into(),
};
assert_eq!(w.pause(1, "a"), 500);
assert_eq!(w.pause(1, "b"), 400, "its group's");
assert_eq!(w.pause(1, "c"), DEFAULT_PAUSE);
assert_eq!(w.pause(2, "a"), DEFAULT_PAUSE, "someone else's says nothing of yours");
}
#[test]
fn id3_len_reads_the_tags_size() {
assert_eq!(id3_len(b"ID3\x04\x00\x00\x00\x00\x02\x01xx"), 10 + 257);
assert_eq!(id3_len(b"ID3\x04\x00\x10\x00\x00\x02\x01"), 10 + 257 + 10, "with a footer");
assert_eq!(id3_len(b"\xff\xfb\x90\x00\x00\x00\x00\x00\x00\x00"), 0);
assert_eq!(id3_len(b"ID3"), 0);
}
/// The whole of it, on a clip ffmpeg makes: a second of tone, two of silence, and so on,
/// played back through its playlist by ffmpeg's own HLS reader. Skipped where ffmpeg is not
/// installed.
#[tokio::test]
async fn the_playlist_of_a_file_is_shorter_by_what_was_cut_and_plays() {
if !have_ffmpeg().await {
eprintln!("no ffmpeg; skipped");
return;
}
let dir = std::env::temp_dir().join(format!("ipx-deadair-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (ext, codec) in [("mp3", "libmp3lame"), ("aac", "aac"), ("m4a", "aac")] {
let src = dir.join(format!("t.{ext}"));
ffmpeg(&[
"-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(),
])
.await
.unwrap();
let plans = analyse(&src).await.unwrap();
if ext == "m4a" {
assert!(plans.is_none(), "AAC in MP4 has no byte ranges to play");
continue;
}
let plans = plans.expect("plans");
assert_eq!(plans.keys().copied().collect::<Vec<_>>(), PAUSES);
let p = &plans[&DEFAULT_PAUSE];
assert_eq!(p.cuts.len(), 4, "{ext}: {:?}", p.cuts);
// About 1.7 s from each of the four 2 s silences, and less for a longer pause.
let cut = p.saved_ms();
assert!((6_500..6_900).contains(&cut), "{ext}: {cut}");
assert!(plans[&500].saved_ms() < cut && cut < plans[&200].saved_ms());
let list = dir.join(format!("{ext}.m3u8"));
std::fs::write(&list, playlist(p, &format!("t.{ext}"))).unwrap();
// Its frames added up: a raw AAC file's duration is only guessed from its bitrate.
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])
.await
.unwrap()
.lines()
.filter_map(|l| l.trim().trim_end_matches(',').parse::<f64>().ok())
.sum::<f64>()
}
};
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());
// 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();
let bytes = parts.concat();
assert_eq!(bytes.len() as u64, n);
if ext == "mp3" {
assert!(bytes.starts_with(b"ID3"), "the original's tag comes too");
}
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());
}
std::fs::remove_dir_all(&dir).unwrap();
}
}
/// A real episode, for trying the cut on something other than a test tone:
/// `IPX_DEADAIR_FILE=/path/ep.mp3 cargo test real_file -- --ignored --nocapture`. Each pause's
/// playlist is left beside it, `ep.mp3.300.m3u8`, to listen to.
#[cfg(test)]
#[tokio::test]
#[ignore]
async fn real_file() {
let src = std::path::PathBuf::from(std::env::var("IPX_DEADAIR_FILE").expect("IPX_DEADAIR_FILE"));
let t = std::time::Instant::now();
let plans = analyse(&src).await.unwrap().expect("plans");
eprintln!("analysed in {:.1?}", t.elapsed());
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());
}
}

View File

@@ -172,10 +172,12 @@ pub async fn download(
.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);
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("reading body")?;
file.write_all(&chunk).await?;
done += chunk.len() as u64;
crate::metrics::STATS.download_bytes.fetch_add(chunk.len() as u64, std::sync::atomic::Ordering::Relaxed);
on_progress(done, total);
}
file.flush().await?;
@@ -207,6 +209,36 @@ pub async fn place(d: &Downloaded, dir: &Path) -> Result<PathBuf> {
Ok(dest)
}
/// Deletes what an enclosure's path names: a file, or the folder a torrent of several files came
/// down as. `remove_file` refused the folder, so the reaper warned on every pass and freed nothing,
/// and Delete failed (#158).
pub fn remove(path: &Path) -> std::io::Result<()> {
if path.is_dir() {
return std::fs::remove_dir_all(path);
}
std::fs::remove_file(path)
}
/// The file to play for an enclosure's path: the path itself, or for a torrent of several files,
/// the largest file in its folder, which is the episode (#158).
pub fn playable(path: &Path) -> PathBuf {
fn largest(dir: &Path, best: &mut Option<(u64, PathBuf)>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for e in entries.flatten() {
match e.metadata() {
Ok(m) if m.is_dir() => largest(&e.path(), best),
Ok(m) if best.as_ref().is_none_or(|(n, _)| m.len() > *n) => *best = Some((m.len(), e.path())),
_ => {}
}
}
}
let mut best = None;
if path.is_dir() {
largest(path, &mut best);
}
best.map_or_else(|| path.to_owned(), |(_, p)| p)
}
/// Advertised as a torrent, before spending any bandwidth on it.
pub fn looks_like_torrent(url: &str, mime: Option<&str>) -> bool {
mime.is_some_and(|m| m.to_ascii_lowercase().contains("torrent"))
@@ -305,6 +337,25 @@ pub fn matches_keywords(keywords: &[String], haystacks: &[&str]) -> bool {
})
}
/// Whether any of these words or phrases appears, as whole words, in the haystacks. Whole words,
/// unlike `matches_keywords`, because a block hides things: "ai" should not hide everything that
/// "said" something. Case and punctuation are ignored, so "A.I." is not caught by "ai", but
/// "Trump's" is by "trump".
pub fn blocked(words: &[String], haystacks: &[&str]) -> bool {
// Letters and digits only, each run of anything else one space, padded so a phrase matches
// at either end: " new york " is in " the new york times ", " york " is not in " yorkshire ".
let norm = |s: &str| {
let mut out = String::from(" ");
for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
out.push_str(&w.to_lowercase());
out.push(' ');
}
out
};
let hay = norm(&haystacks.join(" "));
words.iter().map(|w| norm(w)).any(|w| w.len() > 1 && hay.contains(&w))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -387,7 +438,7 @@ mod tests {
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,
max_new_per_check: None, username: None, password: None, password_env: None, category: None, listed: false,
};
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
@@ -396,6 +447,20 @@ mod tests {
assert_eq!(folder_for(&cfg, "id", &f, None), "etc/Show");
}
#[test]
fn a_torrent_folder_plays_its_largest_file_and_deletes_whole() {
let d = std::env::temp_dir().join(format!("ipx-torrent-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("show/extras")).unwrap();
std::fs::write(d.join("show/cover.jpg"), [0; 10]).unwrap();
std::fs::write(d.join("show/extras/episode.mp3"), [0; 100]).unwrap();
assert_eq!(playable(&d.join("show")), d.join("show/extras/episode.mp3"));
assert_eq!(playable(&d.join("show/cover.jpg")), d.join("show/cover.jpg"), "a file is itself");
remove(&d.join("show")).unwrap();
assert!(!d.join("show").exists());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn keyword_matching_is_or_across_keywords_and_and_within_one() {
let kws = vec!["deep dive".to_string(), "interview".to_string()];
@@ -404,4 +469,16 @@ mod tests {
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
}
#[test]
fn blocking_matches_whole_words_and_phrases() {
let words = vec!["AI".to_string(), "new york".to_string()];
assert!(blocked(&words, &["What AI means now"]));
assert!(blocked(&words, &["<p>ai, again</p>"]), "markup and punctuation are not words");
assert!(!blocked(&words, &["He said so", "Portrait"]), "not inside another word");
assert!(blocked(&words, &["Live from", "New York."]), "a phrase spans runs of space");
assert!(!blocked(&words, &["New Yorkshire"]));
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
assert!(!blocked(&[], &["anything"]));
}
}

View File

@@ -19,6 +19,10 @@ pub mod feeds {
/// The channel's first <itunes:category>, for the Directory.
#[sea_orm(column_type = "Text", nullable)]
pub category: Option<String>,
/// What the feed says it is, as it says it, for its page in the Directory. Added after
/// the table was, so `create_missing` adds it to a database without it.
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub etag: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
@@ -102,6 +106,10 @@ pub mod enclosures {
pub length: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
pub path: Option<String>,
/// 'pending' is queued: a scan downloads it on its own. 'held' is listed but outside the
/// feed's newest max_new_per_check, or its feed downloads nothing automatically; it can
/// be downloaded by hand (`Db::hold_back`). 'skipped' is filtered out ('last_error' says
/// why), then 'downloading', 'done', 'error', 'reaped'.
#[sea_orm(column_type = "Text")]
pub state: String,
#[sea_orm(default_value = 0)]
@@ -228,6 +236,46 @@ pub mod entry_state {
owned_by_user!();
}
/// Words someone never wants to see: an item whose title or text has one is hidden from them and
/// not downloaded on their account (issue #47). `feed_id` is empty for the list that applies to
/// every feed they read.
pub mod blocklists {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "blocklists")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
/// JSON array of strings.
#[sea_orm(column_type = "Text")]
pub words: String,
}
owned_by_user!();
}
/// The items someone's block lists hide from them, worked out whenever a list or the feed
/// changes (`Db::rehide`), since SQL on both databases cannot match whole words itself.
pub mod hidden {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "hidden")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_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,
}
owned_by_user!();
}
pub mod sessions {
use sea_orm::entity::prelude::*;
@@ -243,6 +291,29 @@ pub mod sessions {
owned_by_user!();
}
/// A token a script or agent sends as `Authorization: Bearer`, acting as the person who made
/// it (#123). Kept as its SHA-256, so the table is no use to anyone who reads it.
pub mod api_tokens {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "api_tokens")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
/// What its owner called it, to tell one from another when revoking.
#[sea_orm(column_type = "Text")]
pub name: String,
#[sea_orm(unique, column_type = "Text")]
pub hash: String,
pub created: i64,
pub last_used: Option<i64>,
}
owned_by_user!();
}
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
pub mod catalogue {
@@ -283,3 +354,67 @@ pub mod settings {
impl ActiveModelBehavior for ActiveModel {}
}
/// What DeadAir found in an enclosure (#181): `plans` is a `deadair::Plan` for each pause length,
/// as JSON keyed by the pause in ms, and `{}` for a file it does not apply to (video, not MP3 or
/// AAC, nothing to cut), so that one is looked at once and not every pass.
pub mod deadair_plans {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "deadair_plans")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub enclosure_id: i64,
#[sea_orm(column_type = "Text")]
pub plans: String,
/// Against the enclosure's `downloaded_at`: a file downloaded again is looked at again.
pub made_at: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
/// Who has DeadAir on, for which feeds: it is off for every feed until someone turns it on for
/// that one or its group, and `wanted` false keeps one feed of a group out of the group's.
pub mod deadair_prefs {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "deadair_prefs")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
pub wanted: bool,
/// The pause DeadAir leaves where it cuts a silence, in ms (#182); NULL for the usual.
pub pause_ms: Option<i64>,
}
owned_by_user!();
}
/// The time DeadAir has saved someone: one row per episode they got 75% through on a copy, so
/// replaying it never counts twice. Their total is the sum.
pub mod deadair_saved {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "deadair_saved")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_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 saved_ms: i64,
pub at: i64,
}
owned_by_user!();
}

View File

@@ -11,8 +11,13 @@ pub struct ParsedFeed {
pub title: Option<String>,
pub ttl_mins: Option<u64>,
pub image: Option<String>,
/// The channel's first `<itunes:category>`, for the Directory's chips.
/// The site the feed belongs to, where a favicon can stand in for missing artwork.
pub site: Option<String>,
/// The channel's first `<itunes:category>`, for the Directory's categories.
pub category: Option<String>,
/// What the feed says it is, for its page in the Directory (#130): RSS's `<description>`,
/// or iTunes' summary where that is empty, or Atom's subtitle. HTML as often as not.
pub description: Option<String>,
pub entries: Vec<Entry>,
}
@@ -51,42 +56,72 @@ pub enum Fetched {
},
}
/// Conditional GET. reqwest handles gzip and redirects; the original's hand-rolled
/// CONNECT/socket.ssl proxy path is gone -- `system-proxy` reads http_proxy/https_proxy.
/// The longest a feed may take, connecting to the last byte: a scan handles feeds in order, and
/// with no limit one hung server held every scan for as long as it did. Dreamwidth answered 504
/// after 60-67 s for a day, and each scan took 70-80 s instead of 15 (#108). A feed is small;
/// downloads, which are not, have no such limit.
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so
/// each hop is seen. The second value is where the feed now is when every hop said so for good
/// (301 or 308): a publisher that moved its feed, which the catalogue should follow rather
/// than be redirected on every read. A temporary redirect (302, 307) moves nothing.
/// `system-proxy` reads http_proxy/https_proxy.
#[tracing::instrument(skip_all, fields(url = %cfg.url))]
pub async fn fetch(
client: &reqwest::Client,
cfg: &FeedCfg,
etag: Option<&str>,
last_modified: Option<&str>,
) -> Result<Fetched> {
let mut req = client.get(&cfg.url);
if let Some(tag) = etag {
req = req.header(IF_NONE_MATCH, tag);
}
if let Some(lm) = last_modified {
req = req.header(IF_MODIFIED_SINCE, lm);
}
if let Some(user) = &cfg.username {
req = req.basic_auth(user, cfg.password());
}
) -> Result<(Fetched, Option<String>)> {
let start = reqwest::Url::parse(&cfg.url).context("the feed's address")?;
let mut url = start.clone();
let mut permanent = true;
for _ in 0..10 {
let mut req = client.get(url.clone()).timeout(FEED_TIMEOUT);
if let Some(tag) = etag {
req = req.header(IF_NONE_MATCH, tag);
}
if let Some(lm) = last_modified {
req = req.header(IF_MODIFIED_SINCE, lm);
}
// The feed's own host only: a redirect elsewhere must not be handed the password.
if let Some(user) = &cfg.username
&& url.host_str() == start.host_str()
{
req = req.basic_auth(user, cfg.password());
}
let resp = req.send().await.context("connecting")?;
if resp.status() == StatusCode::NOT_MODIFIED {
return Ok(Fetched::NotModified);
let resp = req.send().await.context("connecting")?;
let status = resp.status();
if status.is_redirection() && status != StatusCode::NOT_MODIFIED {
let to = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow!("HTTP {status} without a Location to go to"))?;
url = url.join(to).with_context(|| format!("redirected to {to:?}, which is not an address"))?;
permanent &= matches!(status, StatusCode::MOVED_PERMANENTLY | StatusCode::PERMANENT_REDIRECT);
continue;
}
let moved = (permanent && url != start).then(|| url.to_string());
if status == StatusCode::NOT_MODIFIED {
return Ok((Fetched::NotModified, moved));
}
if !status.is_success() {
// 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 etag = header(ETAG);
let last_modified = header(LAST_MODIFIED);
let bytes = resp.bytes().await.context("reading body")?.to_vec();
return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
}
let status = resp.status();
if !status.is_success() {
// 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 etag = header(ETAG);
let last_modified = header(LAST_MODIFIED);
let bytes = resp.bytes().await.context("reading body")?.to_vec();
Ok(Fetched::Body { bytes, etag, last_modified })
// Worded as reqwest worded it, which failure_kind reads as a redirect loop.
Err(anyhow!("error following redirect for url ({url}): too many redirects"))
}
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
@@ -96,6 +131,37 @@ pub struct Failure {
pub new_url: Option<String>,
}
/// A failure's kind, for the log's `error.type` (#91): the HTTP status where there is one, as
/// OpenTelemetry names an HTTP error, and otherwise a word for what went wrong. Matches the
/// same wording as `explain_failure`; a message it does not know is "other", never a wrong kind.
pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
let low = msg.to_ascii_lowercase();
let code = low.split("http ").skip(1).find_map(|r| r.get(..3)?.parse::<u16>().ok());
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") {
"dns"
} else if low.contains("too many redirects") {
"redirect_loop"
} else if low.contains("timed out") || low.contains("timeout") {
"timeout"
} else if low.contains("certificate") || low.contains("tls") {
"tls"
} else if low.contains("got a web page") {
"not_a_feed"
} else if low.contains("the site sent ") {
"site_message"
} else if low.contains("connect") {
"connect"
} else if low.contains("pars") {
"parse"
} else {
"other"
};
(kind.into(), None)
}
/// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the
/// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling
/// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled --
@@ -124,18 +190,31 @@ pub fn explain_failure(msg: &str) -> Option<Failure> {
if low.contains("http 401") || low.contains("http 403") {
return Some(Failure { reason: "The site refuses ipx's requests.", new_url: None });
}
if low.contains("answered with nothing") {
return Some(Failure { reason: "This address answers with nothing; the site may be gone.", new_url: None });
}
if low.contains("http 402") {
return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None });
}
if low.contains("dns error")
|| low.contains("failed to lookup address")
|| low.contains("no address associated")
// 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"))
{
return Some(Failure { reason: "This address no longer resolves; the site is gone.", new_url: None });
return Some(Failure { reason: "This address no longer resolves; the site may be gone.", new_url: None });
}
None
}
/// A host that could not be looked up for the moment -- the resolver's EAI_AGAIN, glibc's
/// "Temporary failure in name resolution", musl's "Try again" -- as against a name that does
/// not exist ("Name or service not known"). It says the network was not answering, not that the
/// feed is gone (#143).
pub fn is_temporary_lookup_failure(msg: &str) -> bool {
let low = msg.to_ascii_lowercase();
low.contains("dns error") && (low.contains("temporary failure in name resolution") || low.contains("try again"))
}
/// True when a body is an OPML document rather than a feed.
///
/// The original matched on the URL ending in ".opml" (iPXClass.py:34), which misses an
@@ -185,11 +264,20 @@ pub fn is_patreon_creator(url: &str) -> bool {
}
/// What was typed into Add feed, as a URL. A bare Patreon token is taken as its creator's
/// feed, since the token alone says whose it is.
/// feed, since the token alone says whose it is. An address with no scheme is https: 'cnn.com'
/// was stored as typed and every check failed with "relative URL without a base" (#101).
pub fn expand_input(input: &str) -> String {
let s = input.trim();
let token = s.len() >= 20 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if token { format!("https://www.patreon.com/rss?auth={s}") } else { s.to_owned() }
if token {
format!("https://www.patreon.com/rss?auth={s}")
} else if let Some(rest) = s.strip_prefix("//") {
format!("https://{rest}")
} else if !s.contains("://") {
format!("https://{s}")
} else {
s.to_owned()
}
}
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
@@ -210,7 +298,7 @@ pub async fn patreon_shows(
) -> 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).send().await.context("connecting")?;
let mut resp = client.get(url).timeout(FEED_TIMEOUT).send().await.context("connecting")?;
if !resp.status().is_success() {
return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status()));
}
@@ -330,7 +418,9 @@ fn plain_text(bytes: &[u8]) -> Option<String> {
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
let lower = text.to_lowercase();
// ASCII only: to_lowercase changes some characters' length (U+0130 grows a byte), and the
// offsets found in the lowered copy then sliced the original off a char boundary.
let lower = text.to_ascii_lowercase();
let mut pos = 0;
while let Some(rel) = lower[pos..].find("<link") {
let start = pos + rel;
@@ -349,6 +439,124 @@ fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
None
}
/// What someone asked to add, as a feed: the address itself when it is a feed or an OPML list,
/// otherwise the feed its web page links as its own, otherwise an error and nothing is added.
/// A page that linked no feed used to be added as it was and failed on every check, called a
/// 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")?;
anyhow::ensure!(resp.status().is_success(), "HTTP {}", resp.status());
let base = resp.url().clone();
anyhow::Ok((base, resp.bytes().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}"))?;
if is_feed(&body) {
return Ok(url.to_owned());
}
if !looks_like_html(&body) {
let why = parse(&body).err().map(|e| format!("{e:#}")).unwrap_or_default();
anyhow::bail!("{url} is not a feed: {why}");
}
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 (_, 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())
}
/// Artwork for a feed that has none: the icon its site's page names, or else the site's
/// `/favicon.ico`. None if neither is there.
#[tracing::instrument(skip_all, fields(site = site))]
pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
let timeout = std::time::Duration::from_secs(20);
let resp = client.get(site).timeout(timeout).send().await.ok()?;
// Relative to where the page ended up, not where it was asked for: a site that redirects
// to /en/ would otherwise have its icon looked for in the wrong place.
let base = resp.url().clone();
// 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 Some(href) = page_icon(&page)
&& let Ok(url) = base.join(&href)
&& is_image(client, url.as_str()).await
{
return Some(url.into());
}
let ico = base.join("/favicon.ico").ok()?;
is_image(client, ico.as_str()).await.then(|| ico.into())
}
/// 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
/// hosts the catalogue had on http served the same image on https; The Secret Cabal's CDN
/// presents another name's certificate (#110).
pub async fn prefer_https(
client: &reqwest::Client,
url: &str,
known: &mut std::collections::HashMap<String, bool>,
) -> String {
let Some(rest) = url.strip_prefix("http://") else { return url.to_owned() };
let host = rest.split('/').next().unwrap_or("").to_owned();
let secure = format!("https://{rest}");
let ok = match known.get(&host) {
Some(ok) => *ok,
None => {
let ok = is_image(client, &secure).await;
known.insert(host, ok);
ok
}
};
if ok { secure } else { url.to_owned() }
}
/// Whether `url` answers with an image. A site with no favicon often answers 200 with its home
/// page, which is not an icon.
pub async fn is_image(client: &reqwest::Client, url: &str) -> bool {
let timeout = std::time::Duration::from_secs(20);
let Ok(resp) = client.get(url).timeout(timeout).send().await else { return false };
resp.status().is_success()
&& resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|t| t.starts_with("image/"))
}
/// The icon a web page names in its `<link>` tags, the larger apple-touch-icon first: a plain
/// `icon` is often 16 pixels, which blurs at the size the list draws artwork.
fn page_icon(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
// ASCII only, so byte offsets in the lowered copy stay valid in the original.
let lower = text.to_ascii_lowercase();
let (mut touch, mut icon) = (None, None);
let mut pos = 0;
while let Some(at) = lower[pos..].find("<link") {
let start = pos + at;
let Some(end) = lower[start..].find('>').map(|e| start + e) else { break };
pos = end + 1;
let tag = &text[start..end];
let Some(rel) = tag_attr(tag, "rel").map(|r| r.to_ascii_lowercase()) else { continue };
let rels: Vec<&str> = rel.split_whitespace().collect();
if touch.is_none() && rels.iter().any(|r| r.starts_with("apple-touch-icon")) {
touch = tag_attr(tag, "href");
} else if icon.is_none() && rels.contains(&"icon") {
icon = tag_attr(tag, "href");
}
}
touch.or(icon).filter(|h| !h.is_empty())
}
/// The value of one attribute in an HTML/XML start tag, however it is quoted.
fn tag_attr(tag: &str, name: &str) -> Option<String> {
let key = format!("{name}=");
@@ -538,9 +746,11 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
.and_then(|i| i.image())
.map(str::to_owned)
.or_else(|| ch.image().map(|i| i.url().to_owned())),
site: non_empty(Some(ch.link().trim())),
// Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and
// would fill the Directory with one-off tags. The subcategory where there is one: Apple
// files every tabletop and gaming show under Leisure, which says little; Games says it.
description: non_empty(Some(ch.description())).or_else(|| non_empty(ch.itunes_ext().and_then(|i| i.summary()))),
category: ch
.itunes_ext()
.and_then(|i| i.categories().first())
@@ -603,7 +813,14 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
title: title_text(Some(feed.title().as_str())),
ttl_mins: None,
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
site: feed
.links()
.iter()
.find(|l| l.rel() == "alternate")
.map(|l| l.href().trim().to_owned())
.filter(|h| !h.is_empty()),
category: None,
description: non_empty(feed.subtitle().map(|t| t.as_str())),
entries,
}
}
@@ -809,8 +1026,151 @@ fn parse_date(s: &str) -> Option<i64> {
.map(|d| d.timestamp())
}
/// Apple's podcast categories, each with its subcategories
/// (https://podcasters.apple.com/support/1691-apple-podcasts-categories): the list a podcast's own
/// `<itunes:category>` comes from, and a fixed one, so the Directory's categories stay few.
const CATEGORIES: &[(&str, &[&str])] = &[
("Arts", &["Books", "Design", "Fashion & Beauty", "Food", "Performing Arts", "Visual Arts"]),
("Business", &["Careers", "Entrepreneurship", "Investing", "Management", "Marketing", "Non-Profit"]),
("Comedy", &["Comedy Interviews", "Improv", "Stand-Up"]),
("Education", &["Courses", "How To", "Language Learning", "Self-Improvement"]),
("Fiction", &["Comedy Fiction", "Drama", "Science Fiction"]),
("Government", &[]),
("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"]),
("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"]),
("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"]),
("Technology", &[]),
("True Crime", &[]),
("TV & Film", &["After Shows", "Film History", "Film Interviews", "Film Reviews", "TV Reviews"]),
];
/// Names that are not in Apple's list but mean one that is (#125). Jev picked from this list when
/// it spelt Football and Soccer its own way, and stored those answers; Software How-To is Apple's
/// Technology subcategory from before its 2019 list, which older feeds still name.
const ALIASES: &[(&str, &str)] =
&[("American Football", "Football"), ("Football (Soccer)", "Soccer"), ("Software How-To", "Technology")];
/// 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.
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();
let name = ALIASES.iter().find(|(old, _)| old.eq_ignore_ascii_case(name)).map_or(name, |(_, now)| now);
for (category, subs) in CATEGORIES {
if category.eq_ignore_ascii_case(name) {
return (category.to_string(), None);
}
if let Some(sub) = subs.iter().find(|s| s.eq_ignore_ascii_case(name)) {
return (category.to_string(), Some(sub.to_string()));
}
}
(name.to_string(), None)
}
/// 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
/// is taken however unsure, since an admin can change it and asking again would cost a call a scan.
pub async fn classify(client: &reqwest::Client, key: &str, feed: &ParsedFeed) -> Result<String> {
let items: Vec<&str> = feed.entries.iter().filter_map(|e| e.title.as_deref()).take(15).collect();
// A subcategory says which category it is in; without that, "Games" or "Drama" is ambiguous.
let mut criteria = serde_json::Map::new();
for (category, subs) in CATEGORIES {
criteria.insert(category.to_string(), serde_json::Value::Null);
for sub in *subs {
criteria.insert(sub.to_string(), format!("Within {category}").into());
}
}
let body = serde_json::json!({
"model": "jev-latest",
"state": { "title": feed.title, "recent_items": items },
"questions": { "category": {
"type": "choice",
"instructions": "Which category best describes what this feed publishes?",
"criteria": criteria,
}},
});
let answer: serde_json::Value = client
.post("https://api.typesafe.ai/v1/systemone")
.bearer_auth(key)
.json(&body)
.timeout(FEED_TIMEOUT)
.send()
.await?
.error_for_status()?
.json()
.await?;
picked(&answer)
}
fn picked(answer: &serde_json::Value) -> Result<String> {
let choice = answer["answers"]["category"]["choice"].as_str().unwrap_or_default();
CATEGORIES
.iter()
.flat_map(|(c, subs)| std::iter::once(c).chain(subs.iter()))
.find(|c| **c == choice)
.map(|c| c.to_string())
.ok_or_else(|| anyhow!("no category in the answer: {answer}"))
}
#[cfg(test)]
mod tests {
/// A server answering by path: /old moves for good to /new, /tmp for now, /chain for good
/// to /tmp, /new is the feed.
async fn redirecting_server() -> 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 {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let n = sock.read(&mut buf).await.unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
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(),
"/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(),
_ => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()),
};
let _ = sock.write_all(resp.as_bytes()).await;
});
}
});
format!("http://{addr}")
}
#[tokio::test]
async fn a_feed_that_moved_for_good_says_where_and_one_moved_for_now_does_not() {
let base = redirecting_server().await;
let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap();
let get = |path: &str| {
let cfg: crate::config::Feed = serde_json::from_value(serde_json::json!({ "url": format!("{base}{path}") })).unwrap();
let client = client.clone();
async move { super::fetch(&client, &cfg, None, None).await }
};
let (got, moved) = get("/old").await.unwrap();
assert!(matches!(got, super::Fetched::Body { .. }));
assert_eq!(moved, Some(format!("{base}/new")));
assert_eq!(get("/tmp").await.unwrap().1, None); // 302: for now
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();
assert_eq!(super::failure_kind(&looped).0, "redirect_loop");
}
use super::*;
#[test]
@@ -821,6 +1181,7 @@ mod tests {
assert_eq!(feed.title.as_deref(), Some("Test Cast"));
assert_eq!(feed.ttl_mins, Some(45));
assert_eq!(feed.category.as_deref(), Some("Podcasting"), "the first, by its subcategory");
assert_eq!(feed.description.as_deref(), Some("A synthetic feed used by the parser tests."));
assert_eq!(feed.entries.len(), 3);
let ep = &feed.entries[0];
@@ -845,6 +1206,17 @@ mod tests {
);
}
#[test]
fn a_feed_says_what_it_is_in_itunes_summary_or_atom_subtitle() {
// An empty <description> gives way to iTunes' summary (#130).
let rss = br#"<?xml version="1.0"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel><title>R</title><description> </description><itunes:summary>Said here.</itunes:summary></channel></rss>"#;
assert_eq!(parse(rss).unwrap().description.as_deref(), Some("Said here."));
let atom = br#"<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>A</title>
<subtitle>Or here.</subtitle><id>urn:a</id><updated>2026-01-01T00:00:00Z</updated></feed>"#;
assert_eq!(parse(atom).unwrap().description.as_deref(), Some("Or here."));
}
#[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>
@@ -928,6 +1300,20 @@ mod tests {
);
}
#[test]
fn failures_are_named_by_kind_for_the_log() {
let k = |m: &str| failure_kind(m);
assert_eq!(k("HTTP 404 Not Found"), ("404".into(), Some(404)));
assert_eq!(k("HTTP 503 Service Unavailable"), ("503".into(), Some(503)));
assert_eq!(
k("connecting: error following redirect for url (https://www.toddstashwick.com/): too many redirects").0,
"redirect_loop"
);
assert_eq!(k("connecting: dns error: failed to lookup address information").0, "dns");
assert_eq!(k("operation timed out").0, "timeout");
assert_eq!(k("something new").0, "other");
}
#[test]
fn explain_failure_translates_the_errors_the_ui_should_flag() {
assert_eq!(
@@ -937,8 +1323,17 @@ mod tests {
assert_eq!(explain_failure("HTTP 401 Unauthorized").unwrap().reason, "The site refuses ipx's requests.");
assert_eq!(explain_failure("HTTP 403 Forbidden").unwrap().reason, "The site refuses ipx's requests.");
assert_eq!(explain_failure("HTTP 402 Payment Required").unwrap().reason, "The feed now needs a paid plan.");
let dns = explain_failure("connecting: dns error: failed to lookup address information").unwrap();
assert_eq!(dns.reason, "This address no longer resolves; the site is gone.");
assert_eq!(
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();
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"));
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());
@@ -1093,6 +1488,10 @@ mod tests {
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
assert_eq!(expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}"));
assert_eq!(expand_input("https://example.com/rss"), "https://example.com/rss");
assert_eq!(expand_input("http://example.com/rss"), "http://example.com/rss");
assert_eq!(expand_input(" cnn.com "), "https://cnn.com");
assert_eq!(expand_input("example.com/feed.xml"), "https://example.com/feed.xml");
assert_eq!(expand_input("//example.com/rss"), "https://example.com/rss");
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}")));
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}")));
@@ -1157,6 +1556,24 @@ mod tests {
assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none");
}
#[test]
fn a_feed_link_after_non_ascii_text_is_found() {
// U+0130 lowercases to three bytes from two, which once shifted every offset after it.
let page = "<html><title>\u{130}stanbul \u{130}\u{130}</title>\
<link rel=\"alternate\" type=\"application/rss+xml\" href=\"/feed.xml\">";
assert_eq!(alternate_feed_link(page.as_bytes()).as_deref(), Some("/feed.xml"));
}
#[test]
fn page_icon_prefers_the_touch_icon() {
let page = br#"<head><link rel="stylesheet" href="/a.css">
<link rel="shortcut icon" href="/fav.ico">
<LINK REL="apple-touch-icon-precomposed" sizes="180x180" href='/touch.png'></head>"#;
assert_eq!(page_icon(page).as_deref(), Some("/touch.png"));
assert_eq!(page_icon(br#"<link rel="icon" href="i.svg">"#).as_deref(), Some("i.svg"));
assert_eq!(page_icon(br#"<link rel="stylesheet" href="/a.css">"#), None);
}
#[test]
fn an_items_picture_comes_from_the_most_deliberate_source() {
let xml = br#"<?xml version="1.0"?>
@@ -1208,5 +1625,29 @@ mod tests {
assert_eq!(pick_guid(None, None, Some("e"), Some("t")).as_deref(), Some("e"));
assert_eq!(pick_guid(None, None, None, None), None);
}
#[test]
fn picked_takes_only_an_apple_category() {
let answer = |c: &str| serde_json::json!({"answers": {"category": {"type": "choice", "choice": c}}});
assert_eq!(super::picked(&answer("Technology")).unwrap(), "Technology");
assert_eq!(super::picked(&answer("Tech News")).unwrap(), "Tech News", "a subcategory");
assert!(super::picked(&answer("Blogs")).is_err(), "not one of Apple's");
assert!(super::picked(&serde_json::json!({"error": "rate limited"})).is_err());
}
#[test]
fn category_path_finds_a_subcategory_s_category() {
use super::category_path;
assert_eq!(category_path("Tech News"), ("News".into(), Some("Tech News".into())));
assert_eq!(category_path("video games"), ("Leisure".into(), Some("Video Games".into())), "Apple's spelling");
assert_eq!(category_path("Technology"), ("Technology".into(), None));
assert_eq!(category_path("Homebrew"), ("Homebrew".into(), None), "not Apple's: its own category");
// Apple's spelling, an old answer of Jev's, a stray comma and Apple's old list (#125).
assert_eq!(category_path("Soccer"), ("Sports".into(), Some("Soccer".into())));
assert_eq!(category_path("Football (Soccer)"), ("Sports".into(), Some("Soccer".into())));
assert_eq!(category_path("Soccer,"), ("Sports".into(), Some("Soccer".into())));
assert_eq!(category_path("Football"), ("Sports".into(), Some("Football".into())));
assert_eq!(category_path("Software How-To"), ("Technology".into(), None));
}
}

View File

@@ -15,6 +15,9 @@ pub enum Event {
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 },
Progress {
feed: String,
/// Which enclosure this is about. Without it a UI cannot tell one download's
@@ -29,6 +32,8 @@ pub enum Event {
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 },
/// Terminal: a client that asked for work stops reading here.
ScanDone { feeds: usize },
@@ -50,6 +55,7 @@ impl Event {
"{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred"
),
Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"),
Event::FeedMoved { feed, from, to } => format!("{feed}: moved for good from {from} to {to}; following it"),
Event::Progress { file, done, total, .. } => match total {
Some(t) if *t > 0 => format!(
" {file}: {:.1}% ({:.1}/{:.1} MB)",
@@ -69,12 +75,13 @@ impl Event {
*bytes as f64 / 1_048_576.0
),
Event::Status { feeds, pending, downloaded } => {
format!("{feeds} feeds, {pending} pending, {downloaded} downloaded")
format!("{feeds} feeds, {pending} queued to download, {downloaded} downloaded")
}
Event::Error { msg } => format!("error: {msg}"),
// 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)"),
})
}
@@ -105,6 +112,11 @@ pub enum Command {
enclosure: i64,
},
Status,
/// Read the catalogue and server settings again from the database, which `ipx add`, `rm`
/// and `import` change in a process of their own. A daemon keeps them in memory, and with
/// its old copy wrote a removed feed back, and dropped an added one, at its next change of
/// its own (#120). Answered, as status is, with the counts after it.
Reload,
}
/// Where events go: the socket, the terminal, or both.
@@ -131,38 +143,13 @@ impl Emitter {
// Level by how much it matters. With 80-odd feeds in an OPML subscription, one
// line per feed per tick for "not due yet" would push everything worth reading
// out of the buffer within a few minutes.
let routine = match &e {
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
}
_ => false,
};
let bad = matches!(
&e,
Event::FeedError { .. } | Event::DownloadError { .. } | Event::Error { .. }
);
if let Some(line) = e.human() {
let line = line.trim();
if bad {
tracing::warn!(target: "ipx::scan", "{line}");
} else if routine {
tracing::debug!(target: "ipx::scan", "{line}");
} else {
tracing::info!(target: "ipx::scan", "{line}");
}
}
if let Some(tx) = &self.tx {
// The outbound half of the protocol, as it goes on the wire. Progress is the
// high-volume one, so it sits at debug.
if let Ok(json) = serde_json::to_string(&e) {
if matches!(e, Event::Progress { .. }) {
tracing::debug!(target: "ipx::io", "<- {json}");
} else {
tracing::info!(target: "ipx::io", "<- {json}");
}
}
log_event(&e, self.tx.is_some());
// A feed not due changes nothing a listener shows, and a scan has one for nearly every
// feed: 1,500 at once overflowed the channel's 1,024 for a page slow to read (#197).
let routine = matches!(&e, Event::FeedSkip { reason, .. } if reason.starts_with("not due"));
if let Some(tx) = &self.tx
&& !routine
{
// An error here only means nobody is listening yet.
let _ = tx.send(e.clone());
}
@@ -172,18 +159,77 @@ impl Emitter {
}
}
/// 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.
/// Level by how much it matters: with 80-odd feeds in an OPML subscription, a line per feed per
/// tick for "not due yet" would push everything worth reading out of the log view in minutes,
/// and Progress fires on every whole percent. `wire` also logs the event as it goes on the
/// socket, at debug: the admin page's Daemon I/O tab shows it, production's log leaves it out.
fn log_event(e: &Event, wire: bool) {
let json = serde_json::to_string(e).unwrap_or_default();
let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
let s = |k: &str| v.get(k).and_then(|x| x.as_str());
let n = |k: &str| v.get(k).and_then(|x| x.as_u64());
let (kind, code) = match e {
Event::FeedError { msg, .. } | Event::DownloadError { msg, .. } | Event::Error { msg } => {
let (k, c) = crate::feed::failure_kind(msg);
(Some(k), c)
}
_ => (None, None),
};
let routine = match e {
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
}
_ => false,
};
macro_rules! line {
($level:ident, $target:literal, $text:expr) => {
tracing::$level!(
target: $target,
ev = s("ev"), feed = s("feed"), msg = s("msg"), url = s("url"), reason = s("reason"), from = s("from"), to = s("to"),
new = n("new"), downloaded = n("downloaded"), failed = n("failed"),
torrents = n("torrents"), bytes = n("bytes"), feeds = n("feeds"),
pending = n("pending"), enclosure = n("enclosure"), files = n("files"),
"error.type" = kind, "http.response.status_code" = code,
"{}", $text
)
};
}
// The healthcheck's answer, every 30s: a reply on the socket rather than work done, so it
// stays with the rest of the conversation, and the Scans tab stays about scans.
if matches!(e, Event::Status { .. }) {
return line!(info, "ipx::io", format!("<- {json}"));
}
if wire {
tracing::debug!(target: "ipx::io", "<- {json}");
}
let text = e.human().map(|l| l.trim().to_owned()).unwrap_or_else(|| json.clone());
if kind.is_some() {
line!(warn, "ipx::scan", text)
} else if routine {
line!(debug, "ipx::scan", text)
} else {
line!(info, "ipx::scan", text)
}
}
/// True when something is already listening -- i.e. a daemon owns this socket.
pub async fn daemon_is_live(path: &Path) -> bool {
UnixStream::connect(path).await.is_ok()
}
/// Answers `status` for the socket, without the worker. The worker runs one job at a time, and a
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
/// dead. 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.
/// Answers `status` and `reload` for the socket, without the worker. The worker runs one job at a
/// time, and a healthcheck left waiting behind a scan or a long download timed out and called a
/// busy daemon dead; a reload left waiting would let the web UI write the old catalogue back in
/// 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() -> 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(
@@ -254,12 +300,10 @@ async fn handle(
}
match serde_json::from_str::<Command>(line) {
// Answered here, not queued behind whatever the worker is on: see StatusFn.
Ok(Command::Status) => {
Ok(cmd @ (Command::Status | Command::Reload)) => {
tracing::info!(target: "ipx::io", "-> {line}");
let ev = status().await;
if let Ok(json) = serde_json::to_string(&ev) {
tracing::info!(target: "ipx::io", "<- {json}");
}
let ev = status(cmd).await;
log_event(&ev, true);
let _ = reply.send(ev).await;
}
Ok(cmd) => {
@@ -274,6 +318,26 @@ async fn handle(
Ok(())
}
/// Tells a running daemon to read the catalogue again (see Command::Reload), and waits for it.
pub async fn reload(path: &Path) -> Result<()> {
let stream = UnixStream::connect(path).await?;
let (read, mut write) = stream.into_split();
write.write_all(b"{\"cmd\":\"reload\"}\n").await?;
let mut lines = BufReader::new(read).lines();
let answer = async {
// Other clients' events come here too; the answer is the status, or an error.
while let Some(line) = lines.next_line().await? {
match serde_json::from_str::<Event>(&line) {
Ok(Event::Status { .. }) => return Ok(()),
Ok(Event::Error { msg }) => anyhow::bail!("{msg}"),
_ => continue,
}
}
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")?
}
/// Sends one command to a running daemon and prints the events it produces.
pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
let stream = UnixStream::connect(path).await?;
@@ -303,6 +367,25 @@ pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
mod tests {
use super::*;
#[test]
fn a_feed_not_due_stays_off_the_live_updates() {
let (tx, mut rx) = broadcast::channel(4);
let out = Emitter::socket(tx, false);
for i in 0..10 {
out.emit(Event::FeedSkip { feed: format!("f{i}"), reason: "not due for 5m".into() });
}
out.emit(Event::FeedSkip { feed: "g".into(), reason: "not modified".into() });
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
}
#[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!(e.human().unwrap(), "x: moved for good from http://a/f to https://a/f; following it");
}
#[test]
fn commands_parse_from_the_wire_form() {
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
@@ -374,7 +457,7 @@ mod tests {
// would end its session.
let mut watcher = events.subscribe();
let status: StatusFn =
std::sync::Arc::new(|| Box::pin(async { Event::Status { feeds: 1, pending: 2, downloaded: 3 } }));
std::sync::Arc::new(|_| Box::pin(async { Event::Status { feeds: 1, pending: 2, downloaded: 3 } }));
let (client, server) = UnixStream::pair().unwrap();
tokio::spawn(handle(server, events.subscribe(), cmds, status));

File diff suppressed because it is too large Load Diff

305
src/metrics.rs Normal file
View File

@@ -0,0 +1,305 @@
//! Prometheus metrics for what logs and traces cannot show: the daemon's own state between
//! requests, such as how many database connections are in use (#194). Served on a port and a
//! thread of their own, with a runtime of their own, and made only of what is already in memory:
//! a scrape neither waits for the web server's threads or the database, whose pool running dry
//! is one of the things it is there to show, nor holds anything up itself. No sign-in, as
//! Prometheus exporters have none: checking a token is a database lookup. Written out by hand:
//! the text format is a few lines, and a client library would be a dependency for them.
use std::fmt::Write as _;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering::Relaxed};
/// What the daemon counts as it works, for /metrics to read without asking anything (#195).
/// Global: the web layer, the worker, downloads, artwork and DeadAir each add to it, and a handle
/// threaded through all of them was more code than the counts.
pub static STATS: Stats = Stats::new();
pub struct Stats {
pub http_in_flight: AtomicI64,
/// Live updates dropped for a page or socket client that fell behind the broadcast.
pub events_missed: AtomicU64,
pub downloads_active: AtomicI64,
pub download_bytes: AtomicU64,
/// Feeds a scan is fetching at this moment, up to its AHEAD.
pub feeds_fetching: AtomicI64,
/// When a scan last finished, Unix seconds; 0 for not since starting.
pub last_scan_done: AtomicI64,
/// Files someone wants shortened that DeadAir has not looked at, as of its last pass.
pub deadair_backlog: AtomicI64,
/// When DeadAir started on the file it is on, Unix seconds; 0 when it is on none.
pub deadair_busy_since: AtomicI64,
pub art_cache_bytes: AtomicU64,
/// Images the sweep threw out to keep under the limit: kept bytes sit at the limit by design,
/// so this is what says whether the limit is too small.
pub art_evicted: AtomicU64,
/// Artwork served from disk, and fetched from the publisher because it was not there.
pub art_hits: AtomicU64,
pub art_misses: AtomicU64,
/// The library as the healthcheck last counted it, every 30 s; -1 until it has.
pub feeds: AtomicI64,
pub pending: AtomicI64,
pub downloaded: AtomicI64,
/// 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.
jobs: std::sync::Mutex<std::collections::BTreeMap<&'static str, (u64, f64)>>,
}
impl Stats {
pub const fn new() -> Self {
Self {
http_in_flight: AtomicI64::new(0),
events_missed: AtomicU64::new(0),
downloads_active: AtomicI64::new(0),
download_bytes: AtomicU64::new(0),
feeds_fetching: AtomicI64::new(0),
last_scan_done: AtomicI64::new(0),
deadair_backlog: AtomicI64::new(0),
deadair_busy_since: AtomicI64::new(0),
art_cache_bytes: AtomicU64::new(0),
art_evicted: AtomicU64::new(0),
art_hits: AtomicU64::new(0),
art_misses: AtomicU64::new(0),
feeds: AtomicI64::new(-1),
pending: AtomicI64::new(-1),
downloaded: AtomicI64::new(-1),
job: std::sync::Mutex::new(None),
jobs: std::sync::Mutex::new(std::collections::BTreeMap::new()),
}
}
/// 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 {
*self.job.lock().unwrap() = Some((kind, std::time::Instant::now()));
Job(self)
}
}
pub struct Job(&'static Stats);
impl Drop for Job {
fn drop(&mut self) {
if let Some((kind, since)) = self.0.job.lock().unwrap().take() {
let mut jobs = self.0.jobs.lock().unwrap();
let e = jobs.entry(kind).or_default();
e.0 += 1;
e.1 += since.elapsed().as_secs_f64();
}
}
}
/// Counts one more of something in progress until dropped, however it ends.
pub struct Busy(&'static AtomicI64);
pub fn busy(gauge: &'static AtomicI64) -> Busy {
gauge.fetch_add(1, Relaxed);
Busy(gauge)
}
impl Drop for Busy {
fn drop(&mut self) {
self.0.fetch_sub(1, Relaxed);
}
}
/// What the metrics are read from.
pub struct Sources {
pub ctx: Arc<crate::Ctx>,
pub cmds: tokio::sync::mpsc::Sender<crate::ipc::Command>,
pub events: tokio::sync::broadcast::Sender<crate::ipc::Event>,
pub playing: crate::deadair::Playing,
/// The daemon's runtime, not the metrics thread's own.
pub runtime: tokio::runtime::Handle,
pub started: std::time::Instant,
}
/// Starts serving /metrics on `bind`, on a thread of its own, and says where.
pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
use anyhow::Context as _;
// Bound here, so a port already taken is the daemon's error at start, not a thread's later.
let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding metrics to {bind}"))?;
listener.set_nonblocking(true)?;
let addr = listener.local_addr()?;
let s = Arc::new(s);
std::thread::Builder::new().name("metrics".into()).spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Ok(rt) => rt,
Err(e) => return tracing::error!(error = %e, "metrics could not start"),
};
rt.block_on(async move {
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)) }
}),
);
match tokio::net::TcpListener::from_std(listener) {
Ok(l) => {
if let Err(e) = axum::serve(l, app).await {
tracing::error!(error = %e, "metrics stopped");
}
}
Err(e) => tracing::error!(error = %e, "metrics could not listen"),
}
});
})?;
Ok(addr)
}
/// Every metric, from memory alone.
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_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);
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)]);
let (rss, fds) = process();
if let Some(v) = rss {
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
}
if let Some(v) = fds {
gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]);
}
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
}
/// The work in progress and done, from `STATS`: jobs, queues and what is in flight (#195).
fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_limit: u64) {
let now = crate::db::now();
let job = *st.job.lock().unwrap();
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())]);
}
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<_>>();
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_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)]);
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)]);
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_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_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)]);
for (name, help, v) in [
("ipx_feeds", "Feeds in the catalogue, as the healthcheck last counted.", &st.feeds),
("ipx_downloads_pending", "Files a scan will download on its own, as the healthcheck last counted.", &st.pending),
("ipx_files_downloaded", "Files on disk, as the healthcheck last counted.", &st.downloaded),
] {
let v = v.load(Relaxed);
if v >= 0 {
gauge(out, name, help, &[("", v as f64)]);
}
}
}
/// 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);
}
/// One gauge in Prometheus's text format: its help, its type, and a line for each set of labels
/// (`state="idle"`, or "" for none).
pub fn gauge(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
write(out, "gauge", name, help, samples);
}
fn write(out: &mut String, kind: &str, name: &str, help: &str, samples: &[(&str, f64)]) {
let _ = writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}");
for (labels, value) in samples {
let labels = if labels.is_empty() { String::new() } else { format!("{{{labels}}}") };
let _ = writeln!(out, "{name}{labels} {value}");
}
}
/// 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()?;
Some(kb * 1024.0)
});
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
(rss, fds)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gauges_are_written_as_prometheus_reads_them() {
let mut out = String::new();
gauge(&mut out, "ipx_db_connections", "Connections.", &[("state=\"idle\"", 3.0), ("state=\"in_use\"", 1.0)]);
gauge(&mut out, "ipx_feeds", "Feeds.", &[("", 1535.0)]);
assert_eq!(
out,
"# HELP ipx_db_connections Connections.\n# TYPE ipx_db_connections gauge\n\
ipx_db_connections{state=\"idle\"} 3\nipx_db_connections{state=\"in_use\"} 1\n\
# HELP ipx_feeds Feeds.\n# TYPE ipx_feeds gauge\nipx_feeds 1535\n"
);
}
#[test]
fn jobs_and_work_in_progress_are_counted_however_they_end() {
static T: Stats = Stats::new();
{
let _j = T.job("scan");
let _b = busy(&T.downloads_active);
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}");
}
// 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_event_queue 3\n") && out.contains("ipx_feed_retries 1\n"));
// The library's counts are left out until the healthcheck has counted it.
assert!(!out.contains("ipx_feeds "));
}
#[test]
fn the_process_reads_its_memory_and_files_on_linux() {
let (rss, fds) = process();
assert!(rss.unwrap() > 0.0 && fds.unwrap() > 0.0);
}
}

View File

@@ -1,9 +1,10 @@
//! Disk quota and age sweeps. Replaces iPXQuotaManager.py ("SmartSpace").
use anyhow::Result;
use tracing::Instrument;
use crate::config::Config;
use crate::db::{Candidate, Db, now};
use crate::db::{Candidate, Db, now, step};
/// Headroom kept below the quota, as getToQuota()'s `1048576 * 50` did.
pub const PAD_BYTES: u64 = 50 * 1024 * 1024;
@@ -49,7 +50,8 @@ 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.
for (id, path) in db.missing_files().await? {
// 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? {
if !dry_run {
db.mark_reaped(id).await?;
}
@@ -57,16 +59,20 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
report.reconciled += 1;
}
let candidates = db.reap_candidates().await?;
for id in db.prune_abandoned_failures().instrument(step("prune_abandoned_failures")).await? {
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
}
let candidates = db.reap_candidates().instrument(step("reap_candidates")).await?;
if cfg.general.max_age_days > 0 {
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
report.aged_out = aged(&candidates, cutoff);
for c in &report.aged_out {
report.bytes_freed += remove(db, c, dry_run).await?;
report.bytes_freed += remove(db, c, dry_run).instrument(step("remove_aged")).await?;
}
if !dry_run {
report.entries_pruned = db.prune_entries(cutoff).await?;
report.entries_pruned = db.prune_entries(cutoff).instrument(step("prune_entries")).await?;
}
}
@@ -81,7 +87,7 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
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 {
report.bytes_freed += remove(db, c, dry_run).await?;
report.bytes_freed += remove(db, c, dry_run).instrument(step("remove_over_quota")).await?;
}
}
@@ -92,9 +98,13 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
if dry_run {
return Ok(c.bytes.max(0) as u64);
}
let size = std::fs::metadata(&c.path).map(|m| m.len()).unwrap_or(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());
// Missing is fine: the point is that it is gone.
if let Err(e) = std::fs::remove_file(&c.path)
if let Err(e) = crate::download::remove(std::path::Path::new(&c.path))
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(path = c.path, error = %e, "could not delete");
@@ -185,11 +195,36 @@ mod tests {
);
}
#[tokio::test]
async fn the_marks_of_someone_who_left_count_for_nothing() {
// ray and sam subscribe; left read one episode and pinned the others before unsubscribing.
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false),(3,'left',false);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'heard', 0), ('f', 'pinned', 0), ('f', 'old', 0);
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
(3, 'f', 'heard', true, false),
(1, 'f', 'heard', true, false),
(3, 'f', 'pinned', false, true),
(3, 'f', 'old', false, true);
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
.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 prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f');
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'has-file', 100),

File diff suppressed because it is too large Load Diff

View File

@@ -58,5 +58,43 @@ for (const [name, p] of Object.entries(themes)) {
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
if (p.line === p.panel2) { bad++; console.log(`FAIL ${name}: --line is --panel2, so borders on it vanish`); }
}
// Glass draws its text on a coloured wash, or on a panel that lets the wash through, so its hex
// grounds are not what the text lands on. Sample the wash the way the browser composites it, on a
// laptop, a phone and a tablet, and hold the text to AA wherever it is darkest or lightest.
const washes = [...css.matchAll(/radial-gradient\((\d+)% (\d+)% at (\d+)% (\d+)%,var\(--wash(\d)\)/g)]
.map(m => ({ rx: +m[1], ry: +m[2], cx: +m[3], cy: +m[4], n: m[5] }));
const rgba = (sel, n) => {
const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`));
return [[+m[1], +m[2], +m[3]], +m[4]];
};
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
const over = (c, a, g) => g.map((x, i) => c[i] * a + x * (1 - a));
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) {
const p = themes[name];
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
const sm = css.slice(css.indexOf(sel + ' {')).match(/--glass-sheen:rgba\((\d+),(\d+),(\d+),([\d.]+)\)/);
const sheen = [[+sm[1], +sm[2], +sm[3]], +sm[4]];
const worst = {};
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
let g = rgb(p.bg);
for (const [w, [c, a]] of tint) {
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
g = over(c, a * Math.max(0, 1 - d), g);
}
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it,
// and a panel's top-left corner under its sheen at full strength.
const panel = over(rgb(p.panel), .7, g);
for (const [gn, gc] of [['the wash', g], ['a panel', panel], ['a panel\'s sheen', over(...sheen, panel)]])
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`;
if (!(k in worst) || r < worst[k]) worst[k] = r;
}
}
if (!washes.length) { bad++; console.log('FAIL glass: no wash gradients found in app.css'); }
for (const [k, r] of Object.entries(worst))
if (r < 4.5) { bad++; console.log(`FAIL ${name}: ${k} is ${r.toFixed(2)}:1 at worst, needs 4.5`); }
}
if (bad) process.exit(1);
console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`);

132
tests/dom-stub.js Normal file
View File

@@ -0,0 +1,132 @@
// The stub DOM the page's script is loaded against, shared by page-smoke.js and
// native-bridge.js. It is deliberately thin: enough for every handler the script wires at load
// to find what it reaches for, and no more.
//
// `media` swaps the bare `#audio` proxy for something with the parts of HTMLMediaElement that
// matter -- a prototype carrying the real accessors, and events that actually dispatch -- because
// native.ts replaces that surface on the element and a proxy that answers everything would prove
// nothing about whether it worked.
const vm = require('vm');
function makeContext(html, script, { media = false } = {}) {
// Ids in the page, and in the markup the script builds for its dialogs.
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
const missing = [];
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(){},
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) });
const body = el('body');
const audio = media ? makeMediaElement() : null;
if (media) {
// native.ts reads has-video off the body to decide whether the host takes the file, so this
// one has to be a real set rather than something that always says no.
const classes = new Set();
body.classList = {
add: c => classes.add(c), remove: c => classes.delete(c),
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : on ? classes.add(c) : classes.delete(c)),
contains: c => classes.has(c),
};
}
const document = {
querySelector(sel) {
if (sel === '#audio' && audio) return audio;
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
return el(sel);
},
querySelectorAll: () => [],
createElement: () => el('created'),
addEventListener(){}, body,
documentElement: { dataset: {} },
};
const ctx = {
document, console, crypto: require("crypto").webcrypto, performance,
window: { isSecureContext: false, addEventListener(){} },
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
fetch: (url) => Promise.resolve({
ok: true, status: 200, text: () => Promise.resolve(''),
json: () => Promise.resolve(
String(url).includes('/api/settings')
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
: String(url).includes('/api/users')
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
: /\/api\/(popular|directory)/.test(String(url))
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
}),
EventSource: function () { this.close = () => {}; },
MediaMetadata: function () {},
Blob: function () {},
getComputedStyle: () => ({ backgroundColor: '' }),
setTimeout, clearTimeout, setInterval, clearInterval,
confirm: () => false, prompt: () => null, alert(){},
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
};
if (media) {
ctx.HTMLMediaElement = MediaElement;
ctx.Event = Event;
ctx.isFinite = isFinite;
ctx.CSS = { escape: s => String(s) };
}
ctx.globalThis = ctx;
ctx.window.location = { href: '', hash: '' };
ctx.location = ctx.window.location;
return { ctx, missing, audio, body, ids };
}
/* ---- just enough HTMLMediaElement for the shim to be worth testing ---- */
function Event(type) { this.type = type; }
function MediaElement() {
this._src = ''; this._t = 0; this._dur = NaN; this._paused = true;
this._ready = 0; this._vol = 1; this._rate = 1;
this._listeners = {};
this.dataset = {}; this.classList = { add(){}, remove(){}, toggle(){}, contains(){ return false; } };
// Every call that reached the real element, so a test can say the host took over rather than
// the element quietly playing as well.
this.calls = [];
}
MediaElement.prototype.play = function(){ this.calls.push('play'); this._paused = false; return Promise.resolve(); };
MediaElement.prototype.pause = function(){ this.calls.push('pause'); this._paused = true; };
MediaElement.prototype.load = function(){ this.calls.push('load'); };
MediaElement.prototype.addEventListener = function(name, fn, opts){
(this._listeners[name] || (this._listeners[name] = [])).push({ fn, once: !!(opts && opts.once) });
};
MediaElement.prototype.dispatchEvent = function(ev){
for (const l of (this._listeners[ev.type] || []).slice()) {
if (l.once) this._listeners[ev.type] = this._listeners[ev.type].filter(x => x !== l);
l.fn(ev);
}
return true;
};
MediaElement.prototype.removeAttribute = function(name){ this.calls.push('removeAttribute:' + name); if (name === 'src') this._src = ''; };
MediaElement.prototype.setAttribute = function(){};
MediaElement.prototype.getAttribute = function(){ return null; };
const accessor = (k, field, log) => Object.defineProperty(MediaElement.prototype, k, {
configurable: true,
get(){ return this[field]; },
set(v){ if (log) this.calls.push(k + ':' + v); this[field] = v; },
});
accessor('src', '_src', true);
accessor('currentTime', '_t', true);
accessor('volume', '_vol');
accessor('playbackRate', '_rate');
Object.defineProperty(MediaElement.prototype, 'duration', { configurable: true, get(){ return this._dur; } });
Object.defineProperty(MediaElement.prototype, 'paused', { configurable: true, get(){ return this._paused; } });
Object.defineProperty(MediaElement.prototype, 'readyState', { configurable: true, get(){ return this._ready; } });
function makeMediaElement(){ return new MediaElement(); }
module.exports = { makeContext, vm };

54
tests/load/browse.js Normal file
View File

@@ -0,0 +1,54 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { BASE, FEEDS, ITEMS, as, me, pick, feedId, p95 } from './lib.js';
// An evening's browsing: twenty-five people at once opening their feed list, All Subscriptions,
// a feed, a search, the Directory and a feed's page in it. Each answer's budget is well above what
// it takes now, so it fails on a query that has started asking once per feed -- the Directory
// asked the database three questions a feed until 0.10.0 -- not on a slow minute. Fifty measured
// the queue for what was SQLite's one connection (#136) more than any query.
export const options = {
scenarios: {
evening: {
executor: 'ramping-vus',
stages: [{ duration: '15s', target: 25 }, { duration: '45s', target: 25 }, { duration: '10s', target: 0 }],
},
},
thresholds: {
http_req_failed: ['rate==0'],
checks: ['rate==1'],
// About twice each one's p95 on 2026-10-05 on Tower, and no less than 50ms, where a few ms
// either way is noise. SQLite: 89, 17, 10, 22, 112, 126ms; Postgres: 54, 34, 24, 44, 71, 88ms.
'http_req_duration{name:feeds}': p95(200, 150),
'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),
},
};
export default function () {
const u = me();
const feeds = http.get(`${BASE}/api/feeds`, as(u, 'feeds'));
check(feeds, { 'your thirty feeds': r => r.status === 200 && r.json().length === 30 });
const all = http.get(`${BASE}/api/entries?limit=50&filter=all&sort=published&dir=desc`, as(u, 'all-entries'));
check(all, { 'All Subscriptions, a page of it': r => r.status === 200 && r.json().entries.length === 50 });
const f = pick(feeds.json());
const one = http.get(`${BASE}/api/feeds/${f.id}/entries?limit=50&sort=title&dir=asc`, as(u, 'feed-entries'));
check(one, { 'a feed of yours, every item': r => r.status === 200 && r.json().entries.length === ITEMS });
// Every generated item mentions the weather, in its text, not its title.
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 });
sleep(1 + Math.random() * 2);
}

19
tests/load/lib.js Normal file
View File

@@ -0,0 +1,19 @@
// What the load tests share. Each virtual user is a listener of its own, signed in by name with
// the header the scratch daemon trusts from 127.0.0.1, as production trusts Cloudflare Access's.
export const BASE = __ENV.BASE;
export const USERS = Number(__ENV.USERS || 100);
export const FEEDS = Number(__ENV.FEEDS || 1500);
// Each generated feed has this many items; see run.js.
export const ITEMS = 20;
export const as = (name, tag) => ({
headers: { 'X-Load-User': name, 'Content-Type': 'application/json' },
tags: { name: tag },
});
/// The listener this virtual user is, one of the hundred run.js seeded, thirty feeds each.
export const me = () => `load-${(__VU - 1) % USERS}`;
export const pick = a => a[Math.floor(Math.random() * a.length)];
/// A p95 budget, in ms, for whichever database the daemon is on (run.js --postgres): SQLite reads
/// a file beside the daemon, Postgres answers over the network, and each is quicker at something.
export const p95 = (sqlite, postgres) => [`p(95)<${__ENV.DB === 'postgres' ? postgres : sqlite}`];
export const feedId = n => `gen-${String(n).padStart(4, '0')}`;

57
tests/load/listening.js Normal file
View File

@@ -0,0 +1,57 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { BASE, as, me, pick, p95 } from './lib.js';
// Players saving where people are while scans write. A hundred listeners each save a position
// every second -- the player saves every ten, so this is a thousand people listening -- mark an
// item read now and then, and read their position back to see it is theirs and as they left it,
// while one of them forces a scan of all their feeds every five seconds. On SQLite every one of
// these is a write waiting its turn for the one writer.
export const options = {
scenarios: {
listeners: { executor: 'constant-vus', vus: 100, duration: '60s', exec: 'listen' },
scans: { executor: 'constant-vus', vus: 1, duration: '60s', exec: 'scan' },
},
thresholds: {
http_req_failed: ['rate==0'],
checks: ['rate==1'],
// About twice each one's p95 on 2026-10-05 on Tower. SQLite: 21, 277 and 134ms; Postgres: 9,
// 225 and 153ms. Marking read answers with the feed's row, counts and all, hence its budget.
'http_req_duration{name:position}': p95(100, 100),
'http_req_duration{name:flags}': p95(600, 500),
'http_req_duration{name:read-back}': p95(300, 400),
},
};
// Each virtual user's own episode, and how far into it it is.
let ep = null, secs = 0, n = 0;
export function listen() {
const u = me();
if (!ep) {
const r = http.get(`${BASE}/api/entries?limit=50`, as(u, 'pick'));
ep = pick(r.json().entries.filter(e => e.duration));
}
secs += 1;
const saved = http.post(`${BASE}/api/entries/${encodeURIComponent(ep.feed_id)}/${encodeURIComponent(ep.guid)}/position`,
JSON.stringify({ secs, duration: ep.duration }), as(u, 'position'));
check(saved, { 'position saved': r => r.status === 204 });
if (++n % 10 === 0) {
const flags = http.post(`${BASE}/api/entries/${encodeURIComponent(ep.feed_id)}/${encodeURIComponent(ep.guid)}/flags`,
JSON.stringify({ read: n % 20 === 0 }), as(u, 'flags'));
check(flags, { 'marked read or unread': r => r.status === 200 });
const back = http.get(`${BASE}/api/feeds/${encodeURIComponent(ep.feed_id)}/entries?limit=50`, as(u, 'read-back'));
const mine = back.status === 200 && back.json().entries.find(e => e.guid === ep.guid);
check(mine, {
'your position, as you left it': e => e && e.position === secs,
'read as you marked it': e => e && e.read === (n % 20 === 0),
});
}
sleep(1);
}
export function scan() {
const r = http.post(`${BASE}/api/fetch`, JSON.stringify({ force: true }), as('load-0', 'fetch'));
check(r, { 'scan queued': r => r.status === 202 || r.status === 200 });
sleep(5);
}

48
tests/load/media.js Normal file
View File

@@ -0,0 +1,48 @@
import http from 'k6/http';
import { check } from 'k6';
import { BASE, as, pick } from './lib.js';
// Listeners seeking: fifty at once asking for ranges of the same few episodes, as a player does
// on every seek and every few seconds of playback. Every range is checked against what the feed
// served, byte by byte at a sample of offsets, so a wrong offset fails, not only a wrong status.
export const options = {
scenarios: { seeking: { executor: 'constant-vus', vus: 50, duration: '45s' } },
thresholds: {
http_req_failed: ['rate==0'],
checks: ['rate==1'],
// About four times its p95 on 2026-10-05, 48ms on SQLite and 45 on Postgres: a range is a
// file read, little to vary.
'http_req_duration{name:range}': ['p(95)<200'],
},
};
// The generated file: ID3's ten bytes, then a byte its offset gives (run.js, mediaByte).
const SIZE = 2 * 1024 * 1024;
const byte = k => (k * 31 + 7) & 255;
export function setup() {
const r = http.get(`${BASE}/api/entries?filter=downloaded&limit=50`, as('listener', 'setup'));
const files = r.json().entries.flatMap(e => e.enclosures).filter(x => x.path).map(x => x.id);
if (!files.length) throw new Error('the listener has no downloaded files to seek through');
return { files };
}
export default function ({ files }) {
const start = 10 + Math.floor(Math.random() * (SIZE - 11));
const end = Math.min(SIZE - 1, start + Math.floor(Math.random() * 65536));
const r = http.get(`${BASE}/media/${pick(files)}`, {
headers: { 'X-Load-User': 'listener', Range: `bytes=${start}-${end}` },
responseType: 'binary',
tags: { name: 'range' },
});
const body = r.status === 206 ? new Uint8Array(r.body) : new Uint8Array(0);
check(r, {
'part of the file': r => r.status === 206,
'the range asked for': r => r.headers['Content-Range'] === `bytes ${start}-${end}/${SIZE}`,
'its bytes': () => {
if (body.length !== end - start + 1) return false;
for (const at of [0, body.length - 1, ...Array.from({ length: 30 }, () => Math.floor(Math.random() * body.length))])
if (body[at] !== byte(start + at)) return false;
return true;
},
});
}

280
tests/load/run.js Normal file
View File

@@ -0,0 +1,280 @@
// Load tests: k6 against a scratch daemon with a catalogue the size of production's, for what
// the browser tests cannot show -- many people at once, and what that does to latency, to the
// database and to the healthcheck. The browser suite drives one person against a handful of
// feeds, one request at a time.
//
// node tests/load/run.js [--postgres] [browse|listening|media|signin ...] default: all four
//
// It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure
// the build, not ipx), serves generated feeds from this process, starts a daemon on its own
// config and data under /tmp/ipx-load, wiped first, seeds listeners, then runs each k6 script.
// While each runs, `ipx status` -- the Docker healthcheck -- is run every second, and the run
// fails if any answer takes as long as the healthcheck's 5s timeout.
//
// The daemon is on SQLite unless --postgres, which puts it in IPX_TEST_DATABASE_URL, the
// database the Rust tests use on Postgres too (ipodderx_test on the server production's is on, in
// /src/.envrc), in a schema of its own, ipx_load, made afresh each run (#139). Postgres's numbers
// are what production would see.
// IPX_LOAD_FEEDS sets the catalogue's size (1500, near production's), IPX_LOAD_USERS the
// listeners (100), IPX_LOAD_LOG=1 shows the daemon's log.
const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawn, spawnSync, execFile, execFileSync } = require('child_process');
const repo = path.resolve(__dirname, '../..');
const root = '/tmp/ipx-load';
const WEB = 8793, GEN = 8794;
const FEEDS = Number(process.env.IPX_LOAD_FEEDS || 1500);
const USERS = Number(process.env.IPX_LOAD_USERS || 100);
// The first few feeds carry real files, downloaded for the media test.
const MEDIA = 4;
const ITEMS = 20;
const BASE = `http://127.0.0.1:${WEB}`;
const bin = path.join(repo, 'target/release/ipx');
const SCRIPTS = ['browse', 'listening', 'media', 'signin'];
const POSTGRES = process.argv.includes('--postgres');
const PG_URL = process.env.IPX_TEST_DATABASE_URL || '';
const PG_SCHEMA = 'ipx_load';
const env = {
...process.env,
IPX_CONFIG: `${root}/config/config.toml`,
IPX_DATA_DIR: `${root}/data`,
IPX_LOG: 'ipx=info',
IPX_LOG_FORMAT: 'json',
// Nothing of the test reaches production's database or traces, or a paid API.
// In its own schema, as Db::memory puts each Rust test, and with notices off, as url_for does.
IPX_DATABASE_URL: POSTGRES
? `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}%20-c%20client_min_messages%3Dwarning`
: '',
OTEL_EXPORTER_OTLP_ENDPOINT: '',
TYPESAFE_KEY: '',
};
// ---- the feeds ------------------------------------------------------------------------------
// Apple's categories, some with a subcategory, so the Directory has tiles and pages to draw.
const CATS = [['Technology'], ['News', 'Tech News'], ['Comedy'], ['Leisure', 'Video Games'],
['Society & Culture', 'Documentary'], ['Sports', 'Soccer'], ['Arts', 'Books'],
['Science', 'Astronomy'], ['History'], ['True Crime'], ['Business', 'Investing'],
['Education', 'Self-Improvement'], ['Health & Fitness', 'Mental Health'], ['Music']];
const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const now = Date.now();
// Every third feed is a blog: no files, as most of production's are.
const isBlog = n => n % 3 === 2 && n >= MEDIA;
function feedXml(n) {
const [cat, sub] = CATS[n % CATS.length];
const category = sub
? `<itunes:category text="${esc(cat)}"><itunes:category text="${esc(sub)}"/></itunes:category>`
: `<itunes:category text="${esc(cat)}"/>`;
const items = Array.from({ length: ITEMS }, (_, i) => `<item>
<title>Episode ${ITEMS - i} of Show ${n}</title><guid>gen-${n}-${i}</guid>
<pubDate>${new Date(now - (i * 3 + n % 3) * 86400e3).toUTCString()}</pubDate>
<description>&lt;p&gt;Show ${n}, episode ${ITEMS - i}: an hour on the news, the weather and whatever came up.&lt;/p&gt;</description>
${isBlog(n) ? '' : `<itunes:duration>${1800 + i * 60}</itunes:duration>
<enclosure url="http://127.0.0.1:${GEN}/media/${n}/${i}.mp3" length="${MEDIA_BYTES.length}" type="audio/mpeg"/>`}
</item>`).join('');
return `<?xml version="1.0"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"><channel>
<title>Generated Show ${n}</title><link>http://127.0.0.1:${GEN}/site/${n}</link>
<description>Generated show number ${n}, for the load tests.</description>
<itunes:image href="http://127.0.0.1:${GEN}/art/${n}.jpg"/>${category}${items}</channel></rss>`;
}
// A file whose every byte is known from its offset, so the media test can check that a range
// it asked for is the range it got, not merely that it got something that long. ID3 first, so
// ipx takes it for audio.
const mediaByte = k => (k * 31 + 7) & 255;
const MEDIA_BYTES = Buffer.from(Uint8Array.from({ length: 2 * 1024 * 1024 }, (_, k) => mediaByte(k)));
Buffer.from('ID3\x03\x00\x00\x00\x00\x00\x00', 'latin1').copy(MEDIA_BYTES);
const art = fs.readFileSync(path.join(repo, 'tests/ui/fixtures/art.jpg'));
function serveFeeds() {
return http.createServer((req, res) => {
const m = req.url.match(/^\/(feed|media|art)\/(\d+)/);
if (m?.[1] === 'feed') { res.writeHead(200, { 'content-type': 'application/rss+xml' }); return res.end(feedXml(Number(m[2]))); }
if (m?.[1] === 'media') { res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': MEDIA_BYTES.length }); return res.end(MEDIA_BYTES); }
if (m?.[1] === 'art') { res.writeHead(200, { 'content-type': 'image/jpeg' }); return res.end(art); }
res.writeHead(404).end();
}).listen(GEN, '127.0.0.1');
}
const feedId = n => `gen-${String(n).padStart(4, '0')}`;
const feedUrl = n => `http://127.0.0.1:${GEN}/feed/${n}.xml`;
// ---- the daemon -----------------------------------------------------------------------------
/// The load tests' schema dropped and made again, as /tmp/ipx-load is wiped. Only in a database
/// made for tests: the URL is production's server and role, and one slip of a name would
/// otherwise point this at production's database.
function freshPostgres() {
const u = PG_URL && new URL(PG_URL);
if (!u || !/_test$/.test(u.pathname)) {
throw new Error('--postgres needs IPX_TEST_DATABASE_URL, a database whose name ends in _test (. /src/.envrc)');
}
psql(`DROP SCHEMA IF EXISTS ${PG_SCHEMA} CASCADE`, `CREATE SCHEMA ${PG_SCHEMA}`);
}
/// Statements run in the test database, given by its URL in psql's environment, not its
/// arguments, where every process on Tower could read the password.
function psql(...sql) {
const u = new URL(PG_URL);
const env = { ...process.env, PGHOST: u.hostname, PGPORT: u.port || '5432', PGUSER: decodeURIComponent(u.username),
PGPASSWORD: decodeURIComponent(u.password), PGDATABASE: u.pathname.slice(1), PGOPTIONS: `-c search_path=${PG_SCHEMA}` };
execFileSync('psql', ['-q', '-v', 'ON_ERROR_STOP=1', ...sql.flatMap(s => ['-c', s])], { env, stdio: ['ignore', 'ignore', 'inherit'] });
}
function writeConfig() {
fs.rmSync(root, { recursive: true, force: true });
for (const d of ['config', 'data', 'downloads']) fs.mkdirSync(path.join(root, d), { recursive: true });
// Read into the database's catalogue on the first start, as an existing config.toml is. Only
// the media feeds download: a forced scan of a listener's 30 feeds would otherwise fetch a
// 2 MB episode of each, every time.
const feeds = Array.from({ length: FEEDS }, (_, n) =>
`[feeds.${feedId(n)}]\nurl = "${feedUrl(n)}"\nauto_download = ${n < MEDIA}\n`).join('\n');
fs.writeFileSync(env.IPX_CONFIG, `
[general]
download_dir = "${root}/downloads"
socket = "${root}/ipx.sock"
schedule = "every 60m"
max_new_per_check = 1
[torrent]
enabled = false
[web]
enabled = true
bind = "127.0.0.1:${WEB}"
token = "loadtokenloadtokenloadtoken12345"
# Each k6 user signs in by name, as Cloudflare Access does in production: no password to hash
# for every one of a hundred listeners. Only the sign-in test uses a password.
trusted_header = "X-Load-User"
trusted_proxies = ["127.0.0.1"]
auto_create_users = true
${feeds}`);
}
/// Resolves with the first log line matching `test`, read from the daemon's JSON log, which it
/// writes to stderr.
function waitForLog(daemon, test, ms) {
return new Promise((resolve, reject) => {
let buf = '';
const timer = setTimeout(() => { daemon.stderr.off('data', on); reject(new Error(`no log line in ${ms / 1000}s for ${test}`)); }, ms);
const on = chunk => {
buf += chunk;
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
let ev; try { ev = JSON.parse(line); } catch { continue; }
if (test(ev)) { clearTimeout(timer); daemon.stderr.off('data', on); return resolve(ev); }
}
};
daemon.stderr.on('data', on);
});
}
const as = name => ({ 'X-Load-User': name, 'Content-Type': 'application/json' });
async function api(name, url, opts = {}) {
const r = await fetch(BASE + url, { ...opts, headers: { ...as(name), ...opts.headers } });
if (!r.ok) throw new Error(`${opts.method || 'GET'} ${url} as ${name}: ${r.status} ${await r.text()}`);
return r.status === 204 ? null : r.json().catch(() => null);
}
const opml = ns => `<?xml version="1.0"?><opml version="2.0"><head><title>load</title></head><body>${
ns.map(n => `<outline type="rss" text="${feedId(n)}" xmlUrl="${feedUrl(n)}"/>`).join('')}</body></opml>`;
async function seed() {
// The first account made is the admin.
await api('admin', '/api/me');
// Every listener subscribes to 30 feeds, overlapping as people's do; one OPML each, one scan.
for (let u = 0; u < USERS; u++) {
const mine = Array.from({ length: 30 }, (_, k) => (u * 7 + k * 41) % FEEDS);
await api(`load-${u}`, '/api/opml', { method: 'POST', body: JSON.stringify({ xml: opml(mine) }) });
}
// 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' });
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;
if (Date.now() - t > 120_000) throw new Error(`the listener's ${MEDIA} files did not download in 120s`);
}
// A password, for the sign-in test; the CLI hashes it as `ipx user add` always does.
execFileSync(bin, ['user', 'add', 'piper'], { input: 'piperpassword', env });
}
// ---- the run --------------------------------------------------------------------------------
/// `ipx status` once a second until stopped: the slowest answer, and any that failed. Not
/// spawnSync: blocked in it, this process stops draining the daemon's log, the pipe fills, and
/// the daemon stalls writing its next line, which is the test measuring itself.
function watchHealth() {
const seen = { slowest: 0, failed: 0 };
let on = true;
(async () => {
while (on) {
const t = Date.now();
const ok = await new Promise(res => execFile(bin, ['status'], { env, timeout: 5000 }, err => res(!err)));
seen.slowest = Math.max(seen.slowest, Date.now() - t);
if (!ok) seen.failed++;
await new Promise(res => setTimeout(res, 1000));
}
})();
return () => { on = false; return seen; };
}
function k6(script) {
return new Promise(resolve => {
const run = spawn('k6', ['run', '--quiet', '-e', `BASE=${BASE}`, '-e', `USERS=${USERS}`, '-e', `FEEDS=${FEEDS}`,
'-e', `DB=${POSTGRES ? 'postgres' : 'sqlite'}`,
path.join(__dirname, `${script}.js`)], { stdio: 'inherit' });
run.on('exit', code => resolve(code));
});
}
(async () => {
const only = process.argv.slice(2).filter(a => a !== '--postgres');
for (const s of only) if (!SCRIPTS.includes(s)) { console.error(`no load test called ${s}: ${SCRIPTS.join(', ')}`); process.exit(2); }
if (spawnSync('k6', ['version']).error) { console.error('k6 is not installed: see install.sh'); process.exit(2); }
console.log('building the release binary...');
execFileSync('cargo', ['build', '--release', '-q'], { cwd: repo, stdio: 'inherit' });
const feeds = serveFeeds();
writeConfig();
if (POSTGRES) freshPostgres();
console.log(`on ${POSTGRES ? 'Postgres' : 'SQLite'}`);
// Its log kept out of this run's output, which is k6's; IPX_LOAD_LOG=1 shows it.
const daemon = spawn(bin, ['daemon'], { env, stdio: ['ignore', process.env.IPX_LOAD_LOG ? 'inherit' : 'ignore', 'pipe'] });
daemon.stderr.setEncoding('utf8');
if (process.env.IPX_LOAD_LOG) daemon.stderr.on('data', d => process.stderr.write(d));
// Stopped by hand, the daemon goes too, by its own PID, or it holds the ports for the next run.
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { daemon.kill('SIGTERM'); process.exit(130); });
let failed = 0;
try {
const t = Date.now();
const first = await waitForLog(daemon, ev => ev.ev === 'scan_done' && ev.feeds > 0, 600_000);
console.log(`first scan: ${first.feeds} feeds in ${((Date.now() - t) / 1000).toFixed(1)}s`);
await seed();
// The planner's statistics, as production's long-standing tables have them. A schema filled
// seconds ago has none until autovacuum gets to it, which it did partway through a test, and
// the search's p95 swung from 40ms to 166ms between runs with the plan it changed.
if (POSTGRES) psql('ANALYZE');
// The log is drained from here on, or the pipe fills and the daemon blocks writing to it.
daemon.stderr.resume();
for (const script of only.length ? only : SCRIPTS) {
console.log(`\n=== ${script}`);
const stop = watchHealth();
const code = await k6(script);
const health = stop();
console.log(`ipx status: slowest ${health.slowest}ms, ${health.failed} failed`);
if (code !== 0) { failed++; console.log(`${script}: thresholds failed (k6 exit ${code})`); }
if (health.failed || health.slowest >= 5000) { failed++; console.log(`${script}: the healthcheck would have failed`); }
}
} catch (e) {
console.error(e.message);
failed++;
} finally {
// Its own PID, never a pkill: Tower sees production's ipx too (#38).
daemon.kill('SIGTERM');
feeds.close();
}
console.log(failed ? `\n${failed} failure(s)` : '\nall load tests passed');
process.exit(failed ? 1 : 0);
})();

44
tests/load/signin.js Normal file
View File

@@ -0,0 +1,44 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';
import { BASE, as, me } from './lib.js';
// A flood of sign-in attempts with wrong passwords while everyone else goes on using the site.
// Two things only many requests at once show: whether checking passwords, tens of milliseconds
// of CPU each, starves the rest of the server, and whether a wrong password for a name that is
// an account takes longer to refuse than one for a name that is not -- the answers read the
// same, and the time would tell anyone which names are accounts here.
const gap = new Trend('signin_name_gap', true);
export const options = {
scenarios: {
attempts: { executor: 'constant-vus', vus: 40, duration: '40s', exec: 'attempt' },
everyone: { executor: 'constant-vus', vus: 5, duration: '40s', exec: 'browse' },
},
thresholds: {
http_req_failed: ['rate==0'],
checks: ['rate==1'],
// 57ms on 2026-10-05 on SQLite, 68 on Postgres; 4.3s while password checks ran on the async
// workers (#137).
'http_req_duration{name:meanwhile}': ['p(95)<300'],
// Known name against unknown, one straight after the other: no gap but noise. 0.2ms on
// 2026-10-05; 31ms while an unknown name was refused without a check (#138).
signin_name_gap: ['med<10'],
},
};
const refused = { headers: { 'Content-Type': 'application/json' }, responseCallback: http.expectedStatuses(401) };
export function attempt() {
const known = http.post(`${BASE}/api/login`, JSON.stringify({ name: 'piper', password: 'not-the-password' }),
{ ...refused, tags: { name: 'signin-known' } });
const unknown = http.post(`${BASE}/api/login`, JSON.stringify({ name: `nobody-${__VU}-${__ITER}`, password: 'not-the-password' }),
{ ...refused, tags: { name: 'signin-unknown' } });
check(known, { 'a wrong password refused': r => r.status === 401 });
check(unknown, { 'an unknown name refused': r => r.status === 401 });
gap.add(known.timings.duration - unknown.timings.duration);
}
export function browse() {
const r = http.get(`${BASE}/api/feeds`, as(me(), 'meanwhile'));
check(r, { 'the site still answers': r => r.status === 200 });
sleep(0.5);
}

117
tests/native-bridge.js Normal file
View File

@@ -0,0 +1,117 @@
// The page inside a native shell: web/src/native.ts should take playback off the element and
// hand it to the host, while everything in player.ts carries on talking to the element.
//
// This is the check that the shim and player.ts still agree. The surface native.ts replaces --
// play, pause, src, currentTime, duration, paused, readyState, the events -- is player.ts's
// alone, so a change there that steps outside it would otherwise break the app in a car, on a
// road, with nothing to look at.
//
// node tests/native-bridge.js
const { makeContext, vm } = require('./dom-stub.js');
const { buildPage } = require('../web/build.mjs');
const { html, js: script } = buildPage('index.html');
let failed = 0;
const ok = (cond, what) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
/* ---- a browser: nothing installs ---- */
{
const { ctx } = makeContext(html, script, { media: true });
vm.createContext(ctx);
vm.runInContext(script, ctx, { filename: 'browser', timeout: 5000 });
ok(ctx.window.ipxNative === undefined, 'the bridge installed in a plain browser');
const audio = ctx.document.querySelector('#audio');
audio.src = '/media/1';
ok(audio.calls.includes('src:/media/1'), 'a browser did not set the real src');
}
/* ---- inside the shell ---- */
const posted = [];
const { ctx, audio, body } = makeContext(html, script, { media: true });
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: m => posted.push(m) } } };
vm.createContext(ctx);
vm.runInContext(script, ctx, { filename: 'shell', timeout: 5000 });
const last = t => [...posted].reverse().find(m => m.t === t);
const since = () => posted.splice(0, posted.length);
ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
ok(last('ready'), 'the host was never told the bridge is in');
since();
// What play() does: player.ts fills in `player`, marks the body, sets the src, then plays.
const entry = { guid: 'g1', feed_id: 'f', title: 'Episode One', image: null, position: 0, duration: 1800, read: false,
enclosures: [{ id: 42, mime: 'audio/mpeg', path: '/downloads/f/ep1.mp3', url: 'https://x/ep1.mp3' }] };
vm.runInContext('S.feeds=[{id:"f",title:"A Feed",image:"/art.jpg"}]', ctx);
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
body.classList.toggle('has-video', false);
audio.calls.length = 0;
audio.src = '/media/42';
const load = last('load');
ok(load, 'setting the src told the host nothing');
if (load) {
ok(load.url === '/media/42' && load.enc === 42, 'the host was not told which file');
ok(load.feedId === 'f' && load.guid === 'g1', 'the host cannot save a position without the feed and guid');
ok(load.title === 'Episode One' && load.feedTitle === 'A Feed', 'now-playing has nothing to show');
ok(load.artwork === '/art.jpg', "the feed's art did not stand in for an episode without its own");
}
ok(!audio.calls.some(c => c.startsWith('src:')), 'the element loaded the file as well as the host');
ok(audio.calls.includes('load'), 'the element was not made to let go of what it held');
// player.ts sets currentTime=0 straight after the src.
since();
audio.currentTime = 0;
ok(last('seek') && last('seek').to === 0, 'a seek did not reach the host');
since();
audio.play();
ok(last('play'), 'play did not reach the host');
ok(!audio.calls.includes('play'), 'the element played too -- two engines on one file');
// The host answers, and the page must move as it would have on its own.
let played = 0, timed = 0;
audio.addEventListener('play', () => played++);
audio.addEventListener('timeupdate', () => timed++);
ctx.window.ipxNative.on({ t: 'state', playing: true });
ok(played === 1, 'the page never saw the host start playing');
ok(audio.paused === false, 'audio.paused still says paused while the host plays');
ctx.window.ipxNative.on({ t: 'meta', dur: 1800 });
ok(audio.duration === 1800, 'the duration the host measured did not reach the page');
ok(audio.readyState > 0, 'readyState stayed 0, which is what stops a position being saved');
ctx.window.ipxNative.on({ t: 'time', cur: 30 });
ok(audio.currentTime === 30, "the host's clock did not reach the page");
ok(timed > 0, 'no timeupdate, so the player bar would sit at zero');
// The 15-second key: a read and a write through the shim.
since();
audio.currentTime -= 15;
ok(last('seek') && last('seek').to === 15, 'back 15 seconds did not land at 15');
// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
since();
const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
ok(last('position') && /\/position$/.test(last('position').url), 'the position write did not become a request to the host');
// Closing the player has to stop the host, not just blank the element.
since();
audio.removeAttribute('src');
ok(last('stop'), 'closing the player left the host playing');
// Video stays on the element: CarPlay is audio-only, and a native video layer under a WebView
// buys nothing.
since();
audio.calls.length = 0;
body.classList.toggle('has-video', true);
audio.src = '/media/99';
ok(!last('load'), 'a video was handed to the host');
ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
console.log('OK: native-bridge: the host takes playback and the page follows it');
process.exit(0);

View File

@@ -7,7 +7,7 @@
// and every server-side test passed too, because the server was fine.
//
// node tests/page-smoke.js
const vm = require('vm');
const { makeContext, vm } = require('./dom-stub.js');
const PAGE = process.argv[2] || 'index.html';
const { buildPage } = require('../web/build.mjs');
@@ -19,57 +19,7 @@ if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && P
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
}
// Ids in the page, and in the markup the script builds for its dialogs.
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
const missing = [];
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(){},
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) });
const document = {
querySelector(sel) {
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
return el(sel);
},
querySelectorAll: () => [],
createElement: () => el('created'),
addEventListener(){}, body: el('body'),
documentElement: { dataset: {} },
};
const ctx = {
document, console,
window: { isSecureContext: false, addEventListener(){} },
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
fetch: (url) => Promise.resolve({
ok: true, status: 200, text: () => Promise.resolve(''),
json: () => Promise.resolve(
String(url).includes('/api/settings')
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
: String(url).includes('/api/users')
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
: /\/api\/(popular|directory)/.test(String(url))
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
}),
EventSource: function () { this.close = () => {}; },
MediaMetadata: function () {},
Blob: function () {},
setTimeout, clearTimeout, setInterval, clearInterval,
confirm: () => false, prompt: () => null, alert(){},
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
};
ctx.globalThis = ctx;
ctx.window.location = { href: '', hash: '' };
ctx.location = ctx.window.location;
const { ctx, missing } = makeContext(html, script);
try {
vm.createContext(ctx);
@@ -99,7 +49,6 @@ const drive = PAGE === 'admin.html' ? [
['prefsModal', () => ctx.prefsModal()],
['opmlModal', () => ctx.opmlModal()],
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
['keysModal', () => ctx.keysModal()],

View File

@@ -47,10 +47,10 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
// Auto follows the system, live, with no reload.
await page.emulateMedia({ colorScheme: 'light' });
await expect.poll(root).toEqual(['modern', 'light']);
await expect.poll(bg).toBe('rgb(242, 244, 247)'); // Modern's light --bg
await expect.poll(bg).toBe('rgb(238, 245, 251)'); // Modern's light --bg
await page.emulateMedia({ colorScheme: 'dark' });
await expect.poll(root).toEqual(['modern', 'dark']);
await expect.poll(bg).toBe('rgb(14, 19, 27)'); // Modern's dark --bg
await expect.poll(bg).toBe('rgb(10, 23, 38)'); // Modern's dark --bg
// Dracula, then its light half, Alucard.
await page.locator('#stheme').selectOption('dracula');
@@ -63,13 +63,9 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
await page.locator('#stheme').selectOption('paper');
await expect(page.locator('#smode')).toBeHidden();
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
// The save that says Classic: the ones before it may still be answering.
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH'
&& r.request().postDataJSON().theme === 'classic');
await page.locator('#stheme').selectOption('classic');
await expect(page.locator('#smode')).toBeHidden();
await saved; // kept on the account, not the browser
await page.reload();
await page.reload(); // kept in this browser's cookie
await expect.poll(root).toEqual(['classic', 'light']);
// The 2004 Mac app set its type in Lucida Grande.
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
@@ -83,46 +79,40 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
});
test('the theme is kept on the account, and follows it to another browser', async ({ page, browser }) => {
test('each browser keeps its own theme, in a cookie, not on the account', async ({ page, browser }) => {
// Glass on a phone, Dracula on a desktop, signed in as the same person (issue #69).
let patched = false;
page.on('request', r => { if (r.url().endsWith('/api/me') && r.method() === 'PATCH') patched = true; });
await page.locator('#prefs').click();
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#stheme').selectOption('flatremix');
expect((await saved).status()).toBe(204);
const saved2 = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#smode').selectOption('light');
await saved2;
const cookie = (await page.context().cookies()).find(c => c.name === 'ipx_theme');
expect(cookie?.value).toBe('flatremix.light');
expect(patched, 'nothing sent to the account').toBe(false);
// Another browser: nothing in its localStorage, and the page still arrives in the theme,
// written onto <html> by the server rather than set once the script has run.
// This browser: the server reads the cookie and draws the page in it from the first frame.
const res = await page.reload();
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
// Another browser, the same account: not this one's theme.
const other = await browser.newContext();
const p2 = await other.newPage();
const res = await p2.goto(`/?token=${TOKEN}`);
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
expect(await p2.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
.toEqual(['flatremix', 'light']);
const res2 = await p2.goto(`/?token=${TOKEN}`);
expect(await res2.text()).not.toContain('data-theme=flatremix');
await other.close();
});
test('a theme this browser kept before themes were on the account goes up to it once', async ({ browser }) => {
// A new account, made by the proxy header on first sight, so it has no theme of its own yet.
test('a theme this browser kept in localStorage becomes its cookie once', async ({ browser }) => {
const who = `theme-${Date.now()}@example.com`;
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
const page = await ctx.newPage();
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.goto('/');
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
.toEqual(['modern', 'light']);
expect((await saved).request().postDataJSON()).toEqual({ theme: 'modern', mode: 'light' });
expect((await ctx.cookies()).find(c => c.name === 'ipx_theme')?.value).toBe('modern.light');
await ctx.close();
// Anywhere else now, it comes from the account.
const fresh = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
const p2 = await fresh.newPage();
const res = await p2.goto('/');
expect(await res.text()).toContain('data-theme=modern data-choice=light');
await fresh.close();
});
test('the admin page saves the global schedule', async ({ page }) => {
@@ -167,7 +157,7 @@ test('the three panes are there and the item text lands in the bottom one', asyn
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('#list')).toBeVisible();
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
await expect(page.locator('#detail')).toContainText('Pick an item');
await expect(page.locator('#detail .dt')).toHaveCount(0); // nothing picked, nothing said
await page.locator('.ep', { hasText: 'First Episode' }).click();
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
@@ -252,7 +242,7 @@ test('an item with several enclosures lists them all', async ({ page }) => {
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
});
test('Currently Listening, its own place below Popular, resumes an episode you started or forgets it', async ({ page }) => {
test('Currently Listening, its own place below the Directory, resumes an episode you started or forgets it', async ({ page }) => {
// Second Episode (900s) is 42 seconds in and unfinished. An earlier test may have opened it,
// and opening marks it read; it is listed all the same, because read is not finished. This
// test used to set it unread first, which hid exactly the bug in issue #14.
@@ -261,18 +251,27 @@ test('Currently Listening, its own place below Popular, resumes an episode you s
await page.evaluate(() =>
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
// Popular lists feeds and nothing else; the episodes have a place of their own under it.
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
await expect(page.locator('#popular')).toBeVisible();
// The Directory lists feeds and nothing else; the episodes have a place of their own under it.
await page.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(page.locator('#directory')).toBeVisible();
await expect(page.locator('#listening')).toHaveCount(0);
const places = await page.locator('#feedlist .place b').allTextContents();
expect(places.indexOf('Currently Listening')).toBe(places.indexOf('Popular') + 1);
expect(places.indexOf('Currently Listening')).toBe(places.indexOf('Directory') + 1);
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
await expect(row).toBeVisible({ timeout: 20_000 });
await expect(row).toContainText('14:18 left');
// The search box looks through it, as it does a feed's items (#127).
await page.locator('#epSearch').fill('second');
await expect(row).toBeVisible();
await page.locator('#epSearch').fill('nothing-is-called-this');
await expect(row).toHaveCount(0);
await expect(page.locator('#listening')).toContainText('Nothing you are listening to mentions');
await page.locator('#epSearch').fill('');
await expect(row).toBeVisible();
// Removing it forgets where you got to, so it is still gone on the next visit.
await row.locator('[data-a=remove]').click();
await expect(row).toHaveCount(0);
@@ -470,6 +469,11 @@ test('marking an OPML subscription read covers the feeds inside it', async ({ pa
test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 } });
test('the top bar leaves the logo out, for the search box', async ({ page }) => {
await expect(page.locator('#burger')).toBeVisible();
await expect(page.locator('#applogo')).toBeHidden();
});
test('the feed list is reachable and an item reads full screen', async ({ page }) => {
// The burger used to live in the player bar, which is hidden until something plays --
// leaving no way to reach the feeds at all.
@@ -558,10 +562,12 @@ test('a second person has their own feeds and their own read state', async ({ br
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/login');
// The sign-in page shows the icon, so it has to load before anyone has signed in.
const icon = await page.request.get('/icon.png');
expect(icon.status()).toBe(200);
expect(icon.headers()['content-type']).toBe('image/png');
// The sign-in page shows the logo, so it has to load before anyone has signed in.
for (const path of ['/logo.svg', '/logo-dark.svg']) {
const logo = await page.request.get(path);
expect(logo.status(), path).toBe(200);
expect(logo.headers()['content-type'], path).toBe('image/svg+xml');
}
await page.locator('#name').fill('sam');
await page.locator('#pw').fill('sampassword');
await page.locator('button[type=submit]').click();
@@ -815,7 +821,7 @@ test('ipx import subscribes the admin, and ipx export writes the feeds out', asy
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
});
test('Popular lists what everyone here reads, but never a private feed', async ({ browser }) => {
test('the Directory lists what everyone here reads, the most subscribed first, but never a private feed', async ({ browser, page }) => {
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const env = {
@@ -836,21 +842,23 @@ test('Popular lists what everyone here reads, but never a private feed', async (
await piper.locator('button[type=submit]').click();
await expect(piper.locator('#feedlist')).toContainText('No feeds.');
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
const offered = piper.locator('#popular .childrow');
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// The most subscribed are the Directory's first section; Popular, their own place, is gone (#124).
await expect(piper.locator('#feedlist .place', { hasText: 'Popular' })).toHaveCount(0);
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
const chart = piper.locator('#dchart .rank');
await expect(chart.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// An OPML is listed as the feeds inside it, and a key in a URL marks someone's paid feed.
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
await expect(chart.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(chart.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
// No URL reaches the page at all, so neither can a key, and the server holds the same line.
const listed = await (await piper.request.get('/api/popular')).text();
const listed = await (await piper.request.get('/api/directory')).text();
expect(listed).not.toContain('secret123');
expect(listed).not.toContain('.xml');
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
// Popular is the top ten of the directory, and the directory is every listed feed A to Z,
// with an OPML's feeds in place of the OPML in both.
// The chart is /api/popular's top ten, and the directory is every listed feed A to Z, with an
// OPML's feeds in place of the OPML in both.
const dir = await (await piper.request.get('/api/directory')).json();
const top = await (await piper.request.get('/api/popular')).json();
const names = dir.map(p => (p.title || p.id).toLowerCase());
@@ -858,48 +866,92 @@ test('Popular lists what everyone here reads, but never a private feed', async (
const ids = dir.map(p => p.id);
expect(ids).not.toContain('test-subscriptions');
expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio']));
expect(top.length).toBe(Math.min(10, dir.length));
expect(top.every(t => ids.includes(t.id))).toBe(true);
expect(ids).not.toContain('paid-show');
// Subscribe from the directory this time: the same feeds, as a grid of cover art.
const tiles = piper.locator('#popular .tile');
const pick = (row, name) => piper.locator(`#dirbar .${row} button`, { hasText: new RegExp(`^${name}$`) });
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(chart).toHaveCount(top.length);
await expect(chart.first().locator('.no')).toHaveText('1');
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
// Then the categories, as tiles, each a page of its own.
const cat = name => piper.locator('#directory .cat', { hasText: new RegExp(`^${name}\\d`) });
const tiles = piper.locator('#dtiles .tile');
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' });
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false });
await cat('Technology').click();
await expect(piper.locator('#directory h2')).toHaveText('Technology');
await expect(tiles).toHaveCount(dir.filter(p => p.category === 'Technology').length);
await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible();
// Its way back lands on the tile it went in by.
await piper.locator('#directory .dback').click();
await expect(cat('Technology')).toBeFocused();
// What a feed is holds on every page. No blog here names Technology, so Blogs has no such tile.
const kind = name => piper.locator('#directory .tabs button', { hasText: new RegExp(`^${name}$`) });
await kind('Blogs').click();
await expect(kind('Blogs')).toHaveAttribute('aria-pressed', 'true');
await expect(cat('Technology')).toHaveCount(0);
await piper.locator('#directory .dall').click();
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
await kind('All').click();
await expect(tiles).toHaveCount(dir.length);
await expect(tiles.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
await expect(tiles.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(tiles.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
// Sorted by name either way, A to Z as it opens, or by subscribers (#122).
const byName = dir.map(p => p.title || p.id).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }));
await expect(tiles.first()).toContainText(byName[0]);
await piper.locator('#dirsort').selectOption('za');
await expect(tiles.first()).toContainText(byName[byName.length - 1]);
await expect(tiles).toHaveCount(dir.length);
await piper.locator('#dirsort').selectOption('subscribers');
await expect(tiles).toHaveCount(dir.length);
await piper.locator('#dirsort').selectOption('az');
// The search box looks through the Directory by name (#126), and clearing it goes back.
await piper.locator('#epSearch').fill('test show');
await expect(piper.locator('#directory h2')).toHaveText('Results for “test show”');
await expect(tiles).toHaveCount(dir.filter(p => (p.title || p.id).toLowerCase().includes('test show')).length);
await piper.locator('#epSearch').fill('');
await expect(tiles).toHaveCount(dir.length);
// Two filters that combine: what a feed is, and what it is about.
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' });
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false });
await pick('tabs', 'Blogs').click();
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
// No empty chips: no blog here names Technology, so Blogs does not offer it.
await expect(pick('chips', 'Technology')).toHaveCount(0);
await pick('tabs', 'Podcasts').click();
await pick('chips', 'Technology').click();
await expect(pick('chips', 'Technology')).toHaveAttribute('aria-pressed', 'true');
await expect(tiles).toHaveCount(dir.filter(p => p.podcast && p.category === 'Technology').length);
// A second press lifts the chip and leaves the kind as it was.
await pick('chips', 'Technology').click();
await expect(tiles).toHaveCount(dir.filter(p => p.podcast).length);
await pick('tabs', 'All').click();
// A feed you do not subscribe to opens its page here: what it is and its latest items (#128).
await tiles.filter({ hasText: 'Test Show' }).locator('.txt').click();
await expect(piper.locator('.show h2')).toHaveText('Test Show');
await expect(piper.locator('#dshow .lep', { hasText: 'First Episode' })).toBeVisible();
// What the feed says it is, from its own <description> (#130).
await expect(piper.locator('#dabout p')).toHaveText('A fixture feed.');
await expect(piper.locator('#dabout button')).toBeHidden();
// Back, onto the tile it was opened from, the page under it as it was.
await piper.locator('#directory .dback').click();
await expect(tiles.filter({ hasText: 'Test Show' })).toBeFocused();
await expect(tiles).toHaveCount(dir.length);
// Only a listed feed has one, and it carries no file or its address.
const preview = await (await piper.request.get('/api/directory/test-show')).text();
expect(preview).toContain('First Episode');
expect(JSON.parse(preview).description).toBe('A fixture feed.');
expect(preview).not.toContain('.mp3');
expect((await piper.request.get('/api/directory/paid-show')).status()).toBe(404);
// Nor does anything else give a feed's items or files to someone who does not subscribe (#129).
expect((await piper.request.get('/api/feeds/test-show/entries')).status()).toBe(404);
expect((await piper.request.get('/api/feeds/paid-show/entries')).status()).toBe(404);
const pics = await page.evaluate(() => api('/api/feeds/picture-blog/entries'));
const file = pics.entries.flatMap(e => e.enclosures).find(x => x.path);
expect(file).toBeTruthy();
expect((await page.request.get(`/media/${file.id}`)).status()).toBe(200);
expect((await piper.request.get(`/media/${file.id}`)).status()).toBe(404);
expect((await piper.request.post(`/api/enclosures/${file.id}/download`)).status()).toBe(404);
expect((await piper.request.delete(`/api/enclosures/${file.id}?force=true`)).status()).toBe(404);
expect((await piper.request.post('/api/feeds/picture-blog/download-latest', { data: { count: 1 } })).status()).toBe(404);
// Add a feed is for an address; Popular and Directory are where you browse (issue #30).
// Add a feed is for an address; the Directory is where you browse (issue #30).
await piper.locator('#addFeed').click();
await expect(piper.locator('#nurl')).toBeVisible();
await expect(piper.locator('#modalCard .childrow')).toHaveCount(0);
await expect(tiles).toHaveCount(dir.length);
await expect(piper.locator('#modalCard .tile, #modalCard .rank')).toHaveCount(0);
await piper.locator('#modalCard button[title="Cancel"]').click();
const row = async () =>
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
(await (await piper.request.get('/api/directory')).json()).find(p => p.id === 'test-show');
const before = await row();
expect(before.subscribed).toBe(false);
await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
@@ -907,9 +959,10 @@ test('Popular lists what everyone here reads, but never a private feed', async (
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 });
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
await expect(offered.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
await expect(offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]')).toHaveCount(0);
expect((await piper.request.get('/api/feeds/test-show/entries')).status()).toBe(200);
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(chart.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
await expect(chart.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]')).toHaveCount(0);
// All Subscriptions is every item from piper's feeds and only those: the admin's Picture
// Blog is not among them.
@@ -922,6 +975,46 @@ test('Popular lists what everyone here reads, but never a private feed', async (
await ctx.close();
});
test('the feed list keeps the icons it has drawn when a feed\'s new row comes in (#134)', async ({ page }) => {
await expect(page.locator('#feedlist .feed').first()).toBeVisible({ timeout: 20_000 });
const kept = await page.evaluate(async () => {
const frames = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
// Artwork that loads, on a feed of its own row: the fixtures' fails on purpose, for initials.
const row = { ...S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)), image: '/favicon.png' };
patchFeed(row); await frames();
const rows = [...document.querySelectorAll('#feedlist .feed')];
const before = [...document.querySelectorAll('#feedlist .feed img')];
// The same feed's new row again, as a check of every feed sends one for each feed it reads.
patchFeed({ ...row }); await frames();
const after = [...document.querySelectorAll('#feedlist .feed img')];
return { drawn: before.length > 0, redrawn: !rows[0].isConnected, same: after.length === before.length && after.every((img, i) => img === before[i]) };
});
// The rows are new, the images in them the ones already drawn.
expect(kept).toEqual({ drawn: true, redrawn: true, same: true });
});
test('ipx add and rm while the daemon runs reach it, and its next change keeps them (#120)', async ({ page }) => {
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` };
const ipx = (...args) => execFileSync('./target/debug/ipx', args, { env, encoding: 'utf8' });
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.id);
// A change of the daemon's own: it writes the whole catalogue from the copy it holds, which
// wrote a removed feed back and dropped an added one when that copy was old.
const daemonWrites = category => page.evaluate(c =>
api('/api/feeds/picture-blog', { method: 'PATCH', body: JSON.stringify({ category: c }) }), category);
const id = ipx('add', '--list', 'http://127.0.0.1:8792/cli.xml').match(/^added (\S+?),? /m)[1];
await expect.poll(listed).toContain(id);
await daemonWrites('Comedy');
expect(await listed()).toContain(id);
ipx('rm', id);
await expect.poll(listed).not.toContain(id);
await daemonWrites(null);
expect(await listed()).not.toContain(id);
});
test('adding a feed scans it straight away', async ({ page }) => {
await page.locator('#addFeed').click();
await page.locator('#nurl').fill('http://127.0.0.1:8792/fresh.xml');
@@ -930,6 +1023,66 @@ test('adding a feed scans it straight away', async ({ page }) => {
await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 });
});
test('adding a page adds the feed it links, and a page with no feed is refused', async ({ page }) => {
const feeds = await page.locator('.feed').count();
await page.locator('#addFeed').click();
await page.locator('#nurl').fill('http://127.0.0.1:8792/nofeed.html');
await page.locator('#nsave').click();
await expect(page.locator('.toast')).toContainText('links no feed');
await expect(page.locator('#modal.on')).toBeVisible(); // left open to correct it
await expect(page.locator('.feed')).toHaveCount(feeds);
await page.locator('#nurl').fill('http://127.0.0.1:8792/site.html');
await page.locator('#nsave').click();
await expect(page.locator('.feed', { hasText: 'Linked Site' })).toBeVisible({ timeout: 20_000 });
});
test('reading an item updates its feed\'s count without reloading the list', async ({ page }) => {
const unread = page.locator('.feed:not(.group)', { has: page.locator('.badge:not(.zero)') }).first();
await expect(unread).toBeVisible({ timeout: 20_000 });
const id = await unread.getAttribute('data-id');
const badge = page.locator(`.feed[data-id="${id}"] .badge`);
const before = Number(await badge.textContent());
await unread.click();
await page.locator('.tabs button', { hasText: 'Unread' }).click();
await expect(page.locator('.ep').first()).toBeVisible();
const lists = [];
page.on('request', r => { if (new URL(r.url()).pathname === '/api/feeds') lists.push(r.url()); });
await page.locator('.ep').first().click(); // opening an item reads it
await expect(badge).toHaveText(String(before - 1));
expect(lists, 'the row came back with the read, not by reloading the list').toEqual([]);
});
test('artwork comes from iPX, kept, and only as an image', async ({ page }) => {
// Every image a feed names is drawn from iPX's address.
expect(await page.evaluate(() => artHTML('https://example.com/a.jpg', 'A'))).toContain('src="/api/art?u=https%3A%2F%2Fexample.com%2Fa.jpg"');
// Multi Show's items name art.jpg.
await expect(page.locator('.feed', { hasText: 'Multi Show' })).toBeVisible({ timeout: 20_000 });
const src = '/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/art.jpg');
for (const _ of [1, 2]) { // fetched, then kept
const r = await page.request.get(src);
expect(r.status()).toBe(200);
expect(r.headers()['content-type']).toMatch(/^image\//);
expect(r.headers()['content-security-policy']).toContain('sandbox');
}
// An address no feed names is not fetched.
expect((await page.request.get('/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/show.xml'))).status()).toBe(404);
});
test('an item without a title is named from its text, then its file, then its show and date', async ({ page }) => {
const names = await page.evaluate(() => [
entryName({ title: 'A Title' }),
entryName({ title: ' ', description: '<p>I like the way <b>AI</b> is evolving.</p><img src="x.png">' }),
entryName({ description: '<p>' + 'word '.repeat(40) + '</p>' }).text.length,
entryName({ enclosures: [{ url: 'https://k.example/media/KARTAS691.mp3?x=1' }] }),
entryName({ feed_id: 'nobody', published: 0 }),
]);
expect(names[0]).toEqual({ text: 'A Title', derived: false });
expect(names[1]).toEqual({ text: 'I like the way AI is evolving.', derived: true });
expect(names[2]).toBeLessThanOrEqual(121); // cut at a word, with …
expect(names[3]).toEqual({ text: 'KARTAS691', derived: true });
expect(names[4]).toEqual({ text: 'nobody', derived: true });
});
test('a deleted file looks as if it was never downloaded', async ({ page }) => {
// Other people subscribe to Picture Blog by now, so both prompts come; take them.
page.on('dialog', d => d.accept());
@@ -1114,13 +1267,49 @@ test('keys move through items and places, after Feedly', async ({ page }) => {
await expect(page.locator('#count')).toContainText('All Subscriptions');
});
test('an API token acts as its owner until it is revoked', async ({ page, request }) => {
// Made in Settings, shown once.
await page.locator('#prefs').click();
await page.locator('#tokname').fill('test agent');
await page.locator('#tokadd').click();
const token = await page.locator('#tokval').inputValue();
expect(token).toMatch(/^ipx_[0-9a-f]{64}$/);
await expect(page.locator('.tokrow', { hasText: 'test agent' })).toContainText('last used never');
// Sent as a Bearer header, with no cookie, it is the person who made it.
const as = t => ({ headers: { Authorization: `Bearer ${t}` } });
const me = await (await request.get('/api/me', as(token))).json();
expect(me.name).toBe(await page.evaluate(() => S.me.name));
expect((await request.get('/api/feeds', as(token))).status()).toBe(200);
// A wrong one is turned away, not let through as anyone.
expect((await request.get('/api/me', as('ipx_' + '0'.repeat(64)))).status()).toBe(401);
// Only the hash is kept: the list never carries the token.
const listed = await page.evaluate(() => api('/api/tokens'));
expect(JSON.stringify(listed)).not.toContain(token);
expect(listed.find(t => t.name === 'test agent').last_used).toBeTruthy();
// Revoked, it opens nothing.
await page.locator('.tokrow', { hasText: 'test agent' }).locator('[data-tok]').click();
await expect(page.locator('.tokrow', { hasText: 'test agent' })).toHaveCount(0);
expect((await request.get('/api/me', as(token))).status()).toBe(401);
});
test('an admin can give a blog its Directory category', async ({ page }) => {
const patch = (id, category) => page.evaluate(([id, category]) =>
api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]);
const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id);
await patch('picture-blog', 'Visual Arts');
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Visual Arts' });
// Apple's subcategory, under its category, as the Directory browses them (#118).
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' });
// A category's subcategories are on its own page, as Apple's directory has them.
const pill = name => page.locator('#directory .pills button', { hasText: new RegExp(`^${name}\\d`) });
await page.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(page.locator('#directory .pills')).toHaveCount(0);
await page.locator('#directory .cat', { hasText: /^Arts\d/ }).click();
await pill('Visual Arts').click();
await expect(pill('Visual Arts')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#dtiles .tile')).toHaveCount(1);
// A feed's own iTunes category wins over one given here.
await patch('test-show', 'Comedy');
expect((await listed('test-show')).category).toBe('Technology');
@@ -1178,47 +1367,80 @@ test('a file not yet downloaded has its icon in line with the rest of its row',
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
});
test('the logo is the dark one in dark mode and the light one in light mode', async ({ page }) => {
const shown = () => page.locator('#applogo img:visible').getAttribute('src');
await page.evaluate(() => setTheme('modern', 'dark'));
expect(await shown()).toMatch(/^\/logo-dark\.svg\?v=/);
await page.evaluate(() => setTheme('modern', 'light'));
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
// Paper has only a light palette, so the light logo whatever the mode asked.
await page.evaluate(() => setTheme('paper', 'dark'));
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
await page.evaluate(() => setTheme('modern', 'dark'));
});
test('/api/status gives a dashboard the counts, with the shared token as a cookie', async ({ page }) => {
// As Homepage's customapi widget asks: no session, only the token in a Cookie header.
const r = await page.request.get('/api/status', { headers: { cookie: `ipx_token=${TOKEN}` } });
expect(r.status()).toBe(200);
const s = await r.json();
for (const k of ['feeds', 'pending', 'downloaded']) expect(typeof s[k], k).toBe('number');
expect(s.version).toMatch(/^\d+\.\d+\.\d+/);
const none = await page.request.get('/api/status', { headers: { cookie: '' } });
expect(none.status()).toBe(401);
});
test('the favicon is the logo, square, from both pages', async ({ page }) => {
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', '/favicon.png');
await page.evaluate(() => setTheme('modern', 'light'));
await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/);
await page.evaluate(() => setTheme('modern', 'dark'));
await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon-dark\.png\?v=[0-9a-f]{12}$/);
// A browser asks for /favicon.ico on its own, signed in or not.
for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) {
for (const path of ['/favicon.ico', '/favicon.png', '/favicon-dark.png', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png']) {
const r = await page.request.get(path, { headers: { cookie: '' } });
expect(r.status(), path).toBe(200);
expect(r.headers()['content-type'], path).toBe('image/png');
}
});
test('a feed error is marked in the same column as the folder triangles', async ({ page }) => {
test('a failing feed is marked on its artwork and says why', async ({ page }) => {
await expect(page.locator('.feed.group .chev').first()).toBeVisible();
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
await page.locator('.feed.group .chev').first().click();
// Faked in the page: no fixture feed fails. A feed on its own, and one inside a folder.
await page.evaluate(() => {
// Faked in the page: no fixture feed fails. A feed on its own failing for a day, and one
// inside a folder that failed its last check.
const solo = await page.evaluate(() => {
S.feeds.find(f => f.group).last_error = 'HTTP 404';
S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)).last_error = 'timed out';
const f = S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id));
f.last_error = 'HTTP 404 Not Found';
f.failing = { reason: 'The publisher took this feed down, or moved it.' };
renderFeeds();
return f.id;
});
await expect(page.locator('.ferr')).toHaveCount(2);
await expect(page.locator('.ferr svg')).toHaveCount(2); // the icon, not a "!"
await expect(page.locator('.fart .ferr svg')).toHaveCount(3); // both feeds and the folder
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
const xs = await page.$$eval('.chev, .ferr', els =>
els.map(e => { const r = e.getBoundingClientRect(); return Math.round(r.left + r.width / 2); }));
expect(new Set(xs).size, JSON.stringify(xs)).toBe(1);
const row = page.locator(`.feed[data-id="${solo}"]`);
await expect(row).toHaveClass(/failing/);
await expect(row.locator('small')).toHaveText('The publisher took this feed down, or moved it.');
await expect(page.locator('.feed.group.err small').first()).toHaveText('1 feed not updating');
await page.reload(); // put the real list back
});
test.describe('touch gestures on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
async function drag(page, from, to) {
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol. Sent all at
// once, in order: awaited one by one, a busy machine stretched a drag past the 800 ms a swipe
// has, and it was taken for a slow drag and went nowhere (#184).
async function drag(page, from, to, lift = true) {
const cdp = await page.context().newCDPSession(page);
const steps = 8;
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
const steps = 8, touch = (type, touchPoints) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints });
const sent = [touch('touchStart', [from])];
for (let i = 1; i <= steps; i++)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
sent.push(touch('touchMove', [{ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }]));
if (lift) sent.push(touch('touchEnd', []));
await Promise.all(sent);
return cdp;
}
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
@@ -1231,6 +1453,17 @@ test.describe('touch gestures on a phone', () => {
const shown = page.locator('#detail .dt');
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 300, y: 400 }, { x: 230, y: 410 }); // too short: stays (#49)
await expect(shown).toHaveText(titles[0]);
await expect(page.locator('#detail')).not.toHaveAttribute('style', /translate/); // springs back
// Held partway: the next item is beside the reader, not the list under it (#52), and
// let go short of a swipe, it goes again.
const held = await drag(page, { x: 300, y: 400 }, { x: 230, y: 405 }, false);
await expect(page.locator('#dpeek.next .dt')).toHaveText(titles[1]);
await expect(page.locator('#detail')).toHaveCSS('opacity', '1');
await held.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect(page.locator('#dpeek')).toHaveCount(0);
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
await expect(shown).toHaveText(titles[1]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
@@ -1243,6 +1476,34 @@ test.describe('touch gestures on a phone', () => {
await expect(page.locator('body')).not.toHaveClass(/reading/);
});
test('on the Unread tab, a swipe back goes to the item just read', async ({ page }) => {
await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
// Earlier tests read these; both have to be unread to be on the Unread tab.
await page.evaluate(() => Promise.all(S.entries.map(e => setRead(e, false))));
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
const titles = await page.locator('.ep .t').allTextContents();
await page.locator('.ep').first().click();
const shown = page.locator('#detail .dt');
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
await expect(shown).toHaveText(titles[1]);
// The first is read now, and used to be gone from the list already, so this went back to
// the list instead.
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 });
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 });
await expect(shown).toHaveText(titles[1]);
// Out of the reader, the ones read on the way leave the Unread tab as before.
await page.locator('#dback').click();
await expect(page.locator('body')).not.toHaveClass(/reading/);
await expect(page.locator('.ep .t', { hasText: titles[0] })).toHaveCount(0);
});
test('pulling the list down from its top checks the feed for new items', async ({ page }) => {
await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
@@ -1252,6 +1513,15 @@ test.describe('touch gestures on a phone', () => {
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
// and a spinner says the check started, for a couple of seconds.
await expect(page.locator('#pullspin')).toBeVisible();
// A second pull while it is up checks nothing more.
let again = 0;
page.on('request', r => { if (r.url().endsWith('/api/fetch')) again++; });
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
await expect(page.locator('#pullspin')).toHaveCount(1);
await expect(page.locator('#pullspin')).toHaveCount(0, { timeout: 5_000 });
expect(again).toBe(0);
});
});
@@ -1341,3 +1611,230 @@ test('"check every feed" checks only the feeds of the person asking', async ({ b
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
await ctx.close();
});
test('words in Settings hide the items that mention them', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toBeVisible({ timeout: 20_000 });
await page.locator('#prefs').click();
await page.locator('#sblock').fill('notes for the second'); // a phrase, in the show notes
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#sblock').press('Tab');
expect((await saved).status()).toBe(204);
await page.locator('#modal .cardx').click();
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toHaveCount(0);
await expect(page.locator('.ep', { hasText: 'First Episode' })).toBeVisible();
await page.evaluate(() => api('/api/me', { method: 'PATCH', body: JSON.stringify({ blocked: [] }) }));
});
test('Share hands the feed, the item and the file to the share sheet', async ({ page }) => {
await page.evaluate(() => { window.shared = []; navigator.share = async d => { window.shared.push(d); }; });
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await page.locator('#content .acts [data-a="share"]').click();
await page.locator('.ep', { hasText: 'Second Episode' }).click();
await page.locator('.encbox [data-a="share"]').first().click();
await expect.poll(() => page.evaluate(() => window.shared)).toEqual([
{ title: 'Test Show', url: expect.stringContaining('/show.xml') },
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
]);
});
test('a refresh button turns while what it checks is being checked', 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));
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');
});
test.describe('on a narrow phone', () => {
test.use({ viewport: { width: 360, height: 760 } });
test("a feed's buttons stay on one row, and the bar round the island is the page's colour", async ({ page }) => {
// Seven 44px buttons did not fit 360px, and Unsubscribe went onto a row of its own (#146).
await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).first().click();
const tops = await page.locator('#content .acts .btn').evaluateAll(bs => bs.map(b => b.getBoundingClientRect().top));
expect(tops.length).toBe(7);
expect(new Set(tops).size).toBe(1);
// iOS 26 paints behind the status bar and the Dynamic Island in the colour of a sticky bar at
// the top edge, older ones in theme-color: both the top bar's, which is the page's (#147).
const bar = await page.locator('#topbar').evaluate(b => [getComputedStyle(b).position, b.getBoundingClientRect().top, getComputedStyle(b).backgroundColor]);
expect(bar.slice(0, 2)).toEqual(['sticky', 0]);
expect(bar[2]).toBe(await page.evaluate(() => getComputedStyle(document.body).backgroundColor));
expect(await page.evaluate(() => document.querySelector('meta[name=theme-color]').content)).toBe(bar[2]);
// Glass's bar is see-through on a desktop, which no flat strip can match: solid on a phone.
for (const mode of ['dark', 'light']) {
const [c, tc] = await page.evaluate(m => { setTheme('glass', m);
return [getComputedStyle(document.querySelector('#topbar')).backgroundColor, document.querySelector('meta[name=theme-color]').content]; }, mode);
expect(c).toMatch(/^rgb\(/);
expect(tc).toBe(c);
}
await page.evaluate(() => setTheme('modern', 'dark'));
});
});
test('a page brought back into view catches up with what was read elsewhere', async ({ page }) => {
// Reading an item tells no other page, and a phone's page misses everything while it is in the
// background: All Subscriptions went on counting an item read elsewhere as unread (#168).
const badge = page.locator('.place[data-id=":all"] .badge');
const [one] = (await (await page.request.get('/api/entries?limit=1')).json()).entries;
const flag = read => page.request.post(`/api/entries/${encodeURIComponent(one.feed_id)}/${encodeURIComponent(one.guid)}/flags`, { data: { read } });
await flag(false);
await page.reload();
const before = Number(await badge.textContent());
expect(before).toBeGreaterThan(0);
await flag(true); // read on another device
await page.evaluate(() => {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
document.dispatchEvent(new Event('visibilitychange'));
});
await expect(badge).toHaveText(String(before - 1));
});
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 () => {
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
await page.locator('#content .acts [data-a="settings"]').click();
await expect(page.locator('#modalCard.fset')).toBeVisible();
};
await open();
// The strip is the feed's own items, and Newest 5 lights every one of its two.
await expect(page.locator('#sepwin i')).toHaveCount(2);
await page.locator('.seg label', { hasText: 'Newest' }).click();
await page.locator('#smax').fill('5');
await expect(page.locator('#sepwin i.on')).toHaveCount(2);
await expect(page.locator('#swcap')).toContainText('Downloads the newest');
// Words become chips with Enter or a comma, and come off with their x.
await page.locator('#skwin').fill('first, second');
await page.locator('#skwin').press('Enter');
await expect(page.locator('#skw .chip')).toHaveCount(2);
await page.locator('#skw .chip', { hasText: 'second' }).locator('.chipx').click();
await expect(page.locator('#skw .chip')).toHaveCount(1);
await page.locator('#sblockin').fill('spoiler');
await page.locator('#sblockin').press('Enter');
// What only automatic downloads use folds away with them.
await page.locator('.switch', { hasText: 'Download automatically' }).click();
await expect(page.locator('#sdeps')).toHaveClass(/\boff\b/);
await page.locator('.switch', { hasText: 'Download automatically' }).click();
await expect(page.locator('#sdeps')).not.toHaveClass(/\boff\b/);
await page.locator('#ssave').click();
await expect(page.locator('#modal.on')).toBeHidden();
// Saved: open again and it is all there.
await open();
await expect(page.locator('[name=swin][value=newest]')).toBeChecked();
await expect(page.locator('#smax')).toHaveValue('5');
await expect(page.locator('#skw .chip')).toHaveText(['first']);
await expect(page.locator('#sblock .chip')).toHaveText(['spoiler']);
// Back as it was, for the tests after this one.
await page.locator('.seg label', { hasText: 'Default' }).click();
await page.locator('#skw .chipx').click();
await page.locator('#sblock .chipx').click();
await page.locator('#ssave').click();
await expect(page.locator('#modal.on')).toBeHidden();
await open();
await expect(page.locator('[name=swin][value=default]')).toBeChecked();
await expect(page.locator('#skw .chip, #sblock .chip')).toHaveCount(0);
await page.keyboard.press('Escape');
});
test('DeadAir is off until turned on for a show, then plays it shortened, with your pause, and switches back', async ({ page }) => {
// #171/#172. A show of its own, with real audio, added here and left at the end.
const { id } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } })).json();
const entry = async () => {
const r = await page.request.get(`/api/feeds/${encodeURIComponent(id)}/entries`);
return (await r.json()).entries?.[0];
};
await expect.poll(async () => (await entry())?.guid, { timeout: 15_000 }).toBe('quiet-1');
await page.request.post(`/api/feeds/${encodeURIComponent(id)}/download-latest`, { data: { count: 1 } });
await expect.poll(async () => !!(await entry())?.enclosures[0]?.path, { timeout: 15_000 }).toBe(true);
// Off for a show until someone turns it on: nothing is cut.
expect((await entry()).enclosures[0].deadair).toBeUndefined();
await page.reload();
// On, in the show's settings.
await page.locator('#feedlist .feed', { hasText: 'Quiet Show' }).first().click();
await page.locator('#content .acts [data-a="settings"]').click();
const sw = page.locator('#sdead');
await expect(sw).not.toBeChecked();
await sw.locator('xpath=..').click();
await page.locator('#ssave').click();
// Found at once, not at the next pass, and the row says so when it is done without a reload:
// "Cutting dead air…" until then, then about 1.7 s off each of four 2 s silences.
const row = page.locator('.ep', { hasText: 'Pauses Between' });
await expect(row).toContainText('s shorter', { timeout: 20_000 });
expect((await entry()).enclosures[0].deadair).toBeGreaterThan(5);
await expect(row).not.toContainText('Cutting dead air');
// A longer pause of your own (#182) saves less.
const usual = (await entry()).enclosures[0].deadair;
await page.locator('#content .acts [data-a="settings"]').click();
await expect(page.locator('#sdpause')).toHaveValue('300');
await page.locator('#sdpause').fill('500');
await page.locator('#ssave').click();
await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual);
// 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' } })]);
return { status: d.status, name: d.headers.get('content-disposition'), size: (await d.arrayBuffer()).byteLength, full: (await o.arrayBuffer()).byteLength,
// Kept out of shared caches, or Cloudflare serves its own copy to anyone (#189).
caching: [d, o, p].map(r => r.headers.get('cache-control')), piece: p.status };
}, (await entry()).enclosures[0].id);
expect(saved.status).toBe(200);
expect(saved.name).toContain('DeadAir');
expect(saved.size).toBeLessThan(saved.full);
expect(saved.caching).toEqual(['private', 'private', 'private']);
expect(saved.piece).toBe(206);
// Played through DeadAir's playlist (#181), by hls.js in Chromium, with the switch beside the
// speed pressed. The element's own src is then hls.js's blob: address.
// How long it took to start goes to the server's log, under the trace of the tap (#190).
const started = page.waitForRequest(r => r.url().endsWith('/api/client') && r.postDataJSON()?.ev === 'play_start');
const piece = page.waitForRequest(r => r.url().endsWith('/deadair.mp3'));
await row.locator('[data-a=play]').click();
await expect.poll(() => page.evaluate(() => player.src)).toMatch(/\/deadair\.m3u8$/);
const report = await started;
expect(report.postDataJSON()).toMatchObject({ deadair: true });
expect(report.postDataJSON().ms).toBeGreaterThan(0);
expect(report.headers().traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
// hls.js's own requests carry the same trace as the tap that played them (#192).
const traceOf = h => h.traceparent?.split('-')[1];
expect(traceOf((await piece).headers())).toBe(traceOf(report.headers()));
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').currentTime), { timeout: 15_000 }).toBeGreaterThan(0.5);
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'true');
// Off from the player: the original, from the same place.
await page.locator('#pdead').click();
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'false');
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').getAttribute('src'))).toMatch(/\/media\/\d+$/);
expect((await entry()).enclosures[0].deadair).toBeUndefined();
await page.locator('#pclose').click();
await page.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
});
test('a downloaded file\'s buttons sit on one row in the files pane', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
await expect(page.locator('.ep').first()).toBeVisible();
await page.evaluate(() => showDetail(S.entries.find(e => e.enclosures.some(x => x.path))));
const box = page.locator('#files .encbox').first();
await expect(box.locator('.btn')).toHaveCount(4);
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);
});

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Command Line Show</title><link>http://127.0.0.1:8792/</link>
<description>Added with ipx add while the daemon runs.</description>
<item><title>From the Command Line</title><guid>cli-1</guid><description>x</description></item>
</channel></rss>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Linked Site</title><link>http://127.0.0.1:8792/site.html</link>
<item><title>Linked Post</title><guid>linked-1</guid></item>
</channel></rss>

View File

@@ -0,0 +1,2 @@
<!doctype html>
<html><head><title>No Feed Here</title></head><body>A site that links no feed.</body></html>

BIN
tests/ui/fixtures/quiet.mp3 Normal file

Binary file not shown.

View File

@@ -0,0 +1,8 @@
<?xml version="1.0"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel><title>Quiet Show</title><link>http://127.0.0.1:8792/</link><description>Real audio with long silences, for DeadAir.</description>
<item><title>Pauses Between</title><guid>quiet-1</guid>
<pubDate>Mon, 15 Sep 2026 10:00:00 +0000</pubDate>
<description>A second of tone, then two of silence, five times.</description>
<enclosure url="http://127.0.0.1:8792/quiet.mp3" length="60518" type="audio/mpeg"/></item>
</channel></rss>

View File

@@ -12,7 +12,10 @@ http.createServer((req, res) => {
fs.readFile(file, (err, body) => {
if (err) { res.writeHead(404).end('no'); return; }
const type = file.endsWith('.mp3') ? 'audio/mpeg'
: file.endsWith('.opml') ? 'text/x-opml' : 'application/xml';
: file.endsWith('.opml') ? 'text/x-opml'
// Artwork as an image, or /api/art refuses it as not one.
: file.endsWith('.jpg') ? 'image/jpeg'
: file.endsWith('.html') ? 'text/html' : 'application/xml';
res.writeHead(200, { 'content-type': type, 'content-length': body.length });
res.end(body);
});

View File

@@ -0,0 +1,4 @@
<!doctype html>
<html><head><title>A Site</title>
<link rel="alternate" type="application/rss+xml" href="/linked.xml">
</head><body>A site with a feed.</body></html>

View File

@@ -4,16 +4,17 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>iPodderX admin</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<meta name="theme-color" content="#0a1726">
<title>iPX admin</title>
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" data-src="app.css">
</head>
<body class="adminpage">
<!-- The server sends this page, and its script, to admins only. -->
<header id="topbar">
<a class="btn ico" href="/" title="Back to iPodderX" aria-label="Back to iPodderX" data-icon="left"></a>
<img class="logo" src="/icon.png" alt="" width="26">
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
<img class="logo onlight" src="/logo.svg" alt="" width="26"><img class="logo ondark" src="/logo-dark.svg" alt="" width="26"><img class="logo classic" src="/icon.png" alt="" width="26">
<h1>Admin</h1>
<span class="grow"></span>
<nav class="tabs" id="atabs">

View File

@@ -2,44 +2,44 @@
anyone else. One variable file covers every weight used here; italics are synthesized. Classic
keeps Lucida Grande, the 2004 app's face. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
/* Palette taken from the 2004 iPodderX icon: the silver device body, the blue
screen, and the amber EQ bars. Hex values in comments are sampled straight from it. */
/* Palette taken from the logo (web/logo.svg, logo-dark.svg): its navy ground, the tuning scale's
blue and the orange needle. The dark half follows logo-dark.svg. */
:root {
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
--panel:#151c27;
--panel2:#1c2431;
--raise:#25303f;
--line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
--accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */
--bg:#0a1726; /* the dark logo's navy (#0c2238), taken down */
--panel:#0f1f31;
--panel2:#15283d;
--raise:#1d3450;
--line:#264060;
--fg:#f3f7fb;
--dim:#a3b6ca;
--faint:#8a9fb5;
--accent:#8fc2ea; /* #8FC2EA the dark logo's scale */
--accent2:#ff6a1a; /* #FF6A1A the dark logo's needle */
--ink:#0a1726; /* text on an accent fill */
--good:#6fbf8b;
--warn:#f49e2c; /* the amber doubles as the pending colour */
--bad:#e2705f;
--shadow:0 8px 28px rgba(6,10,16,.55);
--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);
}
/* 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
one block per variant here and none needs repeating under a media query. */
:root[data-theme="modern"][data-mode="light"] {
--bg:#f2f4f7;
--panel:#ffffff; /* #FFFFFF device body */
--panel2:#e9edf3;
--raise:#dde3ec;
--line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */
--faint:#6b6b6b; /* between the icon's #929292 and #606060, to clear AA on the page ground */
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#985e0a; /* the EQ amber, taken down until white on it clears AA */
--bg:#eef5fb; /* the light logo's sky (#DFF0FB), paler */
--panel:#ffffff;
--panel2:#e4eff9;
--raise:#d5e6f5;
--line:#c6d9ea;
--fg:#10233a;
--dim:#46596e;
--faint:#56697e;
--accent:#2f6aa0; /* #2F6AA0 the logo's scale */
--accent2:#c43e00; /* the needle (#FF5500), taken down until white on it clears AA */
--ink:#ffffff;
--good:#2f7d4f;
--warn:#985e0a; /* the same amber; the lighter one failed AA as text */
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
--warn:#985e0a; /* amber, apart from the needle's orange; lighter failed AA as text */
--bad:#b3263a; /* pushed towards crimson, apart from the needle */
--shadow:0 8px 28px rgba(47,106,160,.14);
}
/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour
(#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */
@@ -403,10 +403,67 @@
--bad:#c42b1c;
--shadow:0 4px 16px rgba(0,0,0,.28);
}
/* Glass, after Apple's Liquid Glass: Apple's system colours, on panels that let a soft coloured
wash show through. The hex values are what a panel reads as once blended, so the contrast check
still means something; the translucency and the wash are in the chrome at the end of the sheet. */
:root[data-theme="glass"] {
--bg:#0b0d14;
--panel:#161a24;
--panel2:#1f2430;
--raise:#2a3040;
--line:#343b4c;
--fg:#f5f5f7; /* Apple's label */
--dim:#aeaeb2; /* systemGray2 */
--faint:#98989f; /* systemGray, a shade up to clear AA over the wash */
--accent:#409cff; /* systemBlue's accessible dark variant; #0a84ff falls short as link text */
--accent2:#ff9f0a; /* systemOrange */
--ink:#000000;
--good:#30d158;
--warn:#ff9f0a;
--bad:#ff6961;
--shadow:0 10px 36px rgba(0,0,0,.45);
--glass-edge:rgba(255,255,255,.10);
--glass-hi:rgba(255,255,255,.26);
--glass-lo:rgba(255,255,255,.10);
--glass-sheen:rgba(255,255,255,.07);
--glass-bar:#191e34; /* the top bar on a phone: what the glass one showed, on average */
--wash1:rgba(64,120,255,.30);
--wash2:rgba(175,82,222,.24);
--wash3:rgba(48,176,199,.20);
}
:root[data-theme="glass"][data-mode="light"] {
--bg:#eef1f7;
--panel:#ffffff;
--panel2:#f2f4f8;
--raise:#e4e8f0;
--line:#d1d5de;
--fg:#1d1d1f;
--dim:#515154;
--faint:#5a5a5f; /* systemGray, taken down to clear AA over the wash */
--accent:#0055aa; /* a shade under Apple's #0066cc, which falls short over the wash */
--accent2:#b25000; /* systemOrange taken down until white on it clears AA */
--ink:#ffffff;
--good:#248a3d;
--warn:#824200;
--bad:#b8000f;
--shadow:0 10px 36px rgba(30,50,90,.16);
--glass-edge:rgba(255,255,255,.65);
--glass-hi:rgba(255,255,255,.9);
--glass-lo:rgba(255,255,255,.45);
--glass-sheen:rgba(255,255,255,.5);
--glass-bar:#f6f7ff;
--wash1:rgba(64,120,255,.22);
--wash2:rgba(175,82,222,.16);
--wash3:rgba(48,176,199,.18);
}
*{box-sizing:border-box}
/* A rule that sets display beats the UA's [hidden], and several below do. */
[hidden]{display:none!important}
html,body{height:100%}
/* What iOS paints behind the status bar where nothing sticky is at the top edge, as in the
reader on a phone, which brings its own. With no colour of its own, the root went black or
white there instead (#147). */
html{background-color:var(--bg)}
body{
margin:0;background:var(--bg);color:var(--fg);
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
@@ -442,20 +499,42 @@ a{color:var(--accent)}
background:var(--panel);border-right:1px solid var(--line);
display:flex;flex-direction:column;min-height:0;
}
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
.brand .logo{
width:30px;height:30px;flex:none;object-fit:contain;
}
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1}
#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
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}
/* 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}
.iconbtn{
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)}
/* 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
shell and an iOS home-screen app both give it, so the bars along the edges pay for them in
padding. A browser with its own chrome reports nought and nothing moves. */
:root{
--safe-t:env(safe-area-inset-top,0px);
--safe-b:env(safe-area-inset-bottom,0px);
--safe-l:env(safe-area-inset-left,0px);
--safe-r:env(safe-area-inset-right,0px);
}
#topbar{
display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden;
display:flex;align-items:center;gap:10px;min-width:0;overflow:hidden;
/* Longhand, and it has to stay longhand: the minifier runs a calc() in a padding shorthand
into the value after it -- `calc(12px + var(--safe-r))7px` -- and the browser then throws
the whole declaration away, leaving the bar with no padding at all. */
padding-top:calc(7px + var(--safe-t));padding-bottom:7px;
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
background:var(--panel);border-bottom:1px solid var(--line);
/* Nothing scrolls under it: it is sticky only because iOS 26 paints the strip behind the status
bar and the Dynamic Island in the colour of a sticky or fixed bar at the top edge, and ignores
theme-color, so the bar now runs all the way up behind the island (#147). */
position:sticky;top:0;
}
#topbar .grow{flex:1}
#topbar #epSearch{width:260px;flex:none}
@@ -482,7 +561,7 @@ a{color:var(--accent)}
.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:0 12px 8px}
.searchwrap{padding:12px 12px 8px}
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);
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
@@ -500,7 +579,14 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
.feed.child{margin-left:11px}
/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */
.feed .fpin .i{width:10px;height:10px;margin-right:5px;vertical-align:-1px;color:var(--accent)}
/* Pinned: a disc on the artwork's corner, as a failing feed's mark is, in the accent and the ink
the theme pairs with it. A feed both pinned and failing keeps the error below, the pin above. */
.fpin{
position:absolute;right:-5px;bottom:-5px;width:17px;height:17px;border-radius:50%;
display:grid;place-items:center;background:var(--accent);color:var(--ink);border:2px solid var(--bg);
}
.fpin .i{width:9px;height:9px}
.feed.err .fpin{bottom:auto;top:-5px}
.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}
@@ -520,11 +606,20 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
}
@keyframes ipxspin{to{transform:rotate(360deg)}}
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
a child's included, which is why it moves left by the child's indent. */
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
.ferr .i{width:12px;height:12px}
.feed.child .ferr{left:-27px}
body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,body.scan-any #scanAll .i{
animation:ipxspin 1s linear infinite}
/* A feed's error mark: a solid disc on the artwork's corner, ringed in the page ground so it
stands off any cover. The glyph is cut out of it in --bg, which is the dark against the light
--bad of a dark theme and the light against the deep one of a light theme. */
.fart{position:relative;flex:none;display:grid}
.ferr{
position:absolute;right:-5px;bottom:-5px;width:17px;height:17px;border-radius:50%;
display:grid;place-items:center;background:var(--bad);color:var(--bg);border:2px solid var(--bg);
}
.ferr .i{width:9px;height:9px}
.feed.err .txt small{color:var(--bad)}
/* Failing for a day or more: the cover goes grey, a show gone off the air. */
.feed.failing .fart>.art{filter:grayscale(1);opacity:.45}
.chev .i{width:12px;height:12px;transition:transform .12s}
.chev[aria-expanded="true"] .i{transform:rotate(90deg)}
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
@@ -555,24 +650,102 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
/* Quiet buttons: a filled one on every row outshouted the row that is playing. */
#listening [data-a=play]{color:var(--accent)}
#listening [data-a=remove]{color:var(--faint)}
/* Directory's filters: .tabs for what a feed is, as the item filters are, and chips for what it
is about. A picked chip is underlined in --accent2, as the download bar and the now-playing EQ
are; a .badge's fill already means unread in the sidebar. */
.dirbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px}
.dirbar .tabs{flex:none}
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0}
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent}
.chips button:hover{color:var(--fg)}
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
/* The Directory, laid out as Apple's is: a large title, then sections. Every page carries what
kind of feed (.tabs, as the item filters are) in its title row. */
.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)}
.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}
.dsec{margin-top:28px}
.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}
/* 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)}
.rank .no{font-size:17px;font-weight:650;color:var(--faint);text-align:right;font-variant-numeric:tabular-nums}
.rank .art{width:52px;height:52px;font-size:16px}
.rank .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:14px;line-height:1.3}
.rank .txt small{display:block;margin-top:2px;color:var(--faint);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* A category: its name, and its three most subscribed shows' covers fanned in the corner, so the
shows are what colour it. Not tinted: a tint by the name's hash meant nothing, and the five that
drew --good read as yours, which green means everywhere else (#131). The fan opens a little
under the pointer, the only thing on the page that moves. */
.cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:12px}
.cat{
position:relative;overflow:hidden;height:116px;padding:13px 15px;border-radius:12px;text-align:left;
display:flex;flex-direction:column;align-items:flex-start;
background:var(--panel);border:1px solid var(--line);
}
.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}
.cat small{margin-top:3px;font-size:12px;color:var(--dim);font-variant-numeric:tabular-nums}
.cat .fan{position:absolute;right:14px;bottom:-12px;width:112px;height:80px;pointer-events:none}
.cat .fan .art{
position:absolute;bottom:0;width:62px;height:62px;font-size:16px;border-radius:8px;box-shadow:0 3px 10px rgba(0,0,0,.3);
/* Each cover on a layer of its own before the pointer arrives. Given one only as the fan began
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;
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}
/* 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)}
.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}
/* Square cover art, title underneath: podcast art is made to be known at a glance. The subscribe
button stays visible, since a button that only shows on hover cannot be reached on a phone. */
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px;margin-top:14px}
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px}
.tiles>.hint{grid-column:1/-1}
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start;cursor:pointer}
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start}
.tile .art{grid-column:1/-1;width:100%;height:auto;aspect-ratio:1;font-size:34px}
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
.tile:hover .txt b{color:var(--accent)}
/* 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)}
/* 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}
.show>.art{width:180px;height:180px;font-size:48px;border-radius:12px;box-shadow:var(--shadow)}
.show .meta{min-width:0;flex:1;display:flex;flex-direction:column;align-items:flex-start;gap:5px}
.show h2{margin:0 0 2px;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-.02em;overflow-wrap:anywhere}
.show .sub{margin:0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums}
.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}
/* 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}
.about.open p{display:block}
.about .dlink{margin-top:4px}
.latest{max-width:760px}
.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)}
.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
themes, where on the row's own ground they clear it. */
.tag{
@@ -601,7 +774,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
/* A selected row is --raise too, and the zero count's pill vanished into it. */
.sel .badge.zero{background:var(--bg)}
/* Counts, sizes and times that change in place should not jiggle the text around them. */
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
.badge,.feed small,.childrow small,.tile small,.rank small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
/* ---------- main ---------- */
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
@@ -613,7 +786,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
/* Three panes, as the original had: feeds beside, items above, the item below. */
#split{
display:grid;flex:1;min-height:0;
display:grid;flex:1;min-height:0;overflow:hidden; /* the reader sliding on a swipe */
grid-template-columns:minmax(0,1fr) 270px;
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
grid-template-areas:"list files" "grab grab" "detail detail";
@@ -623,11 +796,21 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
reloads the page, from starting on top of it. */
#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. */
#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)}
#pullspin::before{content:"";width:12px;height:12px;border-radius:50%;
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite}
/* The selected item's files, beside the list, as the original's Files pane was. */
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
/* Nothing selected, or an item with no files: the list takes the width. */
#split:has(>#files[hidden]){grid-template-columns:minmax(0,1fr) 0}
#files .encbox{margin-top:8px}
/* One row in the narrow pane: four buttons and the size wrapped the last button under the rest.
The size gives way first, with an ellipsis. */
#files .encbox{margin-top:8px;flex-wrap:nowrap;gap:6px;padding:10px}
#files .encbox .meta{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#files .encbox .btn.ico{padding:4px 7px;min-width:0;flex:none}
#files .empty{padding:24px 0}
.fhd{font-size:12px;color:var(--faint)}
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
@@ -636,6 +819,9 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.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}
.dmeta .btn{padding:3px 9px;font-size:12px}
/* 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}
#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}
@@ -652,12 +838,10 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.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)}
.acts{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:auto}
.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.primary i{opacity:.9}
.acts .btn:hover{background:var(--raise)}
.acts .btn.primary:hover{background:var(--accent)}
.btn{
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 12px;font-size:13px;
@@ -702,7 +886,7 @@ kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:
body.adminpage{display:block;overflow:auto}
body.adminpage #topbar{position:sticky;top:0;z-index:5}
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
body.adminpage #topbar .logo{border-radius:5px}
body.adminpage #topbar .logo{border-radius:6px}
body.adminpage #topbar > a.btn{text-decoration:none}
body.adminpage #admin{max-width:780px;margin:0 auto}
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
@@ -745,7 +929,15 @@ body.adminpage #log{max-width:none}
icon, so it spilled 3px right of centre and off line with its heading. */
.ep .st,.ep .fl{width:22px;height:22px;padding:0;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent2)}
.ep .fl{color:var(--faint);font-size:13px}
.ep .fl.on{color:var(--fg)}
/* Pinned: the pinned feed's disc (.fpin), the accent and its ink, drawn inside the 22px button so
the row does not move when it changes. */
.ep .fl.on,.ep .fl.on:hover{color:var(--ink);background:radial-gradient(circle,var(--accent) 8.5px,transparent 9px)}
.ep .fl.on .i{width:9px;height:9px}
/* The reader's and a feed's pin button, and the toolbar's, pinned: filled with the accent, as the
disc is. A solid pin on the button's own grey read the same as an outlined one (#148). */
.btn.ico[data-a=pin][aria-pressed=true],.btn.ico[data-a=pin][aria-pressed=true]:hover,
.btn.ico[data-a=flag][aria-pressed=true],.btn.ico[data-a=flag][aria-pressed=true]:hover,
#tbFlag[aria-pressed=true],#tbFlag[aria-pressed=true]:hover{background:var(--accent);border-color:var(--accent);color:var(--ink)}
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
@@ -758,6 +950,8 @@ 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}
/* 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}
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
.ep .line:empty{display:none}
@@ -783,7 +977,9 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
#player{
border-top:1px solid var(--line);background:var(--panel);
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
padding-top:9px;padding-bottom:calc(9px + var(--safe-b));
padding-left:calc(16px + var(--safe-l));padding-right:calc(16px + var(--safe-r));
box-shadow:0 -6px 24px rgba(6,10,16,.4);
}
#player.on{display:grid}
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
@@ -856,10 +1052,100 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.inline{display:flex;gap:6px;align-items:stretch}
.inline input{flex:1;min-width:0}
.inline .btn{white-space:nowrap;flex:none}
/* An API token in Settings: its name and dates, and the button that revokes it. */
.tokrow{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}
.tokrow .hint{display:block}
.inline select{flex:none;width:auto}
.inline input[type=number]{flex:none;width:90px}
.check input{width:16px;height:16px;accent-color:var(--accent)}
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
/* ---------- 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 .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 .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}
.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. */
.switch{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:38px;font-size:14px;font-weight:500;cursor:pointer}
/* The one switch the rest hang from. */
.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}
/* 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)}
.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}
/* 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}
/* 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}
.seg input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
.seg label:has(input:checked){background:var(--raise);color:var(--fg);font-weight:500}
.seg label:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:-2px}
/* The feed's newest items, newest first, each as tall as it is long; lit, those that download on
their own. Dragged along, it sets how many. Only drawing: the number in the sentence below is
what a keyboard and a screen reader use. */
.epwin{display:flex;align-items:flex-end;gap:2px;height:38px;margin-bottom:8px;cursor:ew-resize;touch-action:pan-y;user-select:none;-webkit-user-select:none}
.epwin:empty{display:none}
.epwin i{flex:1 1 0;min-width:2px;max-width:10px;height:var(--h);border-radius:2px;background:var(--line);transition:background-color .2s calc(var(--n) * 12ms)}
.epwin i.on{background:var(--accent)}
.epwin i.nofile{height:4px}
.epwin small{flex:none;align-self:center;margin-left:8px;font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums}
.epwin.all small{color:var(--accent)}
.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}
/* 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}
.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}
.dpslide::before{left:0;right:0;height:6px;margin-top:-3px;border-radius:3px;background:var(--raise)}
.dpslide::after{left:calc(var(--t)/2 - 3px);right:calc(var(--t)/2 - 3px);height:6px;margin-top:-3px;
background:radial-gradient(circle,var(--dim) 2px,transparent 2.5px) 0 0/6px 6px no-repeat,
radial-gradient(circle,var(--dim) 2px,transparent 2.5px) 33.333% 0/6px 6px no-repeat,
radial-gradient(circle,var(--dim) 2px,transparent 2.5px) 66.667% 0/6px 6px no-repeat,
radial-gradient(circle,var(--dim) 2px,transparent 2.5px) 100% 0/6px 6px no-repeat}
.dpslide input[type=range]{position:relative;z-index:1;width:100%;height:var(--t);margin:0;background:transparent}
.dpslide input[type=range]::-webkit-slider-thumb{width:var(--t);height:var(--t);box-shadow:0 0 0 3px var(--panel),0 1px 4px rgba(0,0,0,.3)}
.dpslide input[type=range]::-moz-range-thumb{width:var(--t);height:var(--t);box-shadow:0 0 0 3px var(--panel),0 1px 4px rgba(0,0,0,.3)}
.dpslide input[type=range]:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:var(--t)}
.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: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)}
.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}
.toast{
background:var(--raise);border:1px solid var(--line);border-radius:9px;
@@ -871,9 +1157,14 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
#burger,#dback{display:none}
/* Totals for what is showing, along the bottom, as the original's status bar. */
#status{
padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
padding-top:3px;padding-bottom:calc(3px + var(--safe-b));
padding-left:calc(14px + var(--safe-l));padding-right:calc(14px + var(--safe-r));
min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
/* Only the last bar along the bottom owes the indicator anything, and with a player open that
is the player, not this. Without :has() the worst of it is a gap above the player bar. */
body:has(#player.on) #status{padding-bottom:3px}
/* The feed's own header, kept to one line so the table starts high, as it did. */
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
@@ -894,14 +1185,25 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
@media (max-width:820px){
#shell{grid-template-columns:1fr}
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
#sidebar.open{transform:none}
/* The shadow only while open: off the screen, the drawer's fell across the page's left edge,
the top bar's included (#151). */
#sidebar{position:fixed;inset:0 auto 0 0;padding-top:var(--safe-t);width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s,box-shadow .2s}
#sidebar.open{transform:none;box-shadow:var(--shadow)}
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
#player{position:relative;z-index:39;padding:7px 10px;gap:8px}
#player{
position:relative;z-index:39;gap:8px;
padding-top:7px;padding-bottom:calc(7px + var(--safe-b));
padding-left:calc(10px + var(--safe-l));padding-right:calc(10px + var(--safe-r));
}
/* The feed list is reachable whether or not anything is playing. */
#burger{display:grid}
#topbar{gap:6px;padding:6px 8px}
/* No logo on a phone: the bar has no room to spare for it, and no hover to show its version. */
#applogo{display:none}
#topbar{
gap:6px;padding-top:calc(6px + var(--safe-t));padding-bottom:6px;
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}
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
@@ -914,9 +1216,21 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
#files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none}
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
#grab{display:none}
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px}
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t))}
body.reading #detail{display:block}
#dback{display:inline-block;margin-bottom:10px}
/* Beside the reader while it is swiped (gestures.ts): the item next to it, with a strip of the
page's own background between them, so the list under the reader never shows (#52). */
#dpeek{position:fixed;top:0;bottom:0;left:0;width:calc(100% + 16px);z-index:38;background:var(--bg);pointer-events:none}
#dpeek>div{width:calc(100% - 16px);height:100%;overflow:hidden;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t));background:var(--panel)}
#dpeek.next>div{margin-left:16px}
#dpeek .encbox{margin:0 0 10px}
/* Past the last item, the accent and no words: the colour says it is the end. */
#dpeek.end{background:var(--accent)}
/* Going back to the list from the first item: the list waits under a dimmer that lifts as the
reader, shadowed along its edge, is drawn off it. */
#dpeek.dim{width:100%;z-index:37;background:#000}
#detail.popping{box-shadow:-12px 0 32px rgba(0,0,0,.28)}
#content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px}
#content>.fhead{padding-top:12px}
@@ -936,9 +1250,22 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
#pnow .txt small,#pright #vol{display:none}
.wrap.plain{padding:14px}
.card{padding:16px}
/* The chips get a line of their own and scroll sideways rather than wrap, so they never push
the grid down. */
.chips{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
/* The subcategories get a line of their own and scroll sideways rather than wrap, so they
never push the grid down. */
.dtitle h2{font-size:25px}
.dsec{margin-top:22px}
.dsech h3{font-size:17px}
.chart{grid-template-columns:minmax(0,1fr);grid-template-rows:none;grid-auto-flow:row}
.cats{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
.cat{height:100px;padding:11px 12px}
.cat b{font-size:15px}
.cat .fan{right:6px;bottom:-16px;transform:scale(.7);transform-origin:bottom right}
.pills{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
.dtools{flex-wrap:wrap}
.show{flex-direction:column;align-items:flex-start;gap:14px}
.show>.art{width:150px;height:150px;font-size:40px}
.show h2{font-size:24px}
#dirsort{margin-left:auto}
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
/* A log line has no room for four columns: keep time and message, wrap as prose. */
@@ -948,6 +1275,114 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
#logbox .tg{display:none}
}
/* ---------- A phone, drawn the way iOS draws its own apps (#53) ---------- */
/* Filled shapes rather than outlines, corners that grow rounder as the shape grows and nest inside
each other, 44px to tap and 17px to read. The outlines stay where they are what makes a control
visible at all: the high-contrast theme, and anyone who has asked the system for more contrast.
--edge is the one switch for that. */
@media (max-width:820px){
:root{--edge:transparent}
:root[data-theme="contrast"]{--edge:var(--line)}
/* Glass rules its panels off with an edge of its own; the light along them (--glass-rim) stays. */
:root[data-theme="glass"],:root[data-theme="glass"][data-mode="light"]{--glass-edge:transparent}
}
@media (max-width:820px) and (prefers-contrast:more){
:root{--edge:var(--line)}
}
@media (max-width:820px){
/* The bars run into the page instead of being ruled off from it. */
#topbar{background:var(--bg);border-bottom-color:var(--edge);gap:8px}
#status{background:var(--bg);border-top-color:var(--edge)}
#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. */
.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}
.btn.tiny{min-height:0;padding:2px 9px}
.iconbtn{width:40px;height:40px;border-radius:999px;background:var(--panel2)}
.tgroup{border-radius:999px}
.tgroup button+button,.tgroup button+a{border-left-color:var(--edge)}
.tgroup button,.tgroup a{min-width:36px;height:40px;padding:0 8px}
/* 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;
}
#topbar #epSearch{font-size:16px;border-radius:999px;padding: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. */
.fhead h2,.fhead.slim h2{font-size:26px;font-weight:700;letter-spacing:-.01em;line-height:1.2}
.fhead .sub{font-size:15px}
.acts{gap:8px}
.acts .btn{min-width:44px;height:44px;padding:0 13px;justify-content:center;font-size:15px}
/* Seven to a row with no room to spare at 390px: below that an icon button narrows rather than
the row breaking, which left Unsubscribe alone on a second row (#146). */
.acts .btn.ico{flex:0 1 44px;min-width:0;padding:0}
/* Tabs as a segmented control: equal segments on one track, the chosen one lifted. */
.tabs{flex:1 1 100%;border-radius:10px;padding:2px}
.tabs button,.tabs a{flex:1;text-align:center;padding:7px 4px;border-radius:8px;font-size:14px}
.tabs button.on,.tabs a.on{box-shadow:0 1px 3px rgba(0,0,0,.22)}
/* 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}
.ep.sel,.ep.sel+.ep{background-image:none}
.feed{padding:9px 10px;min-height:44px;border-radius:10px}
.places{border-bottom-color:var(--edge);padding-bottom:10px;margin-bottom:10px}
.feed .txt b{font-size:17px}
.feed .txt small{font-size:13px}
/* The reader. */
.dt{font-size:26px;font-weight:700;line-height:1.2;letter-spacing:-.01em;margin-bottom:8px}
.dmeta{font-size:15px;gap:10px}
.encbox{border-color:var(--edge);border-radius:14px;padding:8px 8px 8px 14px}
.dbody{font-size:17px;line-height:1.55}
/* Dialogs are sheets, up from the bottom. */
#modal{place-items:end stretch;padding:0}
.card,.card.wide{
width:100%;max-height:calc(100dvh - var(--safe-t) - 12px);border-color:var(--edge);border-radius:20px 20px 0 0;
padding:20px 16px calc(20px + var(--safe-b));animation:sheet .3s cubic-bezier(.2,.8,.2,1);
}
.card h3{font-size:22px}
.field label,.field .hint{font-size:13px}
.check{font-size:17px}
/* A feed's settings, at a phone's sizes. */
.card.fset{padding:0}
.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}
.fsethead p,.wcap,.cap{font-size:14px}
.switch,.flabel{font-size:17px}
.switch{min-height:44px}
.sw{width:51px;height:31px}
.sw i::after{width:25px;height:25px}
.sw input:checked+i::after{transform:translateX(20px)}
.seg label{padding:7px 4px;border-radius:8px;font-size:14px}
.seg label:has(input:checked){box-shadow:0 1px 3px rgba(0,0,0,.22)}
.dpslide{--t:28px}
.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}
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
.chipx{width:26px;height:26px}
.fdl,.field.boxed{border-radius:14px}
.toast{border-radius:14px}
}
@keyframes sheet{from{transform:translateY(100%)}}
/* ---------- Classic: the 2004 Mac app ---------- */
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
for whatever is selected, red unread badges, a striped table and Lucida Grande. */
@@ -957,6 +1392,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
:root[data-theme="classic"] #topbar,
:root[data-theme="classic"] #status,
:root[data-theme="classic"] #player{background:linear-gradient(#ececec,#c9c9c9);border-color:#8e8e8e;color:#1a1a1a}
/* The colour iOS takes for above the bar, where it reads no gradient: the top of the metal. */
:root[data-theme="classic"] #topbar{background-color:#ececec}
:root[data-theme="classic"] #status{text-align:center}
:root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3}
:root[data-theme="classic"] #files{background:#f4f4f4}
@@ -977,6 +1414,11 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
:root[data-theme="classic"] .feed.sel .txt small,
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
:root[data-theme="classic"] #epSearch{border-radius:14px}
/* The 2004 app had checkboxes, not switches. */
:root[data-theme="classic"] .sw{width:auto;height:auto}
:root[data-theme="classic"] .sw input{position:static;width:14px;height:14px;opacity:1}
:root[data-theme="classic"] .sw i{display:none}
:root[data-theme="classic"] .seg label:has(input:checked){background:linear-gradient(#86b0ec,#3a73cf);color:#fff}
:root[data-theme="classic"] .badge{background:#c9302c;color:#fff}
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
:root[data-theme="classic"] .ephead,
@@ -1006,3 +1448,99 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
:root[data-theme="classic"] .childrow{background:#fff}
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
/* ---------- Glass: after Apple's Liquid Glass ---------- */
/* Translucent panels over a coloured wash, blurred and saturated the way macOS and iOS do it.
Apple's glass also bends light at its edges; that needs an SVG displacement filter that only
Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43).
What is kept is the light: the rim catches it at the top-left edges and, fainter, the far ones,
as it passes through (#51). */
:root[data-theme="glass"]{--glass-rim:inset 1px 1px 0 var(--glass-hi),inset -1px -1px 0 var(--glass-lo)}
:root[data-theme="glass"] body{
font-family:-apple-system,BlinkMacSystemFont,system-ui,Inter,"Segoe UI",Roboto,sans-serif;
background:
radial-gradient(60% 55% at 8% 0%,var(--wash1),transparent),
radial-gradient(50% 50% at 92% 18%,var(--wash2),transparent),
radial-gradient(60% 60% at 60% 100%,var(--wash3),transparent),
var(--bg);
}
:root[data-theme="glass"] #sidebar,
:root[data-theme="glass"] #topbar,
:root[data-theme="glass"] #player,
:root[data-theme="glass"] #status,
:root[data-theme="glass"] #files,
:root[data-theme="glass"] #detail,
:root[data-theme="glass"] #dpeek>div,
:root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast{
/* A sheen from the top-left corner, where the light comes from. It is a background layer rather
than a pseudo-element because the detail pane, the file list and the dialogs scroll, and an
absolutely placed layer inside a scroller scrolls away with the content. */
background:linear-gradient(135deg,var(--glass-sheen),transparent 45%),color-mix(in srgb,var(--panel) 70%,transparent);
-webkit-backdrop-filter:blur(24px) saturate(180%);
backdrop-filter:blur(24px) saturate(180%);
border-color:var(--glass-edge);
box-shadow:var(--glass-rim);
}
:root[data-theme="glass"] .toolbar,
:root[data-theme="glass"] .ephead{
/* Sticky, so the list scrolls under them: the one place the frosting has something to blur. */
background:color-mix(in srgb,var(--bg) 65%,transparent);
-webkit-backdrop-filter:blur(20px) saturate(180%);
backdrop-filter:blur(20px) saturate(180%);
}
:root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast{box-shadow:var(--glass-rim),var(--shadow)}
:root[data-theme="glass"] .toast{border-radius:14px}
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */
@media (max-width:820px){
:root[data-theme="glass"] #sidebar.open{box-shadow:inset -1px 0 0 var(--glass-hi),var(--shadow)}
}
/* The card is the glass; a lighter scrim leaves something behind it to see through. */
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)}
:root[data-theme="glass"] .tgroup,
:root[data-theme="glass"] .tabs{
background:color-mix(in srgb,var(--panel2) 60%,transparent);
border-color:var(--glass-edge);box-shadow:var(--glass-rim);
}
/* Rounder than the other themes; a phone has shapes of its own (#53), which these would outrank. */
@media (min-width:821px){
:root[data-theme="glass"] .card{border-radius:20px}
:root[data-theme="glass"] .tgroup,
:root[data-theme="glass"] .tabs{border-radius:12px}
:root[data-theme="glass"] .btn,
:root[data-theme="glass"] .sidetools button,
:root[data-theme="glass"] .sidefoot button{border-radius:10px}
}
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
:root[data-theme="glass"] .card.fset .fsetfoot{border-top-color:var(--glass-edge);background:color-mix(in srgb,var(--panel) 60%,transparent)}
/* On a phone the rim all round a small capsule or bar read as the outline #53 took away, so the
light only catches the top edge there, and the bars at the top and bottom have none. */
@media (max-width:820px){
:root[data-theme="glass"]{--glass-rim:inset 0 1px 0 var(--glass-lo)}
:root[data-theme="glass"] #topbar,
:root[data-theme="glass"] #status{box-shadow:none}
/* Solid, in the colour the glass showed on average. iOS paints the strip above it, behind the
status bar and the Dynamic Island, in one flat colour, and over a see-through bar that met it
at a seam: near-black above a blue bar (#147). */
:root[data-theme="glass"] #topbar{background:var(--glass-bar);-webkit-backdrop-filter:none;backdrop-filter:none}
}
/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){
:root[data-theme="glass"] body{background:var(--bg)}
:root[data-theme="glass"] #sidebar,
:root[data-theme="glass"] #topbar,
:root[data-theme="glass"] #player,
:root[data-theme="glass"] #status,
:root[data-theme="glass"] #files,
:root[data-theme="glass"] #detail,
:root[data-theme="glass"] #dpeek>div,
:root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast,
:root[data-theme="glass"] .tgroup,
:root[data-theme="glass"] .tabs{background:var(--panel);-webkit-backdrop-filter:none;backdrop-filter:none;border-color:var(--line)}
:root[data-theme="glass"] .toolbar,
:root[data-theme="glass"] .ephead{background:var(--bg);-webkit-backdrop-filter:none;backdrop-filter:none}
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.6)}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -21,7 +21,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
// 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'] },
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
'login.html': { script: 'login.js', src: ['login'] },
};
@@ -30,6 +30,11 @@ const STYLE = 'app.css';
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181). Served as it comes,
// by a name with its hash in it, which the page's script is given in place of __HLS_JS__.
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)}`;
/// 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 } = {}) {
@@ -38,13 +43,21 @@ export function buildStyle({ minify = true } = {}) {
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
return r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
const out = r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
// The minifier drops the space between a calc() and the value after it in a shorthand --
// `padding:7px calc(12px + var(--safe-r))7px ...` -- and a browser throws the whole
// declaration away, so the element silently loses its padding. It reports no error and the
// page still loads, which is why this is checked rather than trusted. Longhands avoid it.
const run = out.match(/calc\([^()]*(?:\([^()]*\)[^()]*)*\)(?=[0-9a-zA-Z.])/);
if (run) throw new Error(`${STYLE}: minifying ran ${run[0]} into the value after it; use longhand properties`);
return out;
}
/// The page and its script, built: { html, js, script }, where script is the file's name.
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');
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
.replaceAll('__HLS_JS__', HLS_URL);
const js = swc.transformSync(src, {
filename: name + '.ts',
jsc: {
@@ -65,6 +78,11 @@ export function buildPage(name, { minify = true } = {}) {
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg)"/g,
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
if (!minify) return { html: out, js, script };
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'Error');
@@ -76,6 +94,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
const out = process.argv[2] || path.join(here, 'dist');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, STYLE), buildStyle());
fs.writeFileSync(path.join(out, 'hls.js'), HLS);
for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html);

BIN
web/favicon-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -2,10 +2,11 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<title>iPodderX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<meta name="theme-color" content="#0a1726">
<title>iPX</title>
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" data-src="app.css">
</head>
@@ -14,6 +15,7 @@
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
A phone hides the item group, which it has no table for. -->
<span id="applogo" title="iPX {version}"><img class="logo onlight" src="/logo.svg" alt="iPX"><img class="logo ondark" src="/logo-dark.svg" alt="iPX"><img class="logo classic" src="/icon.png" alt="iPodderX"></span>
<div class="tgroup">
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
@@ -33,9 +35,6 @@
</header>
<div id="shell">
<aside id="sidebar">
<div class="brand">
<img class="logo" src="/icon.png" alt="iPodderX" title="The original iPodderX icon, 2004"><h1>iPodderX</h1>
</div>
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
<div id="feedlist"></div>
<div class="sidefoot">
@@ -67,6 +66,7 @@
</div>
</div>
<div id="pright">
<button class="iconbtn" id="pdead" aria-pressed="false" aria-label="DeadAir" hidden></button>
<select id="rate" title="Speed">
<option value="0.8">0.8×</option><option value="1" selected>1×</option>
<option value="1.25">1.25×</option><option value="1.5">1.5×</option>

View File

@@ -3,47 +3,49 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>Sign in — iPodderX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<title>Sign in — iPX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png" media="(prefers-color-scheme:light)">
<link rel="icon" type="image/png" sizes="128x128" href="/favicon-dark.png" media="(prefers-color-scheme:dark)">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>
/* Inter, from ipx itself; see the same rule in index.html. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
:root {
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
--panel:#151c27;
--panel2:#1c2431;
--raise:#25303f;
--line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
--accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */
--bg:#0a1726; /* the dark logo's navy (#0c2238), taken down */
--panel:#0f1f31;
--panel2:#15283d;
--raise:#1d3450;
--line:#264060;
--fg:#f3f7fb;
--dim:#a3b6ca;
--faint:#8a9fb5;
--accent:#8fc2ea; /* #8FC2EA the dark logo's scale */
--accent2:#ff6a1a; /* #FF6A1A the dark logo's needle */
--ink:#0a1726; /* text on an accent fill */
--good:#6fbf8b;
--warn:#f49e2c; /* the amber doubles as the pending colour */
--bad:#e2705f;
--shadow:0 8px 28px rgba(6,10,16,.55);
--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);
--r:10px;
}
/* Signed out, there is no account to take a theme from, so this follows the system. */
/* Modern, as app.css has it. Signed out, there is no account to take a theme from, so this follows
the system. */
@media (prefers-color-scheme:light){:root {
--bg:#f2f4f7;
--panel:#ffffff; /* #FFFFFF device body */
--panel2:#e9edf3;
--raise:#dde3ec;
--line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */
--faint:#6b6b6b;
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#985e0a;
--bg:#eef5fb; /* the light logo's sky (#DFF0FB), paler */
--panel:#ffffff;
--panel2:#e4eff9;
--raise:#d5e6f5;
--line:#c6d9ea;
--fg:#10233a;
--dim:#46596e;
--faint:#56697e;
--accent:#2f6aa0; /* #2F6AA0 the logo's scale */
--accent2:#c43e00; /* the needle (#FF5500), taken down until white on it clears AA */
--ink:#ffffff;
--good:#2f7d4f;
--warn:#985e0a;
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
--warn:#985e0a; /* amber, apart from the needle's orange; lighter failed AA as text */
--bad:#b3263a; /* pushed towards crimson, apart from the needle */
--shadow:0 8px 28px rgba(47,106,160,.14);
}}
*{box-sizing:border-box}
html,body{height:100%}
@@ -55,9 +57,9 @@ form{
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
border-radius:14px;padding:22px;box-shadow:var(--shadow);
}
/* The one place the 2004 icon is shown at the size it was drawn for. */
/* The logo is a square app icon, so it gets an app icon's corners. */
.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
.brand img{width:96px;height:auto}
.brand img{width:72px;height:72px;border-radius:16px}
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
input{
@@ -74,7 +76,7 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
</style>
<form id="f">
<div class="brand"><img src="/icon.png" alt=""><h1>iPodderX</h1></div>
<div class="brand"><picture><source media="(prefers-color-scheme:light)" srcset="/logo.svg"><img src="/logo-dark.svg" alt=""></picture><h1>iPX</h1></div>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="username" autofocus required>
<label for="pw">Password</label>

19
web/logo-dark.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- logo.svg's dark appearance: the same layers in the same places, only recoloured, as the
guidelines ask, so the icon reads as itself in either. -->
<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="scale" fill="#8fc2ea">
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
</g>
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff6a1a"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1003 B

22
web/logo.svg Normal file
View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- The iPodderX radio's screen, reduced to its tuning scale and needle, laid out as Apple's
app icon guidelines ask: square and opaque (the system cuts the corners, and paints
transparency black), one background and flat foreground layers with hard edges, no
highlights or shadows of its own (the system adds them), nothing thin enough to vanish
at 32px. Each <g> is a layer, for splitting out into Icon Composer. -->
<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="scale" fill="#2f6aa0">
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
</g>
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff5500"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -34,11 +34,11 @@ async function drawServer(){
</div>
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
<div class="field"><label>Max new downloads per scan, per feed</label>
<div class="field"><label>Newest episodes to download, per feed</label>
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
<span class="hint">Applies to any feed that does not set its own — including every feed
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
catalogue the first time a feed is scanned.</span></div>
inside an OPML subscription. Older episodes stay listed to download by hand. <b>0 means
every episode</b>, the whole back catalogue.</span></div>
<div class="field"><label>Download these media types automatically</label>
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
@@ -49,6 +49,10 @@ async function drawServer(){
items are never touched.</span></div>
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
<div class="field"><label>Artwork kept (MB, 0 = none)</label>
<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>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>`;
@@ -59,7 +63,8 @@ async function drawServer(){
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})});
max_age_days: Number($('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
toast('Settings saved');
}catch(e){ toast(e.message, true); }
};

View File

@@ -1,7 +1,8 @@
/* ---------------- modals ---------------- */
function openModal(html: string, wide?: boolean){
/// `cls` names a dialog with a frame of its own. Set whole each time, so it never outlives its dialog.
function openModal(html: string, wide?: boolean, cls?: string){
$('#modalCard').innerHTML=html;
$('#modalCard').classList.toggle('wide',!!wide);
$('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:'');
$('#modal').classList.add('on');
}
function closeModal(){
@@ -34,97 +35,238 @@ $('#addFeed').onclick=()=>{
};
};
// What everyone here reads, you included, as a place to start. The rows carry an id, never a
// URL, so a key in someone's feed address never reaches this page.
// The rows carry an id, never a URL, so a key in someone's feed address never reaches this page.
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
async function listFeeds(url,box){
let rows=[];
try{ rows=await api(url)||[]; }catch{}
box.innerHTML=rows.length?'':NONE_LISTED;
for(const p of rows) box.appendChild(listedFeed(p,'childrow'));
return rows.length;
}
/// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The
/// parts are the same either way; the class lays them out.
function listedFeed(p,cls){
/// 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){
const el=document.createElement('div');
el.className=cls;
el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
el.className=cls+(p.subscribed?' mine':'');
el.tabIndex=0; el.dataset.id=p.id;
// As the feed list's rows: Enter or Space opens it, and stops there, or Space would also
// play or pause.
el.onkeydown=ev=>{
if((ev.key==='Enter'||ev.key===' ')&&ev.target===el){ ev.preventDefault(); ev.stopPropagation(); el.click(); }
};
// A chart says what a show is about, as Apple's does; the grid says who here reads it, when
// anyone does. "0 subscribers" under most of the grid said nothing.
const meta=rank?(p.subcategory||p.category):p.subscribers?plural(p.subscribers,'subscriber'):'';
el.innerHTML=(rank?`<span class="no">${rank}</span>`:'')+artHTML(p.image,NAME(p))+
`<div class="txt"><b>${esc(NAME(p))}</b>${meta?`<small class="meta">${esc(meta)}</small>`:''}</div>`+
// 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.subbed}</button>`);
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
$('[data-a="sub"]',el).onclick=async()=>{
try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
await loadFeeds(true); selectFeed(p.id);
}catch(e){ toast(e.message,true); }
};
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
if(p.subscribed){ el.onclick=()=>selectFeed(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); };
return el;
}
// Directory's filters. Kept out here because a finished scan redraws the pane, which would
// otherwise clear them.
let dirKind='All', dirCat=null;
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
/// category, as chips). Both filter in place, without asking the server again.
async function renderDirectory(url,box){
let rows=[];
try{ rows=await api(url)||[]; }catch{}
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; }
const bar=$('#dirbar');
// Only a filter when the server has both kinds.
const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs);
const btn=(k,v,on)=>`<button type="button" data-${k}="${esc(v)}" class="${on?'on':''}" aria-pressed="${on}">${esc(v)}</button>`;
const draw=()=>{
if(!both) dirKind='All';
const ofKind=rows.filter(KINDS[dirKind]);
// No empty chips: only the categories among the feeds the kind lets through.
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
if(!cats.includes(dirCat)) dirCat=null;
bar.innerHTML=
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
// the button just pressed.
for(const b of $$('button',bar)) b.onclick=()=>{
const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k];
if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v;
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
};
box.innerHTML='';
for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile'));
};
draw();
return rows.length;
async function subscribeListed(p){
try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
toast(`Subscribed to ${NAME(p)}`);
await loadFeeds(true); selectFeed(p.id);
}catch(e){ toast(e.message,true); }
}
/// Directory and Popular open in the main pane, as the original's Directory did.
// 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=[];
// The feed whose page is open over all that, by id; going back leaves the rest as it was.
let dirShow=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);
/// The grid's orders. It opens A to Z, as the server sends it.
const DIR_SORTS={
subscribers:['Most subscribers',bySubs],
az:['Name, A to Z',byName],
za:['Name, Z to A',(a,b)=>byName(b,a)],
};
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
const KIND_WORD={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){
// Each visit starts at the front, as going back to a tab does.
dirCat=null; dirSub=null; dirShow=null;
try{ dirRows=await api(url)||[]; }catch{ dirRows=[]; }
drawDirectory(box);
return dirRows.length;
}
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
function drawDirectory(box=$('#directory'), focus?: string){
if(!box) return;
if(!dirRows.length){ box.innerHTML=NONE_LISTED; return; }
// Only a choice when the server has both kinds.
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);
// 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>`;
// back: where its button goes, '' for the front; none on the front itself.
const head=(title,sub,back?)=>`<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 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;
const show=dirShow&&dirRows.find(p=>p.id===dirShow);
if(show){
const n=show.subscribers;
html=`<div class="dhead"><button type="button" class="dback" data-back>${ICON.left}${esc(q?'Results':dirCat?catName(dirCat):'Directory')}</button></div>
<div class="show">${artHTML(show.image,NAME(show))}<div class="meta"><h2>${esc(NAME(show))}</h2>${
// Its category's page is one click away from here.
show.category?`<button type="button" class="dlink" data-go="${esc(show.category)}">${esc(show.subcategory||show.category)}</button>`:''}
${n?`<p class="sub">${n} ${n===1?'person here subscribes':'people here subscribe'}</p>`:''}
<p class="sub dfail" id="dfail" hidden></p>
<div class="acts"><button class="btn ico primary" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button></div></div></div>
<div class="about" id="dabout" hidden><p></p><button type="button" class="dlink" aria-expanded="false" hidden>More</button></div>`
+sec(show.podcast?'Latest episodes':'Latest posts','<div class="latest" id="dshow"><p class="hint">Loading…</p></div>');
}else if(q){
// Names only: a category is a tap away, and "news" matching every News feed buried the
// one called that.
tiles=rows.filter(p=>NAME(p).toLocaleLowerCase().includes(q.toLocaleLowerCase()));
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); }
// 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)} on this server. 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>`,
`<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;
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);
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.
+sec(chart.length?`${word[0].toUpperCase()+word.slice(1)}s`:'',tools(subs.length?`<div class="pills" role="group" aria-label="Subcategory">${
pill('','All',inCat.length)+subs.map(s=>pill(s,s,inCat.filter(p=>p.subcategory===s).length)).join('')}</div>`:'')+grid);
}
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))); }
const t=$('#dtiles',box);
if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
box.onclick=ev=>{
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.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=''; }
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);
if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); };
if(focus) $(focus,box)?.focus();
}
/// 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){
const list=$('#dshow',box), about=$('#dabout',box);
let got;
try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); }
catch(e){ list.innerHTML=`<p class="hint">${esc(e.message)}</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.
const said=plainText(got.description);
if(said&&said!==NAME(p)){
const text=$('p',about), more=$('button',about);
text.textContent=said; about.hidden=false;
// Only when there is more than the three lines shown.
more.hidden=text.scrollHeight<=text.clientHeight+1;
more.onclick=()=>{
const open=about.classList.toggle('open');
more.textContent=open?'Less':'More'; more.setAttribute('aria-expanded',String(open));
};
}
// A broken feed says so, or it reads as a quiet one (#142).
if(got.failing){ const f=$('#dfail',box); f.textContent=got.failing; f.hidden=false; }
const items=got.items;
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>`
+(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.${p.subscribers||got.failing?''
:' A feed nobody here subscribes to is checked once a day.'}</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){
// 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();
return `<button type="button" class="cat" data-go="${esc(c)}"><b>${esc(name)}</b><small>${count}</small>`+
`<span class="fan" aria-hidden="true">${art.map(p=>artHTML(p.image,NAME(p))).join('')}</span></button>`;
}
/// The Directory and Currently Listening open in the main pane, as the original's Directory did.
async function renderListed(v){
const box=$('#content');
box.classList.add('plain');
$('#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>';
$('#count').textContent=v.title;
const n=await renderDirectory(v.url,$('#directory'));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
return;
}
$('#epSearch').placeholder='Search items…';
const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory'];
box.innerHTML=`
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}</div></div>
<div class="meta"><h2>${v.title}</h2><div class="sub">${v.blurb}</div></div>
</div>
${grid?'<div class="dirbar" id="dirbar"></div>':''}
<div class="${grid?'tiles':'childlist'}" id="${listening?'listening':'popular'}"><p class="hint">Loading…</p></div>`;
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
$('#count').textContent=v.title;
const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`;
// 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')}`;
}
/// Currently Listening: episodes you started and have not finished, across every feed you
@@ -133,14 +275,15 @@ async function renderListed(v){
async function renderListening(url,box){
let rows=[];
try{ rows=(await api(url)).entries||[]; }catch{}
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
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');
el.className='childrow';
el.entry=e;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+
`<div class="txt"><b>${EQ}<span>${esc(entryName(e).text)}</span></b>`+
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
`<button class="iconbtn" data-a="play"></button>`+
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
@@ -193,7 +336,12 @@ $('#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);
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; loadEntries(); },250); };
// 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;
if(S.feed===':directory'){ dirShow=null; drawDirectory(); }
else if(S.feed===':listening') renderListed(VIEWS[':listening']);
else loadEntries(); },250); };
// Crossing the phone breakpoint moves the files between their pane and the text.
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
@@ -215,7 +363,15 @@ 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){
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.';
}
async function prefsModal(){
// Fresh, so the time saved counts what was heard since the page loaded.
api('/api/me').then(u=>{ S.me=u; const h=$('#sdeadsaved'); if(h) h.textContent=deadSavedText(u); }).catch(()=>{});
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>
@@ -226,6 +382,8 @@ async function prefsModal(){
<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>
<div class="field"><label>DeadAir</label>
<span class="hint" id="sdeadsaved">${deadSavedText(S.me)}</span></div>
<div class="field"><label>Subscriptions</label>
<div class="inline">
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
@@ -234,77 +392,245 @@ async function prefsModal(){
</div>
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
subscribes you to every feed in one.</span></div>
<div class="field"><label for="sblock">Hide items mentioning</label>
<input type="text" id="sblock" value="${esc((S.me?.blocked||[]).join(', '))}">
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
one in its title or text is hidden from you and not downloaded for you. Each feed's
settings can add more.</span></div>
<div class="field boxed"><label for="tokname">API tokens</label>
<div id="tokens"></div>
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
<button class="btn" id="tokadd" title="Make an API token" aria-label="Make an API token">${ICON.plus} Make</button></div>
<span class="hint">A token lets a script or an agent use iPX as you, adding and removing
your feeds and reading your items: it sends <code>Authorization: Bearer</code> and the token.
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>
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
${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;
drawTokens();
$('#tokadd').onclick=async()=>{
const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; }
try{
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
$('#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); }
};
$('#sblock').onchange=async e=>{
const blocked=splitWords(e.target.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); }
};
}
/// 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(()=>[]);
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); }
};
}
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
/// Words as chips, for the words that pick or hide items: each word or phrase ends with Enter or a
/// comma, and comes off with its x or Backspace. One comma-separated box ran one phrase into the
/// next (#167).
const chipHTML=(w: string)=>`<span class="chip" data-w="${esc(w)}">${esc(w)}<button type="button" class="chipx"
title="Remove ${esc(w)}" aria-label="Remove ${esc(w)}">${ICON.close}</button></span>`;
/// What goes inside a chip box. The box itself is written out where it is used, id and all: the page
/// check finds ids by reading them, and one put together here would read as missing.
const CHIP_HINT='Add a word or phrase';
function chipsHTML(words: string[], input: string){
return `${words.map(chipHTML).join('')}<input type="text" class="chipin" id="${input}"
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 wireChips(box){
const input=$('.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());
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=>{
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(); }
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){
const isGroup = S.feeds.some(c=>c.group===f.id);
openModal(`<h3>${esc(f.title||f.id)}</h3>
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
settings apply to it and are inherited by every feed inside it.</p>`:''}
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from
${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in
config.toml, and it stops following the subscription's settings.</p>`:''}
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed.
Everyone else keeps their own.</p>
<div class="field"><label>Keywords</label>
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
<span class="hint">Comma separated. Empty takes everything.</span></div>
<div class="field"><label>Max new downloads per scan</label>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
the next scan.</span></div>
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
<div class="field"><label>Feed URL</label>
<div class="inline">
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
</div>
<span class="hint">${S.me&&S.me.admin
? `Shared with everyone reading this feed. Editing it keeps every item and download —
handy when an auth token in the URL is rotated. The feed is re-checked from scratch
on the next scan.`
: `The same for everyone reading this feed, so only an admin can change it.`}</span></div>
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label>
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
<span class="hint">Where the files land. There is one copy however many people
subscribe, so this is the same for everyone.</span></div>`:''}
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
<datalist id="scats"></datalist>
<span class="hint">${f.feed_category
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.`
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</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="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
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;
const admin=!!(S.me&&S.me.admin);
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);
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>`;
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'}>
<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')}
<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])=>
`<label><input type="radio" name="swin" value="${v}"${mode===v?' checked':''}>${t}</label>`).join('')}</div>
<div class="epwin" id="sepwin" aria-hidden="true"></div>
<p class="wcap" id="swcap"></p>
<div class="ffield"><label class="flabel" for="skwin">Only items that mention</label>
<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 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>
${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>
<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');
// 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; };
// 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).
const each=isGroup?' of each show':'';
const older=' Older ones stay listed, to download by hand.';
const caption=()=>mode==='newest'
? `Downloads the newest <input type="number" id="smax" min="1" value="${newest}"
aria-label="How many of the newest to download">${each} on its own.${older}`
: mode==='all'
? `Downloads every item${each}: the whole back catalogue${!isGroup&&f.entries?`, ${f.entries} so far`:''}, and each new one.`
: globalMax
? `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=[];
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.
let left=limit();
// Everything: the older ones past the strip are lit too, in words.
$('#sepwin')?.classList.toggle('all',left===Infinity);
for(const b of bars){
const on=!b.classList.contains('nofile')&&left>0;
if(on) left--;
b.classList.toggle('on',on);
}
if(!words) return;
$('#swcap').innerHTML=caption();
const n=$('#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); };
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 strip=$('#sepwin'); if(!items.length||!strip) return;
const longest=Math.max(1,...items.map(e=>e.duration||0));
strip.innerHTML=items.map((e,i)=>{
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>`;
}).join('')+(f.entries>items.length?`<small>+${f.entries-items.length} older</small>`:'');
bars=$$('i',strip);
draw(false);
// Drag along it to set how many: as far as the finger, the newest that far down.
const pick=(x: number)=>{
let n=0;
for(const b of bars){
if(!b.classList.contains('nofile')) n++;
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); }
};
strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
}).catch(()=>{});
wireChips($('#skw'));
if($('#sblock')) wireChips($('#sblock'));
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
// Offer the categories the Directory already shows, so a blog about games joins Games rather
// than starting a second chip beside it.
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
.map(p=>p.category).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
// 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(); }
$('#ssave').onclick=async()=>{
const max=$('#smax').value;
try{
const patch: Record<string, unknown>={
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
max_new_per_check:max===''?null:Number(max),
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
// The shared half is an admin's to change, and the API refuses it from anyone else.
if(S.me&&S.me.admin){
patch.url=$('#surl').value.trim();
patch.folder=$('#sfolder').value.trim()||null;
patch.category=$('#scat').value.trim()||null;
}
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)};
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
// 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();
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
closeModal(); toast('Saved — applies on the next scan');
closeModal(); toast('Saved. It applies from the next check.');
await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); }
};

View File

@@ -1,9 +1,20 @@
/* ---------------- live events ---------------- */
let sse;
/// 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(()=>{
loadFeeds(true).catch(()=>{});
if(S.feed) loadEntries().catch(()=>{});
},300); }; })();
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) catchUp(); });
let dropped=false;
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 refreshFeeds=soon(()=>loadFeeds(true));
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
// 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);
@@ -22,7 +33,14 @@ function connect(){
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
// everyone's feeds used to announce themselves to everyone.
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
refreshEntries(); refreshFeeds();
refreshEntries();
}
// A copy finished: the episode's "Cutting dead air…" becomes how much shorter it is.
else if(ev.ev==='deadair_done'){
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(()=>{});
}
else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
@@ -35,14 +53,21 @@ function connect(){
else if(ev.ev==='feed_done'){
setScanning(ev.feed,false);
if(!mine(ev.feed)) return;
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
if(ev.feed===S.feed||S.feed===':all') 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); if(mine(ev.feed)) refreshFeeds(); }
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
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);
// 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(); }
};
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
}
connect();
loadFeeds();

View File

@@ -1,5 +1,6 @@
/* ---------------- feed page ---------------- */
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];
@@ -27,10 +28,11 @@ function renderFeed(){
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
</div>
<div class="acts">
<button class="btn ico primary" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
<button class="btn ico" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
<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>
<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>
@@ -43,7 +45,7 @@ function renderFeed(){
subscribe to, newest first · ${unreadAll} unread</div>
</div>
<div class="acts">
<button class="btn ico primary" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
<button class="btn ico" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
</div>
</div>`) + `
@@ -117,9 +119,10 @@ function renderGroup(f,kids){
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
</div>
<div class="acts">
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
<button class="btn ico" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</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>
<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>
@@ -166,6 +169,7 @@ async function feedAction(a,f){
}catch(e){ toast(e.message,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.

View File

@@ -1,7 +1,18 @@
/* ---------------- feeds ---------------- */
/// 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){
const i=S.feeds.findIndex(f=>f.id===row.id);
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
}
async function loadFeeds(keepSel?: boolean){
S.feeds = await api('/api/feeds');
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
// 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(()=>{});
renderFeeds();
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
@@ -17,10 +28,8 @@ const unreadFirst=(a,b)=>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={
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'},
// 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},
@@ -39,6 +48,11 @@ function paintScanning(){
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)));
}
function renderFeeds(){
@@ -52,6 +66,11 @@ function renderFeeds(){
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
if(row) (back[1]&&$('.chev',row)||row).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); }
list.innerHTML='';
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
const places=document.createElement('div');
@@ -100,24 +119,32 @@ function renderFeeds(){
// went wrong (failBannerHTML); the list only has to make it findable.
const bad=c=>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');
// A feed that has failed for a day goes grey, as if switched off: a change in lightness
// shows in every theme, where the red mark alone was easy to miss on a dark one.
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
(err?' err':'')+(f.failing?' failing':'')+
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
el.tabIndex=0; el.dataset.id=f.id;
const open = !!(kids && (expanded.has(f.id) || q));
el.innerHTML =
// The error mark hangs in the margin where a folder's triangle does. A folder already has
// its triangle there, so that turns red instead, and the feed inside shows the mark.
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`
:err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
`<div class="txt"><b>${f.pinned?`<span class="fpin" title="Pinned">${ICON.pinOn}</span>`:''}${esc(f.title||f.id)}</b><small>`+
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`:'')+
// The mark sits on the artwork, the thing the eye scans the list by, and the line under
// the name says what is wrong in place of the counts.
`<span class="fart">${mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id)}`+
(err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
(f.pinned?`<span class="fpin" role="img" title="Pinned" aria-label="Pinned">${ICON.pinOn}</span>`:'')+`</span>`+
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
(nbad?`${plural(nbad,'feed')} not updating`
:err?esc(f.failing?.reason||'The last check failed')
:`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`)+
`</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>`;
el.onclick=()=>{ selectFeed(f.id); nav(false); };
if(kids) $('.chev',el).onclick=ev=>{ 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();

View File

@@ -5,7 +5,12 @@
// the document because the panes are rebuilt every time a feed renders.
const PULL = 70; // px down, from the list's top, that counts as a pull
const SWIPE = 60; // px across that counts as a swipe
// A quarter of the reader's width across, and never less than 100px: at a flat 60px a thumb
// scrolling slightly on the diagonal jumped to the next item by accident (issue #49).
const swipeMin = () => Math.max(100, ($('#detail')?.clientWidth || 0) / 4);
const GAP = 16; // px of page between two items side by side, so they read as two sheets
const narrow = () => !!window.matchMedia?.('(max-width:820px)')?.matches;
const still = () => !!window.matchMedia?.('(prefers-reduced-motion:reduce)')?.matches;
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
@@ -31,13 +36,25 @@ function pullShow(dy: number){
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
}
/// A check started by a pull, while its spinner is up. Letting go showed nothing before (issue
/// #68), the sidebar's spinner is hidden on a phone, and people pulled again and again.
let refreshing = false;
function refreshFeed(){
const f = S.feeds.find(x => x.id === S.feed);
if(!f && S.feed !== ':all') return;
if(refreshing) return;
refreshing = true;
// Outside #list, which a feed's render rebuilds and would take the spinner with it.
const spin = document.createElement('div');
spin.id = 'pullspin'; spin.setAttribute('role', 'status'); spin.textContent = 'Checking for new items';
document.body.append(spin);
// New items arrive by the event stream when the scan finishes, as they do for a button press.
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
const asked = api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, 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; });
}
document.addEventListener('touchstart', ev => {
@@ -46,9 +63,15 @@ document.addEventListener('touchstart', ev => {
const t = ev.touches[0], target = ev.target as Element;
const detail = target.closest?.('#detail'), list = target.closest?.('#list');
// Reading means an item is open; the reader is its own screen on a phone, a pane otherwise.
if(detail && S.sel && !ownsSwipe(target)) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
if(detail && S.sel && !ownsSwipe(target)){
touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
// Safari takes a swipe from the screen's left edge as Back, which leaves the page halfway
// through going back to the list. Kept narrower than the reader's padding, so a tap on the
// 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};
}, {passive: true});
}, {passive: false});
document.addEventListener('touchmove', ev => {
if(!touch) return;
@@ -60,11 +83,74 @@ document.addEventListener('touchmove', ev => {
pullShow(touch.dy);
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well
}else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){
touch = null; // scrolling the text, not swiping; the first few px
slide(0); touch = null; // scrolling the text, not swiping; the first few px
// decide nothing, being mostly jitter
}else if(Math.abs(touch.dx) > 10){
// The reader follows the finger, so it is plain before letting go whether this will move on;
// with nothing that way it only gives a little.
const i = S.entries.findIndex(x => x.guid === S.sel);
const end = touch.dx < 0 && i >= S.entries.length - 1;
slide(end ? touch.dx / 3 : touch.dx, 0);
}
}, {passive: false});
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on
/// to, the accent past the last one, or, going back to the list, a dimmer over the list. The
/// reader sits over the list there, and moving it alone showed the list through the gap it left
/// (issue #52). Kept outside #content, which every feed render rebuilds, and inert, since its
/// copy of the reader's markup repeats the reader's ids.
function beside(dx: number){
const i = S.entries.findIndex(x => x.guid === S.sel), e = S.entries[i + (dx < 0 ? 1 : -1)];
const kind = dx < 0 ? (e ? 'next' : 'end') : (e ? 'prev' : 'dim');
let p = $('#dpeek');
if(!p){
p = document.createElement('div'); p.id = 'dpeek'; p.inert = true; p.setAttribute('aria-hidden', 'true');
document.body.append(p);
}
if(p.dataset.key !== kind + (e?.guid || '')){
p.dataset.key = kind + (e?.guid || ''); p.className = kind;
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : '';
}
return p;
}
/// Moves the reader across by dx over ms, or at once (when following a finger), and what lies
/// beside it with it.
function slide(dx: number, ms = 160){
const d = $('#detail'); if(!d) return;
dx = Math.round(dx); // whole pixels, or the seam between the reader and its neighbour blurs
const w = d.clientWidth || 1, tr = ms ? `transform ${ms}ms ease-out, opacity ${ms}ms ease-out` : 'none';
d.style.transition = tr;
d.style.transform = dx ? `translateX(${dx}px)` : '';
// A wider screen has the reader as a pane beside the list, with nothing under it to show.
if(!narrow()){ d.style.opacity = dx ? String(Math.max(.4, 1 - Math.abs(dx) / w)) : ''; return; }
const p = dx ? beside(dx) : $('#dpeek');
d.classList.toggle('popping', !!dx && p?.className === 'dim');
if(!p) return;
p.style.transition = tr;
if(p.className === 'dim') p.style.opacity = String(.35 * (1 - Math.abs(dx) / w));
else p.style.transform = `translateX(${dx + (p.className === 'prev' ? -w - GAP : w)}px)`;
// Gone once the reader is back, unless another drag has already begun.
if(!dx){ if(ms) setTimeout(() => { if(!touch) $('#dpeek')?.remove(); }, ms); else p.remove(); }
}
/// The reader carries on the way the finger went, from wherever it was let go, and the item
/// beside it becomes the reader in its place. Timers, not transitionend, because with reduced
/// motion the stylesheet turns transitions off and transitionend never comes.
function slideOn(dx: number, go: () => void){
const d = $('#detail'), w = d?.clientWidth || 0, dir = Math.sign(dx);
if(!narrow()){
// A pane beside the list: off one side, and the next item in from the other.
slide(dir * w);
setTimeout(() => { go(); slide(-dir * w, 0); requestAnimationFrame(() => requestAnimationFrame(() => slide(0))); }, 160);
return;
}
const to = dir * (w + (beside(dx).className === 'dim' ? 0 : GAP));
const ms = still() ? 0 : Math.round(Math.max(120, 250 * (1 - Math.abs(dx) / (w || 1))));
slide(to, ms);
setTimeout(() => { go(); slide(0, 0); }, ms);
}
document.addEventListener('touchend', () => {
const g = touch; touch = null;
if(!g) return;
@@ -74,9 +160,11 @@ document.addEventListener('touchend', () => {
return;
}
// Across, mostly sideways, and not a slow drag while selecting text.
if(Math.abs(g.dx) < SWIPE || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800) return;
const i = S.entries.findIndex(x => x.guid === S.sel);
if(g.dx < 0){ if(i < S.entries.length - 1) stepEntry(1); return; }
if(i > 0) stepEntry(-1); else showDetail(null);
if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800
|| (g.dx < 0 && i >= S.entries.length - 1)) return slide(0);
if(g.dx < 0) return slideOn(g.dx, () => stepEntry(1));
if(i > 0) return slideOn(g.dx, () => stepEntry(-1));
slideOn(g.dx, () => showDetail(null));
});
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); touch = null; });
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; });

View File

@@ -1,7 +1,7 @@
/* ---------------- items ---------------- */
let loadSeq=0;
async function loadEntries(append?: boolean){
// Directory and Popular list feeds, not items.
// 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});
if(S.q) p.set('q',S.q);
@@ -20,10 +20,12 @@ async function loadEntries(append?: boolean){
// 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.
if(S.filter==='unread') entries=entries.filter(e=>!e.read||e.guid===S.sel);
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){
const open=S.entries.find(e=>e.guid===S.sel);
if(open) entries=[open,...entries];
// 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);
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));
entries=[...gone,...entries];
}
S.entries = entries;
renderEntries();
@@ -37,7 +39,8 @@ function renderEntries(){
const pane=$('#list'), top=pane?pane.scrollTop:0;
box.innerHTML='';
if(!S.entries.length){
box.innerHTML=`<p class="empty">${S.q?'Nothing matches that search.':'Nothing here yet — try Scan now.'}</p>`;
// 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>';
return;
}
for(const e of S.entries) box.appendChild(epEl(e));
@@ -63,17 +66,20 @@ function epEl(e){
el.dataset.guid=e.guid;
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);
el.innerHTML=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${
<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">${esc(e.title||'(untitled)')}</span>
<span class="t${name.derived?' notitle':''}">${esc(name.text)}</span>
<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_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>
</div>
@@ -134,6 +140,28 @@ 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; };
/// 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
/// name, then its feed and date: "(untitled)" fifty times down a list said nothing about any of
/// them. `derived` marks a name that is not a title, which the list sets as text, not heading.
/// 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
? (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}{
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,'');
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};
}
/// 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){
@@ -151,7 +179,7 @@ function setRead(e,read){
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(()=>{ w.done=performance.now(); loadFeeds(true); });
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); });
}
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
@@ -161,13 +189,24 @@ function markRead(e){
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
}
function selectEntry(e){
/// 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){
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
turned.clear();
if(!gone.length) return;
S.entries=S.entries.filter(x=>!gone.includes(x)); S.total-=gone.length;
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
}
function selectEntry(e,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.
const prev=S.filter==='unread' && S.sel!==e.guid && S.entries.find(x=>x.guid===S.sel);
if(prev&&prev.read){
S.entries=S.entries.filter(x=>x!==prev); S.total--;
$(`#eps .ep[data-guid="${CSS.escape(prev.guid)}"]`)?.remove();
if(S.filter==='unread' && S.sel && S.sel!==e.guid){
turned.add(S.sel);
if(!turning) dropTurned(e.guid);
}
S.sel=e.guid;
markRead(e);
@@ -207,8 +246,12 @@ 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;
// The same icons as the item's own buttons beside its title, so the two never disagree.
$('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check;
// 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;
if(e){
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
@@ -224,12 +267,26 @@ function showDetail(e){
document.body.classList.toggle('reading',!!e);
syncTools(e);
if(!e){
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
dropTurned(S.sel);
box.innerHTML='';
if(files) files.innerHTML='<p class="empty">No files</p>';
return;
}
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
// where the files sit over the text, a box saying "No files" only pushed the text down.
const encs=e.enclosures.map(encBox).join('');
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);
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));
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){
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
@@ -237,28 +294,26 @@ function showDetail(e){
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
const f=S.feeds.find(x=>x.id===e.feed_id);
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
box.innerHTML=`
// description was sanitized server-side with ammonia before it ever reached here
return `
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
${/* A post without a title starts with its text: its own first words as a heading above
themselves would read as a mistake. */ e.title&&e.title.trim() ? `<h3 class="dt">${esc(e.title)}</h3>` : ''}
<div class="dmeta">
${/* Joined, so a missing date or number leaves no stray dot behind. */
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
[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>')}
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</button>
<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'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
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"
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''}
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>`;
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
// description was sanitized server-side with ammonia before it ever reached here
$('#dback').onclick=()=>showDetail(null);
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));
syncPlayButtons();
}
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
@@ -272,7 +327,11 @@ function encBox(x){
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
: 'Delete file';
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
// 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>`;
// 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)){
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
return `<div class="encbox">
@@ -280,6 +339,7 @@ function encBox(x){
<span class="meta" style="flex:1">${size}</span>
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
${saveBtn}
${shareBtn}
${delBtn}
</div>`;
}
@@ -291,18 +351,20 @@ function encBox(x){
<span class="meta" style="flex:1">${size}</span>
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
${saveBtn}
${shareBtn}
${delBtn}
</div>`;
}
// 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';
const viewable = !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
${shareBtn}
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
</div>`;
}
@@ -314,12 +376,13 @@ async function epAction(a: string, e, el, encId?: number){
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==='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'){
if(!enc) return;
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
toast('Queued: '+(e.title||'item'));
toast('Queued: '+entryName(e).text);
}
if(a==='del'){
const f=S.feeds.find(x=>x.id===e.feed_id);

177
web/src/native.ts Normal file
View File

@@ -0,0 +1,177 @@
/* ---------------- native shell bridge ---------------- */
// Inside the iOS or Android app this page is a WebView, and the audio it plays has to come from
// the host's own player instead of this element: CarPlay and Android Auto are template surfaces
// that cannot render a WebView at all, and the only audio they will control is the host's.
//
// So the host's player takes over, and the element keeps its face. Everything in player.ts speaks
// to `audio` through a small surface -- play, pause, src, currentTime, duration, paused,
// readyState, volume, playbackRate, and the events it fires -- so replacing that surface on the
// element leaves the player bar, the row buttons, the EQ bars and the keyboard shortcuts working
// exactly as they do in a browser, with nothing in player.ts changed.
//
// In a browser none of this installs and the page is untouched.
/// How the host is reached. iOS puts a handler on `webkit.messageHandlers`; Android's
/// `addJavascriptInterface` gives a plain object with a `postMessage(string)`. Null in a browser,
/// which is what switches the whole file off.
const ipxHost: ((m: any) => void) | null = (() => {
const w = window as any;
const ios = w.webkit?.messageHandlers?.ipx;
if (ios) return (m: any) => ios.postMessage(m);
const android = w.ipxAndroid;
if (android?.postMessage) return (m: any) => android.postMessage(JSON.stringify(m));
return null;
})();
if (ipxHost) installNativePlayback();
function installNativePlayback(){
const post = ipxHost!;
const M = HTMLMediaElement.prototype;
const own = (k: string) => Object.getOwnPropertyDescriptor(M, k)!;
const realPlay = M.play, realPause = M.pause, realLoad = M.load;
// Bound before anything is redefined, because the src setter below has to drop the element's
// file without that counting as closing the player: removeAttribute is overridden further down
// to mean exactly that, and going through it there switched the shim straight back off.
const realRemoveAttribute = audio.removeAttribute.bind(audio);
const src = own('src'), currentTime = own('currentTime'), duration = own('duration');
const paused = own('paused'), readyState = own('readyState');
const volume = own('volume'), playbackRate = own('playbackRate');
// What the host last told us. `on` is the whole switch: false means this element is playing for
// itself, which is still the case for video -- the host plays audio, and a native video layer
// under a WebView buys nothing when CarPlay is audio-only either way.
const N = {on:false, cur:0, dur:NaN, paused:true, ready:0};
const fire = (name: string) => audio.dispatchEvent(new Event(name));
const define = (k: string, d: PropertyDescriptor) =>
Object.defineProperty(audio, k, {configurable:true, ...d});
define('play', {value(){
if(!N.on) return realPlay.call(audio);
post({t:'play'});
// player.ts does audio.play().catch(...) to toast a failure. A failure here arrives as a
// message from the host instead, so there is nothing to reject.
return Promise.resolve();
}});
define('pause', {value(){
if(!N.on) return realPause.call(audio);
post({t:'pause'});
}});
define('src', {
get(){ return N.on ? '' : src.get!.call(audio); },
set(v){
// has-video is set immediately before the src in play(), so it is already right here.
if(document.body.classList.contains('has-video')){
stop();
src.set!.call(audio, v);
return;
}
N.on = true; N.cur = 0; N.dur = NaN; N.paused = true; N.ready = 0;
// Let go of whatever the element was holding, or a video just closed keeps its buffer and
// its audio track. removeAttribute alone does not: it takes a load() to act on it.
realPause.call(audio);
realRemoveAttribute('src');
realLoad.call(audio);
const e = player.entry, f = player.feed;
post({t:'load', url:v, enc:player.enc, feedId:f, guid:player.guid,
title:e ? entryName(e).text : '', feedTitle:feedName(f),
artwork:(e && e.image) || feedArt(f) || null,
// Where the host starts is not this: the seek to where you left off is player.ts's, on
// loadedmetadata, so one piece of code decides it. This is for the host's now-playing
// display before the file has loaded.
position:(e && e.position) || 0, duration:(e && e.duration) || null,
rate:audio.playbackRate, volume:audio.volume});
},
});
define('currentTime', {
get(){ return N.on ? N.cur : currentTime.get!.call(audio); },
set(v){
if(!N.on){ currentTime.set!.call(audio, v); return; }
N.cur = v;
post({t:'seek', to:v});
// The clock and the scrubber move now rather than at the host's next tick, which is what
// makes the 15 and 30 second keys feel like they did.
fire('timeupdate');
},
});
define('duration', {get(){ return N.on ? N.dur : duration.get!.call(audio); }});
define('paused', {get(){ return N.on ? N.paused : paused.get!.call(audio); }});
define('readyState', {get(){ return N.on ? N.ready : readyState.get!.call(audio); }});
define('volume', {
get(){ return volume.get!.call(audio); },
set(v){ volume.set!.call(audio, v); if(N.on) post({t:'volume', v}); },
});
define('playbackRate', {
get(){ return playbackRate.get!.call(audio); },
set(v){ playbackRate.set!.call(audio, v); if(N.on) post({t:'rate', v}); },
});
// Closing the player is `audio.removeAttribute('src')`, which would otherwise leave the host
// playing on with nothing on screen to stop it.
define('removeAttribute', {value(name: string){
if(name === 'src') stop();
return realRemoveAttribute(name);
}});
function stop(){
if(!N.on) return;
N.on = false; N.paused = true; N.cur = 0; N.dur = NaN; N.ready = 0;
post({t:'stop'});
}
// Position belongs to the host. player.ts is emphatic about what a stale write costs -- a player
// left paused in another tab once saved its older place over where you had got to -- and a
// backgrounded WebView is exactly that tab: frozen, holding a time from minutes ago, while the
// host plays on. So the beacon becomes a request for the host to save its own time, and the host
// is also the one saving while nothing here is running at all.
const beacon = navigator.sendBeacon && navigator.sendBeacon.bind(navigator);
navigator.sendBeacon = function(url: string, data?: any){
if(N.on && /\/position$/.test(String(url))){ post({t:'position', url:String(url)}); return true; }
return beacon ? beacon(url, data) : false;
} as any;
// What the host calls back into. On `window` deliberately: the host reaches it by name through
// evaluateJavaScript, and a top-level const would work but not obviously.
(window as any).ipxNative = {
version: 1,
on(m: any){
if(!N.on) return;
switch(m.t){
case 'time':
N.cur = m.cur;
if(m.dur != null) N.dur = m.dur;
fire('timeupdate');
break;
case 'meta':
N.dur = m.dur; N.ready = 1;
fire('loadedmetadata');
break;
case 'state':
if(m.playing === !N.paused) return;
N.paused = !m.playing;
fire(m.playing ? 'play' : 'pause');
break;
case 'ended':
N.paused = true;
fire('pause');
fire('ended');
break;
case 'error':
N.paused = true;
fire('pause');
toast('Playback failed' + (m.message ? ': ' + m.message : ''), true);
break;
}
},
};
// The host waits for this to know the bridge is in and which build it got: an app newer than the
// deployed page would otherwise sit there sending messages nothing answers.
post({t:'ready', version:1, rate:audio.playbackRate, volume:audio.volume});
}

View File

@@ -1,8 +1,42 @@
/* ---------------- player ---------------- */
const audio=$('#audio');
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, moved?: boolean,
const player: {guid: string|null, feed: string|null, entry: any, 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}`;
/// 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
/// on the playlist's MP3 ranges with "stream parsing failed", and so plays nothing.
let hls: any=null;
function dropHls(){ if(hls){ hls.destroy(); hls=null; } }
/// Puts a file in the player: null when it is there at once, else a promise to wait on before
/// playing, while hls.js loads. Safari always takes the first way, where play() has to run within
/// the tap itself or it is refused.
function setSrc(url: string): Promise<void>|null{
dropHls();
const native=navigator.vendor.startsWith('Apple')&&!!audio.canPlayType('application/vnd.apple.mpegurl');
// Safari fetches the playlist itself, with no header to carry the tap's trace: in the address
// instead (#192). Only the playlist, which is never cached.
if(!url.endsWith('.m3u8')){ audio.src=url; return null; }
if(native){ audio.src=`${url}?traceparent=${traceparent()}`; return null; }
const w=window as any;
const ready=w.Hls?Promise.resolve():new Promise<void>((ok,fail)=>{
const s=document.createElement('script');
s.src='__HLS_JS__'; s.onload=()=>ok(); s.onerror=()=>fail(new Error('the DeadAir player did not load'));
document.head.append(s);
});
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);
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
hls.loadSource(url); hls.attachMedia(audio);
});
}
// Marked read when an item has actually been listened to -- at the end, or past 90%.
// NOT on play: doing that made the item vanish from the Unread list the instant
// you pressed play, which looks exactly like it went missing.
@@ -20,33 +54,37 @@ function play(e,enc=e.enclosures.find(isPlayable)){
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
return;
}
// The same file carries on where it was; another of the item's files starts from its top.
const resuming = player.guid===e.guid && player.enc===enc.id;
// The same file carries on where it was; another of the item's files starts from its top. So
// does the other version of the same file, from the place the entry gives in its clock.
const resuming = player.guid===e.guid && player.enc===enc.id && player.src===mediaSrc(enc);
// Every play button is a pause button for what is playing, as the player bar's is.
if(resuming && !audio.paused){ audio.pause(); return; }
let wait: Promise<void>|null=null;
if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false;
document.body.classList.toggle('has-video', kindOf(enc)==='video');
audio.src=`/media/${enc.id}`;
wait=setSrc(player.src=mediaSrc(enc));
audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id));
$('#ptitle').textContent=e.title||'(untitled)';
$('#ptitle').textContent=entryName(e).text;
const f=S.feeds.find(x=>x.id===e.feed_id);
$('#pfeed').textContent=f?(f.title||f.id):'';
$('#player').classList.add('on');
mediaSession(e,f);
player.marked=false;
paintDead();
}
audio.play().catch(err=>toast('Playback failed: '+err.message,true));
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();
renderEntries();
}
function mediaSession(e,f){
if(!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata=new MediaMetadata({
title:e.title||'', artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
artwork:(e.image||(f&&f.image))?[{src:e.image||f.image,sizes:'512x512'}]:[],
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'}]:[],
});
const h={play:()=>audio.play(),pause:()=>audio.pause(),
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
@@ -78,7 +116,8 @@ function savePos(){
// cannot tell a finished episode from a started one. NaN before metadata, Infinity on a stream.
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
navigator.sendBeacon?.(
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`,
// A beacon sends no headers, so the trace goes in the address (#192).
`/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);
@@ -104,13 +143,69 @@ function syncPlayButtons(){
}
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
window.addEventListener('beforeunload',savePos);
/// For the server's log (#190): how long play takes to start, how long it stalls for, and what
/// the browser says when it cannot play a file at all.
let asked=0, waited=0;
const playing=()=>({feed:player.feed,enclosure:player.enc,deadair:!!player.src?.endsWith('.m3u8')});
audio.addEventListener('play',()=>{ asked=performance.now(); });
audio.addEventListener('waiting',()=>{ if(!asked) waited=performance.now(); });
audio.addEventListener('playing',()=>{
const now=performance.now();
if(asked) report('play_start',{ms:Math.round(now-asked),...playing()});
else if(waited && now-waited>=500) report('stall',{ms:Math.round(now-waited),...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)};
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
/// 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.
function paintDead(){
const b=$('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
b.hidden=!f||document.body.classList.contains('has-video');
if(b.hidden) return;
b.setAttribute('aria-pressed',String(!!f.deadair));
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
}
$('#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; }
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'
:'DeadAir is on for this show. Episodes lose their dead air a few minutes after they download.');
renderEntries();
};
/// Carries on at the same place in the other version of what is playing, once DeadAir has been
/// 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);
// 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 enc=e&&e.enclosures.find(x=>x.id===player.enc);
if(!enc||mediaSrc(enc)===player.src) return false;
const was=!audio.paused;
player.entry=e; player.src=mediaSrc(enc); player.saveAt=e.position;
const wait=setSrc(player.src);
audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
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();
// Called here, not left to the pause event: closing a player already paused fires none.
syncListening()};
(function restore(){
@@ -135,13 +230,13 @@ document.addEventListener('keydown',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',p:':popular',l:':listening'};
const GO={a:':all',d:':directory',l:':listening'};
let gAt=0;
function stepEntry(by){
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);
selectEntry(e,true);
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
}
function stepFeed(by){
@@ -176,10 +271,10 @@ function keysModal(){
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c);
const rows=[
['Go to'],
[g('a'),'All Subscriptions'],[g('d'),'Directory'],[g('p'),'Popular'],
[g('a'),'All Subscriptions'],[g('d'),'Directory'],
[g('l'),'Currently Listening'],[g('s'),'Settings'],
[k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'],
[k('/'),'Search items'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'],
[k('/'),'Search items, Currently Listening or the Directory'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'],
['Items'],
[k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'],
[k('Shift')+' '+k('A'),'Mark all read'],

View File

@@ -1,7 +1,8 @@
/* ---------------- theme ---------------- */
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept on
// the account, so it follows you to another browser or computer. The server writes it onto the
// page's <html> tag (data-theme, data-choice) so the page is drawn in it from the start. The
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept in a
// cookie, so each device has its own: Glass on a phone, Dracula on a desktop (issue #69). The
// server reads it and writes it onto the page's <html> tag (data-theme, data-choice) so the page
// is drawn in it from the start. The
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
// the system, so no palette is written twice.
const THEMES: Record<string, {name: string, modes: boolean}> = {
@@ -10,6 +11,7 @@ const THEMES: Record<string, {name: string, modes: boolean}> = {
classic: {name: 'Classic', modes: false},
dracula: {name: 'Dracula', modes: true},
flatremix: {name: 'Flat Remix', modes: true},
glass: {name: 'Glass', modes: true},
gruvbox: {name: 'Gruvbox', modes: true},
contrast: {name: 'High contrast', modes: true},
material: {name: 'Material', modes: true},
@@ -24,7 +26,7 @@ const OLD_THEMES: Record<string, [string, string]> = {dark: ['modern', 'dark'],
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
const theme = {name: 'modern', mode: 'dark'};
/// `save` for a choice made in Settings, which goes to the account; not for applying one.
/// `save` for a choice made in Settings, which goes to this browser's cookie; not for applying one.
function setTheme(name = theme.name, mode = theme.mode, save = false){
theme.name = THEMES[name] ? name : 'modern';
theme.mode = MODES[mode] ? mode : 'dark';
@@ -34,27 +36,30 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){
// A theme with one palette has it whatever the mode; both of those are light.
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];
// 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');
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 mf = $('#smodefield'); if(mf) mf.hidden = !both;
if(save) saveTheme();
}
/// One save at a time, each sending the choice as it stands when it goes. Sent as they came,
/// several at once, a quick run through the list could reach the server out of order and
/// leave the account on a theme passed on the way.
let themeSaving = Promise.resolve();
/// Kept a year, for the whole site: the admin page is drawn in it too.
function saveTheme(){
themeSaving = themeSaving
.then(() => api('/api/me', {method: 'PATCH', body: JSON.stringify({theme: theme.name, mode: theme.mode})}))
.catch(e => toast(`Your theme was not saved: ${e.message}`, true));
document.cookie = `ipx_theme=${theme.name}.${theme.mode}; Path=/; Max-Age=31536000; SameSite=Lax`;
}
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
(() => {
const root = document.documentElement;
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice);
// Nothing on the account yet. A theme this browser kept, from before themes were kept on the
// account, goes up to it once, so nobody has to choose again.
// Without a cookie, the server sent the theme the account kept from before; this browser takes
// it as its own, once, so nobody has to choose again.
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice, !/(^|; )ipx_theme=/.test(document.cookie));
// Nothing on the account either. A theme this browser kept in localStorage, from before that,
// 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];

View File

@@ -10,6 +10,7 @@ const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt
// 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={
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
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
@@ -20,6 +21,10 @@ 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
@@ -29,7 +34,6 @@ const ICON={
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
popular:fa('0 0 576 512','<path fill="currentColor" d="M309.5-18.9c-4.1-8-12.4-13.1-21.4-13.1s-17.3 5.1-21.4 13.1L193.1 125.3 33.2 150.7c-8.9 1.4-16.3 7.7-19.1 16.3s-.5 18 5.8 24.4l114.4 114.5-25.2 159.9c-1.4 8.9 2.3 17.9 9.6 23.2s16.9 6.1 25 2L288.1 417.6 432.4 491c8 4.1 17.7 3.3 25-2s11-14.2 9.6-23.2L441.7 305.9 556.1 191.4c6.4-6.4 8.6-15.8 5.8-24.4s-10.1-14.9-19.1-16.3L383 125.3 309.5-18.9z"/>'), // solid/star
all:fa('0 0 512 512','<path fill="currentColor" d="M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z"/>'), // solid/layer-group
unread:fa('0 0 512 512','<path fill="currentColor" d="M48 64c-26.5 0-48 21.5-48 48 0 15.1 7.1 29.3 19.2 38.4l208 156c17.1 12.8 40.5 12.8 57.6 0l208-156c12.1-9.1 19.2-23.3 19.2-38.4 0-26.5-21.5-48-48-48L48 64zM0 196L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-188-198.4 148.8c-34.1 25.6-81.1 25.6-115.2 0L0 196z"/>'), // solid/envelope: a closed letter, not a record button
audio:fa('0 0 448 512','<path fill="currentColor" d="M64 224c0-88.4 71.6-160 160-160s160 71.6 160 160l0 37.5c-10-3.5-20.8-5.5-32-5.5l-16 0c-26.5 0-48 21.5-48 48l0 128c0 26.5 21.5 48 48 48l16 0c53 0 96-43 96-96l0-160C448 100.3 347.7 0 224 0S0 100.3 0 224L0 384c0 53 43 96 96 96l16 0c26.5 0 48-21.5 48-48l0-128c0-26.5-21.5-48-48-48l-16 0c-11.2 0-22 1.9-32 5.5L64 224z"/>'), // solid/headphones
@@ -56,8 +60,40 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
/// 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`; }
/// A total of time saved: "3 h 12 min", "12 min", "40 s".
function spanText(secs){
const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60);
return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`;
}
/// One trace for each thing you do (#190): the requests a tap or a key sets off share a trace id,
/// sent as traceparent, so Tempo shows them together. getRandomValues, not randomUUID, which a
/// LAN address over plain HTTP does not have.
let traceId='';
const hex=(n: number)=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join('');
for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ traceId=hex(16); },{capture:true,passive:true});
function traceparent(){ return `00-${traceId||=hex(16)}-${hex(8)}-01`; }
/// Something that went wrong, or how long something took, in the browser, for the server's log
/// (#190). The same thing at most once a minute: a failure in a loop would otherwise send a line
/// a frame.
const reported=new Map<string,number>();
function report(ev: string, o: Record<string,any>={}){
const key=ev+(o.msg||''), now=Date.now();
if(o.msg==='signed out' || 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(()=>{});
}
window.addEventListener('error',e=>report('page_error',{msg:String(e.message||e.error||'error'),src:e.filename,line:e.lineno}));
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'},...opts});
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
if(!r.ok) throw new Error(await r.text().catch(()=>'')||String(r.status));
return r.status===204?null:r.json().catch(()=>null);
@@ -92,6 +128,27 @@ async function copyText(text,btn){
}
}
/// The system's share sheet where there is one (phones, Safari, Edge), the clipboard elsewhere.
/// Asks first when the address looks like it carries your own access to a paid or private
/// feed: a Patreon feed's token, or a key, token or password in it, which would hand whoever
/// gets it your subscription (issue #48).
///
/// 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?){
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); }
return;
}
await copyText(url,btn);
toast('Link copied');
}
function toast(msg: string, bad?: boolean){
const t=document.createElement('div');
t.className='toast'+(bad?' bad':''); t.textContent=msg;
@@ -123,9 +180,15 @@ const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>
function tileHTML(name,cls){
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
/// no publisher's server asked on every visit. It began with http-only artwork, which the https
/// page could not load (#90).
function artSrc(url: string){
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
}
function artHTML(url: string | null, name: string, cls?: string){
return url
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
? `<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.
@@ -134,7 +197,7 @@ function folderArt(f,kids){
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(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.