91 Commits

Author SHA1 Message Date
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
52 changed files with 5146 additions and 521 deletions

View File

@@ -32,12 +32,13 @@ lines had no trace id, the access log's time was `ms`, and each event was logged
| 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`; 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::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 |
Events (`ev`): `feed_start`, `feed_done` (new, downloaded, failed, torrents), `feed_skip` (not due,
routine), `feed_error` (msg), `download_done` (bytes), `download_error` (msg, url),
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), `error` (msg).
@@ -85,6 +86,12 @@ a count says something happened, the lines and traces say why.
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}'

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
/web/dist
.claude/settings.local.json
__pycache__/

View File

@@ -7,6 +7,123 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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, and Settings shows the time it has saved you. It is off for every show until you turn it on (#171, #172).
- An episode DeadAir is still working on says "Cutting dead air…", and changes to how much shorter it is when it is done, without a reload (#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).
- DeadAir shortens an episode you have opened but not played yet, instead of passing it over as if you had finished it (#173).
- DeadAir shortens episodes with more than about a hundred long silences, which is most of an hour's conversation, instead of failing on them, and tries a file it failed on again after the next restart (#174).
- 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.
- Glass's top bar is solid on a phone, in the colour its glass showed, so the strip above it on an iPhone can match it.
- 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.
- 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.
- The feed list, and each live update of it, no longer asks the database for a group's title once for every feed in the group.
- 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.
- 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. This replaces the grid of every feed under rows of category buttons.
- The most subscribed feeds are the Directory's first section instead of a place of their own: Popular is gone from the feed list, and so is 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.
- The Directory can be sorted by name, A to Z or Z to A, or by most subscribers. It still opens A to Z.
- 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
@@ -692,7 +809,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...main
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.10.3...main
[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

View File

@@ -124,7 +124,8 @@ 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:
@@ -141,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.
@@ -182,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

3
Cargo.lock generated
View File

@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "ipx"
version = "0.9.1"
version = "0.11.0"
dependencies = [
"ammonia",
"anyhow",
@@ -1843,6 +1843,7 @@ dependencies = [
"sea-orm",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"toml",
"tower",

View File

@@ -1,6 +1,6 @@
[package]
name = "ipx"
version = "0.9.1"
version = "0.11.0"
edition = "2024"
[dependencies]
@@ -25,7 +25,8 @@ 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"] }

View File

@@ -24,8 +24,10 @@ COPY web ./web
RUN touch src/main.rs && cargo build --release --locked
FROM debian:bookworm-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

@@ -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

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 |
@@ -143,6 +150,7 @@ cargo test # parsing, filters, retention, schedules, SQL, per-use
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.

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
@@ -27,6 +27,12 @@ config.toml's default location is `$XDG_CONFIG_HOME/ipx/config.toml`
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 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]`
```toml
@@ -39,21 +45,27 @@ 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. 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. Admin-only from the UI.
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.
* **`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
@@ -124,6 +136,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`
```
@@ -133,6 +146,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.

View File

@@ -233,5 +233,19 @@ Set `auto_create_users = false` once everyone who should have an account has one
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

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",

136
src/art.rs Normal file
View File

@@ -0,0 +1,136 @@
//! 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;
}
}
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)]
@@ -163,6 +166,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 +187,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 +298,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 +320,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 +331,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 +589,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 +608,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 +629,7 @@ mod tests {
password: Some("literal".into()),
password_env: None,
category: None,
listed: false,
};
assert_eq!(f.password().as_deref(), Some("literal"));

828
src/db.rs

File diff suppressed because it is too large Load Diff

560
src/deadair.rs Normal file
View File

@@ -0,0 +1,560 @@
//! DeadAir (issue #171): podcasts play from a copy with their long silences shortened, so they
//! finish sooner without sounding sped up.
//!
//! The copy is the original's own frames with the silent ones left out, copied by ffmpeg with
//! nothing re-encoded: about 10 s an hour of audio on one core, finding the silences included.
//! It is made in the background, one file at a time, for files in feeds someone has DeadAir on
//! for, and kept beside the original (`copy_path`) until `download::remove` takes both.
//!
//! Positions stay in the original's time. What the copy left 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::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
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;
/// Kept at each end of a 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.
const KEEP: f64 = 0.1;
/// ffmpeg takes the frames to drop as one argument, and Linux refuses an argument over 128 KiB:
/// 3500 of them, numbered into the millions, come to about 110 KiB.
// shortcut: a file with more silences than this keeps only its longest; about 7 hours of
// conversation at the rate measured. Pass the list some other way if a feed ever gets there.
const MAX_CUTS: usize = 3500;
/// 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 the copy leaves out.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cut(pub i64, pub i64);
/// Where the copy of `original` goes: beside it, `name.deadair.mp3` for `name.mp3`.
pub fn copy_path(original: &Path) -> PathBuf {
let stem = original.file_stem().map(|s| s.to_string_lossy()).unwrap_or_default();
let name = match original.extension() {
Some(ext) => format!("{stem}.deadair.{}", ext.to_string_lossy()),
None => format!("{stem}.deadair"),
};
original.with_file_name(name)
}
/// 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` in from each end, go.
pub fn plan(silences: &[(f64, f64)], first: f64, frame: f64) -> (Vec<(u64, u64)>, Vec<Cut>) {
let mut 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();
if spans.len() > MAX_CUTS {
spans.sort_by_key(|&(a, b)| std::cmp::Reverse(b - a));
spans.truncate(MAX_CUTS);
spans.sort();
}
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)
}
/// A time in the original, in ms, as the same moment in the copy. 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 copy, 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 from its copies. 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` needs.
pub struct Wanting {
pub people: HashMap<i64, HashMap<String, bool>>,
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();
for (user, feed, wanted) in ctx.db.deadair_prefs(only).await? {
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, 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)))
}
}
/// What each person is playing from a copy, by (person, feed, guid): set when `/media` serves
/// them a copy and cleared when it serves 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 more of the copy 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<crate::db::Copy>>>>;
/// Puts a page of entries in the clock of what this person will play: for each file with a
/// copy they play, `deadair` is the seconds it saves, and the entry's position and length are
/// the copy's. The page plays `/media/{id}/deadair` 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 copies: HashMap<i64, crate::db::Copy> =
ctx.db.copies(&ids).await?.into_iter().map(|c| (c.enclosure_id, c)).collect();
let done = ctx.db.deadair_done(&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" && !done.contains(&e.id) && !finished;
let Some(c) = copies.get(&e.id) else { continue };
e.deadair = Some(c.saved_ms / 1000);
// One position an item, however many files: the first with a copy sets the clock.
if !std::mem::replace(&mut converted, true) {
r.position = to_copy(&c.cuts, r.position * 1000) / 1000;
r.duration = r.duration.map(|d| to_copy(&c.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 a copy, 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 copy = playing.lock().unwrap().get(&(user, feed.to_owned(), guid.to_owned())).cloned();
let Some(c) = copy else { return Ok((secs, duration)) };
let original = |s: i64| to_original(&c.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, c.saved_ms).await?;
}
Ok((secs, duration))
}
/// Makes copies until stopped: the next one anyone wants, 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 makes no copies");
return;
}
// Files ffmpeg failed on, passed over until the next start. Not stored: a failure recorded
// as the file having no copy 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 made = tokio::select! {
_ = stop.changed() => return,
made = next(&ctx, &mut failed) => made,
};
match made {
Ok(true) => continue,
Ok(false) => {}
Err(e) => tracing::warn!(error = %format!("{e:#}"), "DeadAir could not make a copy"),
}
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())
}
/// Makes one copy someone wants, if there is one to make. A file everyone who would play the
/// copy 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.
let wanting = Wanting::load(ctx, None).await?;
if wanting.people.is_empty() {
return Ok(false);
}
for t in ctx.db.deadair_todo().await? {
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();
let made = make(&src).await;
let (path, cuts) = match made {
Ok(Some((path, cuts))) => (Some(path.display().to_string()), cuts),
Ok(None) => (None, vec![]),
Err(e) => {
tracing::warn!(path = %src.display(), error = %format!("{e:#}"), "DeadAir could not make a copy");
failed.insert(t.id);
return Ok(true);
}
};
let saved_ms = saved(&cuts);
ctx.db.store_deadair(t.id, path.as_deref(), &serde_json::to_string(&cuts)?, saved_ms).await?;
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 = cuts.len(),
saved_s = saved_ms / 1000,
took_ms = started.elapsed().as_millis() as u64,
"DeadAir copy made"
);
return Ok(true);
}
Ok(false)
}
/// Makes the copy of one file. None for a file it does not apply to: video, anything but MP3
/// and AAC, or one with no silence long enough to cut.
pub async fn make(src: &Path) -> Result<Option<(PathBuf, Vec<Cut>)>> {
let Some((first, frame)) = probe(src).await? else { return Ok(None) };
let log = ffmpeg(&[
"-hide_banner", "-nostats", "-i", &src.display().to_string(), "-map", "0:a:0",
"-af", &format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"), "-f", "null", "-",
])
.await
.context("finding the silences")?;
let (frames, cuts) = plan(&parse_silences(&log), first, frame);
if cuts.is_empty() {
return Ok(None);
}
let drop: Vec<String> = frames.iter().map(|(a, b)| format!("between(n\\,{a}\\,{b})")).collect();
let dst = copy_path(src);
// Written under another name and moved into place, so a copy cut short by a restart is
// never served.
let tmp = dst.with_file_name(format!(
"{}.part.{}",
dst.file_stem().unwrap_or_default().to_string_lossy(),
dst.extension().unwrap_or_default().to_string_lossy()
));
ffmpeg(&[
"-v", "error", "-y", "-i", &src.display().to_string(), "-map", "0:a:0", "-c", "copy",
"-bsf:a", &format!("noise=drop='{}',setts=ts=N*DURATION", any(&drop)),
&tmp.display().to_string(),
])
.await
.context("copying the frames kept")?;
tokio::fs::rename(&tmp, &dst).await.with_context(|| format!("moving into {}", dst.display()))?;
Ok(Some((dst, cuts)))
}
/// Terms summed as a balanced tree, `((a+b)+(c+d))`. A plain `a+b+c+...` nests a level a term,
/// and Debian's ffmpeg 5.1 refuses an expression nested much past 100 (#174).
fn any(terms: &[String]) -> String {
match terms {
[] => "0".into(),
[one] => one.clone(),
_ => {
let (a, b) = terms.split_at(terms.len() / 2);
format!("({}+{})", any(a), any(b))
}
}
}
/// When the first audio frame starts and how long each lasts, in seconds, for a file DeadAir
/// can copy; 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", "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");
if video || !audio.is_some_and(|a| a["codec_name"] == "mp3" || a["codec_name"] == "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.
let frame = 1152.0 / 44100.0;
let (frames, cuts) = plan(&[(1.0, 3.0), (5.0, 5.3)], 0.0, frame);
assert_eq!(frames, vec![(43, 110)]);
let Cut(s, e) = cuts[0];
assert!(s >= 1100 && e <= 2900, "{cuts:?}");
assert!(s - 1100 < 27 && 2900 - e < 27, "no more than a frame kept beyond KEEP: {cuts:?}");
// AAC in MP4 starts a frame early, for the encoder's priming.
let (frames, _) = plan(&[(1.0, 3.0)], -0.02322, 1024.0 / 44100.0);
assert_eq!(frames, vec![(49, 124)]);
// A silence the margins use up entirely is left alone.
assert!(plan(&[(1.0, 1.21)], 0.0, frame).0.is_empty());
}
#[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 copy 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_frames_to_drop_nest_shallowly_and_fit_one_argument() {
let terms: Vec<String> = (0..MAX_CUTS as u64)
.map(|i| format!("between(n\\,{}\\,{})", 1_000_000 + i * 300, 1_000_000 + i * 300 + 250))
.collect();
let e = any(&terms);
let deepest = e.chars().scan(0i32, |d, c| {
*d += match c { '(' => 1, ')' => -1, _ => 0 };
Some(*d)
}).max().unwrap();
assert!(deepest <= 14, "{deepest} deep");
assert!(e.len() < 128 * 1024, "{} bytes", e.len());
assert_eq!(any(&terms[..3]), format!("({}+({}+{}))", terms[0], terms[1], terms[2]));
}
#[test]
fn the_copy_sits_beside_the_original() {
assert_eq!(copy_path(Path::new("/d/show/ep 1.mp3")), Path::new("/d/show/ep 1.deadair.mp3"));
}
/// The whole of it, on a clip ffmpeg makes: a second of tone, two of silence, and so on.
/// Skipped where ffmpeg is not installed.
#[tokio::test]
async fn a_copy_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"), ("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 (dst, cuts) = make(&src).await.unwrap().expect("a copy");
assert_eq!(cuts.len(), 4, "{ext}: {cuts:?}");
// About 1.8 s from each of the four 2 s silences.
let cut = saved(&cuts);
assert!((7_000..7_300).contains(&cut), "{ext}: {cut}");
let len = |p: &Path| {
let p = p.display().to_string();
async move {
run_out("ffprobe", &["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", &p])
.await
.unwrap()
.trim()
.parse::<f64>()
.unwrap()
}
};
let short = len(&src).await - len(&dst).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", &dst.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`. The copy is left
/// beside it to listen to.
#[cfg(test)]
#[tokio::test]
#[ignore]
async fn real_file() {
let src = PathBuf::from(std::env::var("IPX_DEADAIR_FILE").expect("IPX_DEADAIR_FILE"));
let t = std::time::Instant::now();
let (dst, cuts) = make(&src).await.unwrap().expect("a copy");
eprintln!("{} cuts, {:.1} min saved, {:.1?} -> {}", cuts.len(), saved(&cuts) as f64 / 60_000.0, t.elapsed(), dst.display());
}

View File

@@ -207,6 +207,38 @@ 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);
}
// Its DeadAir copy goes with it, if it has one; it is no use without the original (#171).
let _ = std::fs::remove_file(crate::deadair::copy_path(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"))
@@ -406,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");
@@ -415,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()];

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)]
@@ -283,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 {
@@ -323,3 +354,69 @@ pub mod settings {
impl ActiveModelBehavior for ActiveModel {}
}
/// DeadAir's copy of an enclosure: the same file with long silences shortened (issue #171).
/// `cuts` is what the copy leaves out, as `[[start_ms, end_ms], ...]` of the original's time,
/// which is what converts positions between the two. `path` is NULL for a file that cannot have
/// one (video, or not MP3 or AAC), so it is looked at once and not every pass.
pub mod deadair {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "deadair")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub enclosure_id: i64,
#[sea_orm(column_type = "Text", nullable)]
pub path: Option<String>,
#[sea_orm(column_type = "Text")]
pub cuts: String,
pub saved_ms: i64,
/// Against the enclosure's `downloaded_at`: a file downloaded again needs its copy 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,
}
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

@@ -13,8 +13,11 @@ pub struct ParsedFeed {
pub image: Option<String>,
/// 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 chips.
/// 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>,
}
@@ -53,43 +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
@@ -158,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
@@ -253,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()));
}
@@ -451,6 +496,30 @@ pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
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 {
@@ -681,6 +750,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
// 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())
@@ -750,6 +820,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
.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,
}
}
@@ -955,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]
@@ -967,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];
@@ -991,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>
@@ -1097,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());
@@ -1390,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 copy made, {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.
@@ -172,7 +184,7 @@ fn log_event(e: &Event, wire: bool) {
($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"),
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"),
@@ -204,13 +216,15 @@ 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(
@@ -281,9 +295,9 @@ 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;
let ev = status(cmd).await;
log_event(&ev, true);
let _ = reply.send(ev).await;
}
@@ -299,6 +313,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?;
@@ -328,6 +362,14 @@ pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
mod tests {
use super::*;
#[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();
@@ -399,7 +441,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

View File

@@ -61,7 +61,11 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
}
let candidates = db.reap_candidates().await?;
let mut candidates = db.reap_candidates().await?;
// A file's DeadAir copy is on the disk too, and goes when it goes (#171).
for c in &mut candidates {
c.bytes += copy_bytes(&c.path) as i64;
}
if cfg.general.max_age_days > 0 {
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
@@ -96,9 +100,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() + copy_bytes(&c.path));
// 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");
@@ -108,6 +116,10 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
Ok(size)
}
fn copy_bytes(path: &str) -> u64 {
std::fs::metadata(crate::deadair::copy_path(std::path::Path::new(path))).map_or(0, |m| m.len())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -189,11 +201,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),

View File

@@ -32,17 +32,21 @@ pub struct WebState {
pub events: broadcast::Sender<Event>,
/// Cloudflare Access's signing keys, fetched once and kept.
pub access: Arc<crate::access::Keys>,
pub playing: crate::deadair::Playing,
}
pub fn router(state: WebState) -> Router {
Router::new()
.route("/", get(index))
.route("/api/me", get(me).patch(patch_me))
.route("/api/tokens", get(list_tokens).post(add_token))
.route("/api/tokens/{id}", axum::routing::delete(remove_token))
.route("/api/logout", post(logout))
.route("/api/feeds", get(feeds).post(add_feed))
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
.route("/api/feeds/{id}/entries", get(entries))
.route("/api/entries", get(all_entries))
.route("/api/entries/{feed_id}/{guid}", get(one_entry))
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
.route("/api/feeds/{id}/read-all", post(read_all))
@@ -56,6 +60,7 @@ pub fn router(state: WebState) -> Router {
.route("/api/status", get(status))
.route("/api/popular", get(get_popular))
.route("/api/directory", get(get_directory))
.route("/api/directory/{id}", get(get_listed))
.route("/api/popular/{id}", post(subscribe_popular))
.route("/api/users", get(list_users).post(add_user))
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
@@ -64,6 +69,7 @@ pub fn router(state: WebState) -> Router {
.route("/admin", get(admin_page))
.route("/admin.js", get(admin_js))
.route("/media/{id}", get(media))
.route("/media/{id}/deadair", get(media_deadair))
.route("/api/art", get(art))
.layer(middleware::from_fn_with_state(state.clone(), auth))
// Signing in cannot require being signed in, so these sit outside the auth layer.
@@ -76,6 +82,8 @@ pub fn router(state: WebState) -> Router {
.route("/favicon.png", get(favicon))
.route("/favicon-dark.png", get(favicon_dark))
.route("/apple-touch-icon.png", get(touch_icon))
// iOS asks for this one first when the site is added to a home screen (#109).
.route("/apple-touch-icon-precomposed.png", get(touch_icon))
.route("/app.js", get(app_js))
.route("/app.css", get(app_css))
.route("/login.js", get(login_js))
@@ -86,9 +94,17 @@ pub fn router(state: WebState) -> Router {
}
pub async fn serve(state: WebState, bind: &str) -> Result<()> {
use axum::serve::ListenerExt;
// Nagle off, as axum 0.7 had it: under load a response goes out in parts, and with Nagle
// on its last small part waits for the browser's delayed ACK, up to 40 ms (#170).
let listener = tokio::net::TcpListener::bind(bind)
.await
.with_context(|| format!("binding {bind}"))?;
.with_context(|| format!("binding {bind}"))?
.tap_io(|tcp| {
if let Err(e) = tcp.set_nodelay(true) {
tracing::debug!(error = %e, "could not set TCP_NODELAY");
}
});
tracing::info!(bind, "web ui listening");
axum::serve(
listener,
@@ -98,8 +114,8 @@ pub async fn serve(state: WebState, bind: &str) -> Result<()> {
.context("serving the web ui")
}
/// Who is asking, in order of how specific the claim is: a proxy that vouches for a name,
/// a session cookie, then the shared token (which is the admin).
/// Who is asking, in order of how specific the claim is: a proxy that vouches for a name, an
/// API token, a session cookie, then the shared token (which is the admin).
///
/// Any of them has to survive being put in a cookie: an `<audio src>` request is issued by
/// the browser, and there is no way to attach a header to it.
@@ -142,6 +158,26 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
}
let by_proxy = user.is_some();
// An API token, made by its owner in Settings, for a script or an agent acting as them
// (#123). One that is not known is turned away rather than tried as anything else: a
// script sent with a revoked token should hear so, not fall through to a cookie.
if user.is_none()
&& let Some(bearer) = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
{
match state.ctx.db.api_token_user(&crate::auth::api_token_hash(bearer.trim())).await {
Ok(Some(u)) => user = Some(u),
Ok(None) => return (StatusCode::UNAUTHORIZED, "unknown or revoked API token").into_response(),
Err(e) => {
tracing::error!(error = %e, "looking up an API token");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}
}
// 2. A session cookie from signing in here.
if user.is_none() {
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
@@ -290,11 +326,8 @@ async fn login(
) -> Result<Response, ApiError> {
let name = body.name.trim().to_ascii_lowercase();
let user = state.ctx.db.user_by_name(&name).await?;
// The same answer either way: whether a name exists is not something to leak.
let ok = user
.as_ref()
.and_then(|u| u.pass_hash.as_deref())
.is_some_and(|h| crate::auth::verify_password(&body.password, h));
// The same answer either way, in the same time: whether a name exists is not something to leak.
let ok = crate::auth::check_password(body.password, user.as_ref().and_then(|u| u.pass_hash.clone())).await;
if !ok {
tracing::warn!(user = %name, "failed sign-in");
return Ok((StatusCode::UNAUTHORIZED, "wrong name or password").into_response());
@@ -343,8 +376,10 @@ async fn me(
let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
let saved = state.ctx.db.deadair_total(user.id).await.unwrap_or(0) / 1000;
Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
"deadair_saved": saved,
}))
}
@@ -390,6 +425,54 @@ fn last_admin(users: &[crate::db::User], id: i64) -> bool {
admins == [id]
}
/// Your API tokens: names and dates, never the tokens, which are not kept.
async fn list_tokens(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<serde_json::Value>, ApiError> {
let tokens: Vec<_> = state
.ctx
.db
.api_tokens(user.id).await?
.iter()
.map(|t| serde_json::json!({ "id": t.id, "name": t.name, "created": t.created, "last_used": t.last_used }))
.collect();
Ok(Json(serde_json::json!(tokens)))
}
#[derive(Deserialize)]
struct NewToken {
name: String,
}
/// Makes an API token that acts as you, and answers with it: the only time it is shown.
async fn add_token(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<NewToken>,
) -> Result<Json<serde_json::Value>, ApiError> {
let name = body.name.trim();
if name.is_empty() {
return Err(ApiError::bad_request("give the token a name, to know it by when revoking it"));
}
let token = crate::auth::new_api_token();
let id = state.ctx.db.create_api_token(user.id, name, &crate::auth::api_token_hash(&token)).await?;
tracing::info!(user = %user.name, token = name, "API token made");
Ok(Json(serde_json::json!({ "id": id, "name": name, "token": token })))
}
async fn remove_token(
State(state): State<WebState>,
user: crate::db::User,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
if !state.ctx.db.delete_api_token(user.id, id).await? {
return Err(ApiError::bad_request(format!("you have no API token with id {id}")));
}
tracing::info!(user = %user.name, id, "API token revoked");
Ok(StatusCode::NO_CONTENT)
}
async fn list_users(
State(state): State<WebState>,
user: crate::db::User,
@@ -488,7 +571,7 @@ async fn remove_user(
target.name
)));
}
state.ctx.db.delete_user(id).await?;
crate::remove_user(&state.ctx, id).await?;
tracing::info!(by = %user.name, user = %target.name, "account removed");
Ok(StatusCode::NO_CONTENT)
}
@@ -690,6 +773,8 @@ struct FeedRow {
keywords: Vec<String>,
/// Your words that hide an item of this feed, beside your list for every feed.
blocked: Vec<String>,
/// Played from DeadAir's copies: on for this feed or its group, off until you turn it on.
deadair: bool,
allow_explicit: bool,
auto_download: bool,
max_new_per_check: Option<usize>,
@@ -737,20 +822,26 @@ async fn feeds(
/// The feed list as this person sees it, or with `only` the one row for that feed: what a live
/// update sends after the feed changes, so the page redraws a row instead of reloading the list.
async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>) -> anyhow::Result<Vec<FeedRow>> {
use crate::db::step;
use tracing::Instrument;
let cfg = state.ctx.cfg();
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
// What comes back is only the part of it this person subscribes to.
let subs = crate::subscriptions(&state.ctx).await?;
let subs = crate::subscriptions(&state.ctx).instrument(step("catalogue")).await?;
let mine: std::collections::HashMap<String, crate::db::Sub> = state
.ctx
.db
.subscriptions_for(user.id).await?
.subscriptions_for(user.id)
.instrument(step("subscriptions"))
.await?
.into_iter()
.map(|s| (s.feed_id.clone(), s))
.collect();
let counts = state.ctx.db.subscriber_counts().await?;
let pinned = state.ctx.db.pinned_feeds(user.id).await?;
let mut listed = state.ctx.db.feed_list(user.id, only).await?;
let counts = state.ctx.db.subscriber_counts().instrument(step("subscriber_counts")).await?;
let pinned = state.ctx.db.pinned_feeds(user.id).instrument(step("pinned")).await?;
let deadair: std::collections::HashMap<String, bool> =
state.ctx.db.deadair_prefs(Some(user.id)).await?.into_iter().map(|(_, f, w)| (f, w)).collect();
let mut listed = state.ctx.db.feed_list(user.id, only).instrument(step("feed_list")).await?;
let mut out = Vec::with_capacity(mine.len());
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
let (id, feed) = (&sub.id, &sub.cfg);
@@ -774,6 +865,7 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
.or_else(|| up.and_then(|u| u.keywords.clone()))
.unwrap_or_else(|| feed.keywords.clone()),
blocked,
deadair: deadair.get(id).or_else(|| feed.group.as_deref().and_then(|g| deadair.get(g))).copied().unwrap_or(false),
allow_explicit: mine
.allow_explicit
.or(up.and_then(|u| u.allow_explicit))
@@ -859,8 +951,10 @@ struct PopularRow {
subscribers: i64,
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
subscribed: bool,
/// The feed's own iTunes category, if it names one; most blogs do not.
/// Apple's category, from the feed's own iTunes category or else the catalogue's, and the
/// subcategory within it where there is one: the Directory browses them in that order.
category: Option<String>,
subcategory: Option<String>,
/// Any audio or video enclosure. Unlike category, every feed has an answer, so the
/// Directory's Podcasts and Blogs between them hold everything.
podcast: bool,
@@ -870,39 +964,37 @@ struct PopularRow {
/// first. Popular is the top of it, the directory is all of it, and it is all that
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
/// feeds inside it and never itself: both lists are for finding a show.
async fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
/// The catalogue as others may see it. `everything` is the Directory: every feed, with or
/// without subscribers, so the ones an admin listed show before anyone takes them. Without it,
/// Popular: only what people subscribe to.
async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result<Vec<PopularRow>> {
let db = &state.ctx.db;
// One pass for every feed's title, artwork and category, not three queries a feed.
let mut listed = db.feed_list(user_id, None).await?;
let mine: std::collections::HashSet<String> =
db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
let counts = db.subscriber_counts().await?;
let media = db.media_feeds().await?;
let catalogue = crate::subscriptions(&state.ctx).await?;
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
let is_folder: std::collections::HashSet<&str> =
catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
let ok = listable(&catalogue);
let mut out = vec![];
for s in &catalogue {
let n = counts.get(&s.id).copied().unwrap_or(0);
// A feed inside an OPML that looks private is as private as the OPML.
let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g));
if n == 0
|| is_folder.contains(s.id.as_str())
|| looks_private(&s.cfg)
|| folder.is_some_and(|f| looks_private(f))
{
if (n == 0 && !everything) || !ok.contains(s.id.as_str()) {
continue;
}
let sum = db.feed_summary(&s.id).await?;
let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default();
let subscribed = mine.contains(&s.id);
// The feed's own wins; an admin's is for the feeds, mostly blogs, that name none.
let path = sum.category.or_else(|| s.cfg.category.clone()).map(|c| crate::feed::category_path(&c));
out.push(PopularRow {
id: s.id.clone(),
title: sum.title,
image: sum.image,
subscribers: n,
subscribed,
// The feed's own wins; an admin's is for the feeds, mostly blogs, that name none.
category: sum.category.or_else(|| s.cfg.category.clone()),
category: path.as_ref().map(|p| p.0.clone()),
subcategory: path.and_then(|p| p.1),
podcast: media.contains(&s.id),
});
}
@@ -910,11 +1002,31 @@ async fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
Ok(out)
}
/// The feeds the Directory may list: not an OPML or a Patreon creator itself, which is listed as
/// the feeds in it, and neither the feed nor the group it is in private. Asked of one feed, without
/// building the whole Directory, by a feed's Directory page and by subscribing from it: building
/// it was nearly all of the 0.57 s a feed's page took (#160).
fn listable(catalogue: &[crate::Sub]) -> std::collections::HashSet<&str> {
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
let is_folder: std::collections::HashSet<&str> = catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
catalogue
.iter()
// A feed inside an OPML that looks private is as private as the OPML.
.filter(|s| {
!is_folder.contains(s.id.as_str())
&& !looks_private(&s.cfg)
&& !s.cfg.group.as_deref().and_then(|g| by_id.get(g)).is_some_and(|f| looks_private(f))
})
.map(|s| s.id.as_str())
.collect()
}
async fn get_popular(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id).await?;
let mut rows = popular(&state, user.id, false).await?;
rows.truncate(10);
Ok(Json(rows))
}
@@ -924,11 +1036,74 @@ async fn get_directory(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id).await?;
let mut rows = popular(&state, user.id, true).await?;
rows.sort_by_key(sort_name);
Ok(Json(rows))
}
/// One of a listed feed's latest items, for its page in the Directory before anyone subscribes
/// (#128): what it is, never its files, whose addresses are where a key would be.
#[derive(Serialize)]
struct ListedItem {
guid: String,
title: Option<String>,
link: Option<String>,
published: Option<i64>,
duration: Option<i64>,
description: Option<String>,
}
#[derive(Serialize)]
struct Listed {
/// What the feed says it is (#130), sanitized as its items are.
description: Option<String>,
/// Why its last check failed, when it did (#142), in the words a feed's own page uses.
failing: Option<String>,
items: Vec<ListedItem>,
}
/// A feed's last error as its Directory page says it: explained once it has been failing a day, as
/// the feed's own page does, or else that the check failed; never the error as it stands, which can
/// name the feed's address, and the Directory names none. A day, so that a site down for an hour,
/// or a resolver for a minute, is not called gone (#143).
fn failure_words(err: &str, failing_for: i64) -> String {
Some(err)
.filter(|_| failing_for >= FLAG_AFTER_SECS)
.and_then(crate::feed::explain_failure)
.map_or_else(|| "Its last check failed.".to_owned(), |f| f.reason.to_owned())
}
/// A listed feed's description and latest twenty items. Only a feed the Directory lists, so a
/// guessed id reaches nothing private, as `subscribe_popular` checks.
async fn get_listed(
State(state): State<WebState>,
user: crate::db::User,
Path(id): Path<String>,
) -> Result<Json<Listed>, ApiError> {
if !listable(&crate::subscriptions(&state.ctx).await?).contains(id.as_str()) {
return Err(ApiError::not_found(format!("{id:?} is not in the directory")));
}
let order = crate::db::order_sql("published", "desc");
let rows = state.ctx.db.entries_in(user.id, Some(&id), crate::db::Filter::parse("all"), None, 0, 20, &order).await?;
let mut sanitizer = feed_sanitizer();
let description = state.ctx.db.feed_description(&id).await?.map(|d| clean_description(&mut sanitizer, &d, None));
let summary = state.ctx.db.feed_summary(&id).await?;
let failing_for = summary.error_since.map_or(0, |since| crate::db::now() - since);
let failing = summary.last_error.as_deref().map(|e| failure_words(e, failing_for));
let items = rows
.into_iter()
.map(|e| ListedItem {
description: e.description.as_deref().map(|d| clean_description(&mut sanitizer, d, e.link.as_deref())),
guid: e.guid,
title: e.title,
link: e.link,
published: e.published,
duration: e.duration,
})
.collect();
Ok(Json(Listed { description, failing, items }))
}
fn sort_name(p: &PopularRow) -> String {
p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase()
}
@@ -940,10 +1115,12 @@ async fn subscribe_popular(
user: crate::db::User,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
if !popular(&state, user.id).await?.iter().any(|p| p.id == id) {
if !listable(&crate::subscriptions(&state.ctx).await?).contains(id.as_str()) {
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
}
state.ctx.db.subscribe(user.id, &id).await?;
// A listed feed nobody took was checked once a day at most: read it now, not in an hour.
scan_soon(&state, Some(id.clone())).await;
Ok(Json(serde_json::json!({ "id": id })))
}
@@ -1018,6 +1195,18 @@ impl IntoResponse for ApiError {
mod tests {
use super::*;
#[test]
fn a_failing_feed_says_why_in_the_directory_without_its_address() {
let day = FLAG_AFTER_SECS;
let nothing = "the feed's address answered with nothing at all";
assert_eq!(failure_words(nothing, day), "This address answers with nothing; the site may be gone.");
// Not before it has been failing a day, as on the feed's own page (#143).
assert_eq!(failure_words(nothing, 3600), "Its last check failed.");
// reqwest names the URL; the Directory names none.
let raw = "error sending request for url (http://daily-quests.com/comic/?feed=atom): connection closed";
assert_eq!(failure_words(raw, day), "Its last check failed.");
}
#[test]
fn a_relative_image_resolves_against_the_post() {
let mut b = feed_sanitizer();
@@ -1073,6 +1262,7 @@ mod tests {
password: None,
password_env: None,
category: None,
listed: false,
};
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
@@ -1091,6 +1281,25 @@ mod tests {
assert!(looks_private(&basic), "a feed with a login configured");
}
#[test]
fn the_directory_lists_feeds_not_their_groups_and_nothing_private() {
let sub = |id: &str, url: &str, group: Option<&str>| crate::Sub {
id: id.into(),
cfg: crate::config::Feed { group: group.map(Into::into), ..feed(url) },
managed: group.is_some(),
};
let catalogue = [
sub("plain", "https://example.com/rss", None),
sub("opml", "https://example.com/list.opml", None),
sub("kid", "https://kid.example/rss", Some("opml")),
sub("paid", "https://www.patreon.com/rss/x?auth=abc", None),
sub("paid-show", "https://www.patreon.com/rss/x?auth=abc&show=1", Some("paid")),
];
let mut got: Vec<&str> = listable(&catalogue).into_iter().collect();
got.sort();
assert_eq!(got, ["kid", "plain"], "the OPML as its feeds; a private feed and its shows never");
}
#[test]
fn only_the_last_admin_is_protected() {
let u = |id, is_admin| crate::db::User {
@@ -1119,7 +1328,7 @@ mod tests {
crate::config::Feed {
url: url.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,
}
}
@@ -1201,9 +1410,22 @@ async fn entries(
user: crate::db::User,
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> {
subscribed(&state, &user, &id).await?;
entry_page(&state, user.id, Some(&id), &page).await
}
/// A feed's items and files are for the people who subscribe to it (#129). Anyone signed in
/// could read any feed's by its id, a paid one's included, with its enclosure addresses, which
/// can carry the subscriber's key, and fetch, queue or delete its files by theirs. A feed in
/// an OPML has a subscription of its own for everyone subscribed to the OPML, so this holds
/// for those too. Not found, as for any other feed that is not yours.
async fn subscribed(state: &WebState, user: &crate::db::User, feed_id: &str) -> Result<(), ApiError> {
match state.ctx.db.subscription(user.id, feed_id).await? {
Some(_) => Ok(()),
None => Err(ApiError::not_found("you do not subscribe to that feed")),
}
}
/// Every subscribed feed's items together, newest first: All Subscriptions.
async fn all_entries(
State(state): State<WebState>,
@@ -1232,10 +1454,30 @@ async fn entry_page(
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
}
}
crate::deadair::for_reader(&state.ctx, user_id, &mut rows).await?;
let total = db.count_in(user_id, feed, filter, search).await?;
Ok(Json(EntryPage { total, entries: rows }))
}
/// One entry, in the clock of the file this person will play (`deadair::for_reader`).
async fn one_entry(
State(state): State<WebState>,
Path((feed_id, guid)): Path<(String, String)>,
user: crate::db::User,
) -> Result<Json<crate::db::EntryRow>, ApiError> {
subscribed(&state, &user, &feed_id).await?;
let Some(row) = state.ctx.db.entry(user.id, &feed_id, &guid).await? else {
return Err(ApiError::not_found("no such entry"));
};
let mut rows = [row];
crate::deadair::for_reader(&state.ctx, user.id, &mut rows).await?;
let [mut row] = rows;
if let Some(d) = &row.description {
row.description = Some(clean_description(&mut feed_sanitizer(), d, row.link.as_deref()));
}
Ok(Json(row))
}
/// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
fn feed_sanitizer() -> ammonia::Builder<'static> {
let mut b = ammonia::Builder::new();
@@ -1287,7 +1529,6 @@ async fn add_feed(
user: crate::db::User,
Json(body): Json<NewFeed>,
) -> Result<Json<serde_json::Value>, ApiError> {
let mut cfg = (*state.ctx.cfg()).clone();
// Before the duplicate check, so a site's page finds the feed someone already has. What is
// not a feed and links none is refused here, with why, and nothing is added.
let url = crate::feed::find_feed(&state.ctx.client, &crate::feed::expand_input(&body.url))
@@ -1295,26 +1536,14 @@ async fn add_feed(
.map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
// Someone else may already have it. Then adding costs nothing: no second fetch, no
// second copy on disk, just another name against the same feed.
if let Some(existing) = crate::subscriptions(&state.ctx).await?
.into_iter()
.find(|s| crate::feed::same_feed(&s.cfg.url, &url))
{
let already = state.ctx.db.subscription(user.id, &existing.id).await?.is_some();
state.ctx.db.subscribe(user.id, &existing.id).await?;
if !already {
explicit_on_add(&state, user.id, &existing.id, body.allow_explicit).await?;
}
scan_soon(&state, Some(existing.id.clone())).await;
return Ok(Json(
serde_json::json!({ "id": existing.id, "existing": already }),
));
}
let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?;
state.ctx.store_cfg(cfg).await?;
let (id, existed) = crate::add_to_catalogue(&state.ctx, &url, body.folder, body.keywords).await?;
let already = existed && state.ctx.db.subscription(user.id, &id).await?.is_some();
state.ctx.db.subscribe(user.id, &id).await?;
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
if !already {
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
}
scan_soon(&state, Some(id.clone())).await;
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
Ok(Json(serde_json::json!({ "id": id, "existing": already })))
}
/// Queues a scan, so a feed just added shows its items without anyone pressing Scan now.
@@ -1348,6 +1577,10 @@ struct FeedPatch {
#[serde(default, deserialize_with = "double_option")]
max_new_per_check: Option<Option<usize>>,
pinned: Option<bool>,
/// True plays the feed from DeadAir's copies, false keeps it out (of its group's too), null
/// follows its group again (#171).
#[serde(default, deserialize_with = "double_option")]
deadair: Option<Option<bool>>,
}
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
@@ -1385,14 +1618,13 @@ async fn patch_feed(
{
return Err(ApiError::not_found("you do not subscribe to that feed"));
}
if let Some(wanted) = body.deadair {
state.ctx.db.set_deadair(user.id, &id, wanted).await?;
state.ctx.deadair.notify_one();
}
// What one person wants -- which items, whether to fetch them, how many at a time --
// is theirs. It goes on their subscription and nobody else sees the change.
if state.ctx.db.subscription(user.id, &id).await?.is_some() {
let mut mine = state
.ctx
.db
.subscription(user.id, &id).await?
.unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() });
if let Some(mut mine) = state.ctx.db.subscription(user.id, &id).await? {
let mut touched = false;
if let Some(v) = body.keywords.clone() {
mine.keywords = Some(v.into_iter().filter(|k| !k.trim().is_empty()).collect());
@@ -1430,7 +1662,7 @@ async fn patch_feed(
"the feed's address, folder, schedule and category are the same for everyone, so only an admin changes them",
));
}
let mut cfg = (*state.ctx.cfg()).clone();
let mut cfg = state.ctx.edit_cfg().await;
// Derived feeds have no config entry. Editing one is the moment it earns a real
// entry: promote it, so the config holds your decisions and nothing else.
@@ -1491,30 +1723,7 @@ async fn remove_feed(
Path(id): Path<String>,
user: crate::db::User,
) -> Result<StatusCode, ApiError> {
// Unsubscribing is personal: it takes the feed off your list and leaves everyone
// else's alone.
state.ctx.db.unsubscribe(user.id, &id).await?;
for child in crate::subscriptions(&state.ctx).await?
.iter()
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
{
state.ctx.db.unsubscribe(user.id, &child.id).await?;
}
if state.ctx.db.subscriber_counts().await?.contains_key(&id) {
return Ok(StatusCode::NO_CONTENT);
}
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
// someone subscribes again they do not pull the back catalogue a second time.
let mut cfg = (*state.ctx.cfg()).clone();
if cfg.feeds.remove(&id).is_none() {
// A derived feed: forget it here, though the OPML will list it again on the next
// read unless you unsubscribe from the OPML itself.
state.ctx.db.drop_managed(&id).await?;
return Ok(StatusCode::NO_CONTENT);
}
state.ctx.store_cfg(cfg).await?;
crate::retire_group(&state.ctx, &id).await?;
crate::leave(&state.ctx, user.id, &id).await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -1548,12 +1757,14 @@ async fn set_flags(
async fn download_now(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
) -> Result<StatusCode, ApiError> {
let enc = state
.ctx
.db
.enclosure(id).await?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
.ok_or_else(|| ApiError::not_found(format!("no file {id}")))?;
subscribed(&state, &user, &enc.feed_id).await?;
if enc.path.is_some() {
return Ok(StatusCode::NO_CONTENT); // Already here.
}
@@ -1582,7 +1793,8 @@ async fn delete_file(
.ctx
.db
.enclosure(id).await?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
.ok_or_else(|| ApiError::not_found(format!("no file {id}")))?;
subscribed(&state, &user, &enc.feed_id).await?;
// There is one copy of the file: deleting it deletes everyone's. Say so before doing
// it, once, and let them decide.
@@ -1607,7 +1819,7 @@ async fn delete_file(
}
}
if let Some(path) = &enc.path
&& let Err(e) = std::fs::remove_file(path)
&& let Err(e) = crate::download::remove(std::path::Path::new(path))
&& e.kind() != std::io::ErrorKind::NotFound
{
return Err(e.into());
@@ -1687,7 +1899,10 @@ async fn events(
/// routine skip of a feed not due, dozens a minute that change nothing.
fn changed_feed(ev: &Event) -> Option<&str> {
match ev {
Event::FeedDone { feed, .. } | Event::FeedError { feed, .. } | Event::DownloadDone { feed, .. } => Some(feed),
Event::FeedDone { feed, .. }
| Event::FeedError { feed, .. }
| Event::FeedMoved { feed, .. }
| Event::DownloadDone { feed, .. } => Some(feed),
Event::FeedSkip { feed, reason } if !reason.starts_with("not due") => Some(feed),
_ => None,
}
@@ -1697,15 +1912,46 @@ fn changed_feed(ev: &Event) -> Option<&str> {
async fn media(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
req: Request,
) -> Response {
serve_media(state, id, user, req, false).await
}
/// DeadAir's copy of a file (#171), at an address of its own: a player that had part of one
/// version cached and asked the same address for more would stitch the two together.
async fn media_deadair(
State(state): State<WebState>,
Path(id): Path<i64>,
user: crate::db::User,
req: Request,
) -> Response {
serve_media(state, id, user, req, true).await
}
async fn serve_media(state: WebState, id: i64, user: crate::db::User, req: Request, copy: bool) -> Response {
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
};
let Some(path) = enc.path else {
if let Err(e) = subscribed(&state, &user, &enc.feed_id).await {
return e.into_response();
}
let Some(mut path) = enc.path else {
return (StatusCode::NOT_FOUND, "not downloaded").into_response();
};
match ServeFile::new(path).oneshot(req).await {
// Which version this person is playing, so the positions their player saves are read in its
// clock (`deadair::on_save`).
let key = (user.id, enc.feed_id.clone(), enc.guid.clone());
if copy {
let Some(c) = state.ctx.db.copies(&[id]).await.ok().and_then(|c| c.into_iter().next()) else {
return (StatusCode::NOT_FOUND, "no DeadAir copy").into_response();
};
path = c.path.clone();
state.playing.lock().unwrap().insert(key, Arc::new(c));
} else {
state.playing.lock().unwrap().remove(&key);
}
match ServeFile::new(crate::download::playable(std::path::Path::new(&path))).oneshot(req).await {
Ok(r) => r.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
@@ -1716,16 +1962,29 @@ struct ArtQuery {
u: String,
}
/// Artwork a feed names on plain http, fetched here for the https page. The browser upgrades an
/// http image on an https page to https, and a host with no https, such as The Secret Cabal's
/// CDN, then answers nothing (issue #90).
/// Artwork, from iPX: the page asks here for every image a feed or item names, and the first
/// time it is fetched from the publisher and kept on disk (`art`, up to `art_cache_mb`), so
/// later it is fast, the publisher is not asked on every visit, and it outlives the publisher's
/// server. Only an address some feed or item names as its artwork, and only an image up to
/// 20 MB, so the route cannot be pointed at anything else. It began as a way round http-only
/// artwork on the https page (#90).
async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Response {
const MAX: usize = 5 << 20;
if !q.u.starts_with("http://") || !state.ctx.db.names_image(&q.u).await.unwrap_or(false) {
// 5 MB turned away the Glass Cannon Network's covers, 7 MB PNGs (#169).
const MAX: usize = 20 << 20;
let web = q.u.starts_with("http://") || q.u.starts_with("https://");
if !web || !state.ctx.db.names_image(&q.u).await.unwrap_or(false) {
return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response();
}
let keep = state.ctx.cfg().general.art_cache_mb > 0;
let file = crate::art::path(&q.u);
if keep && let Some((kind, body)) = crate::art::read(&file) {
return art_response(kind, body);
}
if let Some(why) = crate::art::failed(&q.u) {
return (StatusCode::BAD_GATEWAY, why).into_response();
}
let got = async {
let mut r = state.ctx.client.get(&q.u).send().await?.error_for_status()?;
let mut r = state.ctx.client.get(&q.u).timeout(crate::feed::FEED_TIMEOUT).send().await?.error_for_status()?;
let kind = r.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
anyhow::ensure!(kind.starts_with("image/"), "not an image: {kind}");
let mut body = Vec::new();
@@ -1737,12 +1996,36 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
};
match got.await {
Ok((kind, body)) => {
([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "private, max-age=86400".into())], body).into_response()
if keep && let Err(e) = crate::art::write(&file, &kind, &body) {
tracing::debug!(error = %e, "could not keep the artwork");
}
art_response(kind, body)
}
Err(e) => {
let why = format!("{e:#}");
// The access line says only that it failed, not which image or why (#169).
tracing::warn!(url = %q.u, error = %why, "could not fetch artwork");
crate::art::fail(&q.u, &why);
(StatusCode::BAD_GATEWAY, why).into_response()
}
Err(e) => (StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(),
}
}
/// An image from someone else's server, served from iPX's own address: it must stay an image.
/// An SVG opened on its own would otherwise run its script as iPX's page, with its cookies.
fn art_response(kind: String, body: Vec<u8>) -> Response {
(
[
(header::CONTENT_TYPE, kind),
(header::CACHE_CONTROL, "private, max-age=2592000".into()),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".into()),
(header::CONTENT_SECURITY_POLICY, "default-src 'none'; style-src 'unsafe-inline'; sandbox".into()),
],
body,
)
.into_response()
}
#[derive(Deserialize)]
struct Position {
secs: i64,
@@ -1756,7 +2039,9 @@ async fn set_position(
user: crate::db::User,
Json(body): Json<Position>,
) -> Result<StatusCode, ApiError> {
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs, body.duration).await?;
let (secs, duration) =
crate::deadair::on_save(&state.ctx, &state.playing, user.id, &feed_id, &guid, body.secs, body.duration).await?;
state.ctx.db.set_position(user.id, &feed_id, &guid, secs, duration).await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -1804,8 +2089,10 @@ fn five() -> i64 {
async fn download_latest(
State(state): State<WebState>,
Path(id): Path<String>,
user: crate::db::User,
Json(body): Json<HowMany>,
) -> Result<Json<serde_json::Value>, ApiError> {
subscribed(&state, &user, &id).await?;
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100)).await?;
for enc in &ids {
state.ctx.db.requeue(*enc).await?;
@@ -1908,6 +2195,7 @@ struct Settings {
download_dir: String,
max_total_gb: f64,
max_age_days: u64,
art_cache_mb: u64,
}
async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
@@ -1920,6 +2208,7 @@ async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
download_dir: cfg.general.download_dir.display().to_string(),
max_total_gb: cfg.general.max_total_gb,
max_age_days: cfg.general.max_age_days,
art_cache_mb: cfg.general.art_cache_mb,
})
}
@@ -1930,6 +2219,7 @@ struct SettingsPatch {
media_types: Option<Vec<String>>,
max_total_gb: Option<f64>,
max_age_days: Option<u64>,
art_cache_mb: Option<u64>,
}
async fn patch_settings(
@@ -1940,7 +2230,7 @@ async fn patch_settings(
if !user.is_admin {
return Err(ApiError::forbidden("only an admin changes these settings"));
}
let mut cfg = (*state.ctx.cfg()).clone();
let mut cfg = state.ctx.edit_cfg().await;
if let Some(sched) = body.schedule {
let sched = sched.trim().to_owned();
if crate::config::parse_interval(&sched).is_none() {
@@ -1966,6 +2256,9 @@ async fn patch_settings(
if let Some(v) = body.max_age_days {
cfg.general.max_age_days = v;
}
if let Some(v) = body.art_cache_mb {
cfg.general.art_cache_mb = v;
}
state.ctx.store_cfg(cfg).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -66,6 +66,7 @@ function makeContext(html, script, { media = false } = {}) {
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(){},

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);
}

View File

@@ -49,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

@@ -157,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');
@@ -242,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.
@@ -251,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);
@@ -812,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 = {
@@ -833,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());
@@ -855,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();
@@ -904,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.
@@ -919,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');
@@ -956,6 +1052,37 @@ test('reading an item updates its feed\'s count without reloading the list', asy
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());
@@ -1140,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');
@@ -1233,7 +1396,7 @@ test('the favicon is the logo, square, from both pages', async ({ page }) => {
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', '/favicon-dark.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');
@@ -1493,3 +1656,150 @@ test('a refresh button turns while what it checks is being checked', async ({ pa
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 its shortened copy 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: no copy is made.
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();
// Made 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.8 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');
// Played from the copy, with the switch beside the speed pressed.
await row.locator('[data-a=play]').click();
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').getAttribute('src'))).toMatch(/\/deadair$/);
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>

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

@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<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">
@@ -13,7 +14,7 @@
<!-- The server sends this page, and its script, to admins only. -->
<header id="topbar">
<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 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

@@ -426,6 +426,7 @@
--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);
@@ -450,6 +451,7 @@
--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);
@@ -458,6 +460,10 @@
/* 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;
@@ -498,6 +504,9 @@ a{color:var(--accent)}
/* 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;
@@ -522,6 +531,10 @@ a{color:var(--accent)}
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}
@@ -566,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}
@@ -630,24 +650,102 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
/* 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{
@@ -676,7 +774,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
/* 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}
@@ -708,7 +806,11 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
#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)}
@@ -717,6 +819,9 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
.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}
@@ -733,7 +838,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
.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:hover{background:var(--raise)}
@@ -824,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}
@@ -837,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}
@@ -937,10 +1052,79 @@ 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}
.fdl+.ffield{margin-top:18px}
.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;
@@ -980,8 +1164,10 @@ body:has(#player.on) #status{padding-bottom:3px}
@media (max-width:820px){
#shell{grid-template-columns:1fr}
#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: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;gap:8px;
@@ -1018,8 +1204,8 @@ body:has(#player.on) #status{padding-bottom:3px}
#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}
#dpeek.end{display:flex;align-items:center;padding-left:28px;color:var(--faint);font-size:13px}
#dpeek.end p{max-width:4.5em;margin:0}
/* 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}
@@ -1043,9 +1229,22 @@ body:has(#player.on) #status{padding-bottom:3px}
#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. */
@@ -1101,6 +1300,9 @@ body:has(#player.on) #status{padding-bottom:3px}
.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}
@@ -1135,6 +1337,25 @@ body:has(#player.on) #status{padding-bottom:3px}
.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)}
/* 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%)}}
@@ -1148,6 +1369,8 @@ body:has(#player.on) #status{padding-bottom:3px}
: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}
@@ -1168,6 +1391,11 @@ body:has(#player.on) #status{padding-bottom:3px}
: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,
@@ -1244,7 +1472,7 @@ body:has(#player.on) #status{padding-bottom:3px}
: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{box-shadow:inset -1px 0 0 var(--glass-hi),var(--shadow)}
: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)}
@@ -1263,12 +1491,17 @@ body:has(#player.on) #status{padding-bottom:3px}
: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){

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<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">
@@ -14,7 +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"></span>
<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>
@@ -65,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

@@ -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.plus}</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); }
};
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. -->
@@ -239,12 +397,35 @@ async function prefsModal(){
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
one in its title or text is hidden from you and not downloaded for you. Each feed's
settings can add more.</span></div>
<div class="field 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>`);
$('#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{
@@ -255,75 +436,191 @@ async function prefsModal(){
};
}
/// 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>
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
<span class="hint">Comma separated words or phrases. An item with one in its title or text
is hidden from you and not downloaded for you, as well as those your Settings hide
everywhere.</span></div>`}
<div class="field"><label>Newest episodes to download</label>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank follows the global default (${globalMax}). Older episodes stay
listed to download by hand. <b>0 means every episode</b>, the whole back catalogue.</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>
<div class="ffield">${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>
${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); };
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:splitWords($('#skw').value),
max_new_per_check:max===''?null:Number(max),
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
// 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};
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,7 +1,19 @@
/* ---------------- 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 refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
@@ -23,6 +35,13 @@ function connect(){
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
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}"]`);
if(bar) bar.classList.remove('live');
@@ -48,7 +67,7 @@ function connect(){
// 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

@@ -28,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},
@@ -68,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');
@@ -130,8 +133,9 @@ function renderFeeds(){
// 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>`:'')+`</span>`+
`<div class="txt"><b>${f.pinned?`<span class="fpin" title="Pinned">${ICON.pinOn}</span>`:''}${esc(f.title||f.id)}</b><small>`+
(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`)+
@@ -140,6 +144,7 @@ function renderFeeds(){
`<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

@@ -95,7 +95,7 @@ document.addEventListener('touchmove', ev => {
}, {passive: false});
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on
/// to, a note that there are no more, or, going back to the list, a dimmer over the list. The
/// 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.
@@ -109,7 +109,7 @@ function beside(dx: number){
}
if(p.dataset.key !== kind + (e?.guid || '')){
p.dataset.key = kind + (e?.guid || ''); p.className = kind;
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : kind === 'end' ? '<p>No more items</p>' : '';
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : '';
}
return p;
}

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);
@@ -39,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));
@@ -65,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" 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>
@@ -136,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){
@@ -242,7 +268,7 @@ function showDetail(e){
syncTools(e);
if(!e){
dropTurned(S.sel);
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
box.innerHTML='';
if(files) files.innerHTML='<p class="empty">No files</p>';
return;
}
@@ -271,18 +297,20 @@ function detailHtml(e){
// 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'}"
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
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>`:''}
<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>`;
@@ -328,7 +356,7 @@ function encBox(x){
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
// through here, which would make ipx a fetch-anything relay.
const viewable = !isPlayable(x) && x.state !== 'pending';
const viewable = !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
@@ -346,13 +374,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(e.title||'', encId!=null ? enc.url : e.link, el);
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);

View File

@@ -77,7 +77,7 @@ function installNativePlayback(){
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 && e.title) || '', feedTitle:feedName(f),
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

View File

@@ -1,8 +1,12 @@
/* ---------------- 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 copy when the server says this person plays it (#171),
/// whose times the entry is already given in, else the file itself.
const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair`:`/media/${enc.id}`;
// 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,24 +24,26 @@ 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; }
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}`;
audio.src=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));
renderEntries();
@@ -45,8 +51,8 @@ function play(e,enc=e.enclosures.find(isPlayable)){
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};
@@ -110,6 +116,48 @@ $('#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)};
/// 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;
audio.src=player.src;
audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
if(was) audio.play().catch(()=>{});
return true;
}
$('#pclose').onclick=()=>{savePos();audio.pause();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()};
@@ -135,7 +183,7 @@ 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;
@@ -176,10 +224,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

@@ -38,6 +38,10 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){
: 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;

View File

@@ -21,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
@@ -30,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
@@ -57,6 +60,16 @@ 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`;
}
async function api(url: string, opts?: RequestInit): Promise<any>{
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
@@ -145,10 +158,11 @@ 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>`;
}
/// On the https page, the browser upgrades an http:// image to https, and a host that has no
/// https shows nothing (issue #90); ipx fetches those itself.
/// 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 location.protocol==='https:'&&/^http:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
}
function artHTML(url: string | null, name: string, cls?: string){
return url