80 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:50:15 +00:00
50c3b9db64 Between releases, the version says a release is in progress: 0.10.2-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:58:40 +00:00
42 changed files with 3769 additions and 429 deletions

View File

@@ -1,6 +1,6 @@
--- ---
name: ipx-prod-check name: ipx-prod-check
description: Look for problems in production ipx (the iPX container on Tower) from its logs in Loki and its traces in Tempo, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site. description: Look for problems in production ipx (the iPX container on Tower) from its logs in Loki, its traces in Tempo and its metrics in Prometheus, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site.
--- ---
# Checking production ipx # Checking production ipx
@@ -16,9 +16,15 @@ $Q logs '<LogQL>' [since] # lines, newest first (500 at most)
$Q metric '<LogQL metric>' [since] # $range becomes `since` $Q metric '<LogQL metric>' [since] # $range becomes `since`
$Q traces '<TraceQL>' [since] # slowest first $Q traces '<TraceQL>' [since] # slowest first
$Q trace <trace id> # one trace as a tree, with its log lines $Q trace <trace id> # one trace as a tree, with its log lines
$Q prom '<PromQL>' [since] # Prometheus, now; $range becomes `since`
``` ```
`since` is `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new. `since` is `30m`, `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
"Since the last deploy" is since the container started:
```sh
S=$(docker inspect iPX -f '{{.State.StartedAt}}'); echo $(( ($(date +%s) - $(date -d "$S" +%s)) / 60 + 1 ))m
```
## What the log carries ## What the log carries
@@ -35,12 +41,16 @@ lines had no trace id, the access log's time was `ms`, and each event was logged
| `ipx::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures | | `ipx::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::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving (`-> {...}`) and the healthcheck's answer |
| `ipx` | message, sometimes fields | start-up, shutdown, account and config messages | | `ipx` | message, sometimes fields | start-up, shutdown, account and config messages |
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection` and `player_error` as warnings; `play_start` (ms from play to sound) and `stall` (ms waiting mid-play) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
Events (`ev`): `feed_start`, `feed_done` (new, downloaded, failed, torrents), `feed_skip` (not due, Events (`ev`): `feed_start`, `feed_done` (new, downloaded, failed, torrents), `feed_skip` (not due,
routine), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address routine), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address
updated), `download_done` (bytes), `download_error` (msg, url), updated), `download_done` (bytes), `download_error` (msg, url),
`torrent_deferred`, `reaped`, `scan_done` (feeds checked), `reap_done`, `status` (feeds, pending, `torrent_deferred`, `reaped`, `scan_done` (feeds checked), `reap_done`, `status` (feeds, pending,
downloaded: the healthcheck's, every 30s), `error` (msg). downloaded: the healthcheck's, every 30s), `deadair_done` (feed, enclosure, `saved_s` at the
default pause), `error` (msg). DeadAir also logs `DeadAir found the silences` (`cuts`, `saved_s`,
`took_ms`) under `ipx::deadair`, and warns `DeadAir could not read a file` when ffmpeg fails on
one, which it then passes over until the next restart.
`error.type` is the HTTP status (`404`, `503`) or one of `dns`, `redirect_loop`, `timeout`, `tls`, `error.type` is the HTTP status (`404`, `503`) or one of `dns`, `redirect_loop`, `timeout`, `tls`,
`not_a_feed`, `site_message`, `connect`, `parse`, `other`; Loki's `| json` names it `error_type`. `not_a_feed`, `site_message`, `connect`, `parse`, `other`; Loki's `| json` names it `error_type`.
@@ -49,6 +59,35 @@ Filter on the text before `| json` where you can (`|= "\"ev\":\"feed_error\""`):
cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not JSON, and cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not JSON, and
`| json | __error__=""` drops them. `| json | __error__=""` drops them.
## What Prometheus carries
The daemon's own state between requests, which no log line or trace holds (#194): ipx serves
`/metrics` on `[web] metrics_bind` (production: `192.168.1.130:9187`, LAN only), on a thread and
port of its own, from memory alone, and Prometheus scrapes it every 15 s as `job="ipx"`. Kept
from 2026-10-11 15:15 UTC. All gauges:
| metric | what |
|---|---|
| `ipx_db_connections{state="in_use"\|"idle"}`, `ipx_db_connections_max` | the database pool: in use reaching the max means requests and scans wait for a connection |
| `ipx_worker_queue` | commands waiting for the sequential worker (scans, downloads, adds) |
| `ipx_event_listeners` | open pages and socket clients on the live-update stream |
| `ipx_torrent_slots_free` | of 2 |
| `ipx_deadair_playing` | episodes being played through DeadAir's playlist |
| `ipx_runtime_workers`, `ipx_runtime_tasks`, `ipx_runtime_queue` | tokio: threads, live tasks, tasks waiting for a thread |
| `ipx_process_resident_bytes`, `ipx_process_open_fds` | memory and open files and sockets |
| `ipx_uptime_seconds`, `ipx_build_info{version}` | since the daemon started; what runs |
| `ipx_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; the healthcheck passes while it is stuck |
| `ipx_jobs_total{kind}`, `ipx_job_seconds_total{kind}` | counters: jobs finished, and their seconds all told |
| `ipx_last_scan_age_seconds` | since a scan last finished; absent until one has since starting |
| `ipx_http_requests_in_flight`, `ipx_scan_feeds_fetching`, `ipx_downloads_active` | work in progress |
| `ipx_download_bytes_total` | counter; `rate()` is the download speed |
| `ipx_event_queue`, `ipx_events_missed_total` | live updates unread by some listener; counter of those dropped for one that fell behind |
| `ipx_deadair_backlog`, `ipx_deadair_busy_seconds` | files of shows with DeadAir on not yet looked at (episodes everyone heard through stay counted); how long it has been on its file |
| `ipx_feed_retries` | feeds waiting to retry a failed name lookup |
| `ipx_art_cache_bytes`, `ipx_art_cache_limit_bytes` | artwork kept, as of the last sweep, and the most kept: kept sits at the limit by design |
| `ipx_art_evicted_total`, `ipx_art_requests_total{result="hit"\|"miss"}` | counters: images the sweep threw out to stay under the limit, and artwork served from disk or fetched from the publisher; many evicted at every sweep with a falling hit rate means the limit is too small |
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
## The checks ## The checks
Run these, then read the lines behind whatever stands out. Most of the time is in the reading: Run these, then read the lines behind whatever stands out. Most of the time is in the reading:
@@ -103,6 +142,62 @@ a count says something happened, the lines and traces say why.
A web request over a second is worth a look; the trace shows whether the time is in the A web request over a second is worth a look; the trace shows whether the time is in the
handler or a scan it waited on. handler or a scan it waited on.
Steps are `step` spans named by `of` (#187): an item list's `entries_in`, `sanitize`,
`deadair`, `count_in`; the feed list's `catalogue`, `feed_list` and its queries; a `reap`'s
`missing_files`, `prune_abandoned_failures`, `reap_candidates`, `clean_directory` (once a day
since #193, so a reap is about a second), `trim_artwork`, with what it removed on the `reap`
span. DeadAir's work on a file is a `deadair` trace (feed, enclosure, cuts) with `probe`,
`silencedetect`, `frame_bytes`, `store_plans`: seconds each, more is a file ffmpeg struggles
with. A trace with no name in `$Q traces` started in the page (#190, #192): its root is the tap
in the browser, which sends no span, so it holds the requests that tap made (API calls, the
playlist and audio of a play, position saves, the page's own report) side by side.
6. **The daemon's state, from Prometheus.** Pressure and leaks that requests and traces only
hint at.
```
$Q prom 'up{job="ipx"}'
$Q prom 'max(max_over_time(ipx_db_connections{job="ipx",state="in_use"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_db_connections_max{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_worker_queue{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_runtime_queue{job="ipx"}[$range]))' 24h
$Q prom 'max(resets(ipx_uptime_seconds{job="ipx"}[$range]))' 24h
$Q prom 'ipx_process_resident_bytes{job="ipx"} - ipx_process_resident_bytes{job="ipx"} offset $range' 24h
$Q prom 'ipx_process_open_fds{job="ipx"} - ipx_process_open_fds{job="ipx"} offset $range' 24h
$Q prom 'ipx_runtime_tasks{job="ipx"} - ipx_runtime_tasks{job="ipx"} offset $range' 24h
$Q prom 'max(max_over_time(ipx_worker_job_seconds{job="ipx"}[$range])) by (kind)' 24h
$Q prom 'max(max_over_time(ipx_last_scan_age_seconds{job="ipx"}[$range]))' 24h
$Q prom 'sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_http_requests_in_flight{job="ipx"}[$range]))' 24h
$Q prom 'sum(increase(ipx_events_missed_total{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_deadair_busy_seconds{job="ipx"}[$range]))' 24h
```
`up` 0 means Prometheus cannot reach the port: the container is down or `metrics_bind` is
unset. Connections in use at the pool's max (20 on Postgres) means something is waiting for
one: find the slow requests or scan of that time in check 3 and 5. A worker queue that only
grows means downloads are not keeping up, or the worker is stuck on one job. A runtime queue
above 0 for more than a scrape or two means every thread is busy: something is blocking a
thread or burning one. Memory, open files or tasks only climbing across a day with no restart
is a leak (a file or socket not closed, a task never ending); a step after a deploy is not. A
reset of the uptime is a restart: match it with a deploy, or it is a crash (check 4).
The worker: one job over 30 minutes (`ipx_worker_job_seconds`), or a scan last finished over
15 minutes ago, is the worker stuck, which the healthcheck does not notice; its trace and the
log lines of that time say on what. A kind whose share of `ipx_job_seconds_total` jumps is
where the worker's time went (reap was most of it until #193). Web requests in flight near
the pool's max mean the pool is what they wait on. Live updates missed mean a page showed stale
counts until reloaded. DeadAir over a minute on one file is a file ffmpeg chokes on.
7. **What the page saw.** The browser's own errors and timings, which no server line shows.
```
$Q metric 'sum by (ev, msg) (count_over_time({container="iPX"} |= "\"target\":\"ipx::client\"" | json | __error__="" | level="WARN" [$range]))' 7d
$Q logs '{container="iPX"} |= "\"target\":\"ipx::client\"" | json | ev=~"play_start|stall" | line_format "{{.timestamp}} {{.user}} {{.ev}} {{.ms}}ms deadair={{.deadair}} {{.feed}}"' 24h
```
A `player_error` is playback that failed in front of someone: `hls.js: ...` is DeadAir's
playlist (its trace has the playlist and audio requests), `media error 4` a file the browser
cannot play. A `page_error` or `rejection` is a bug in the page; `src` and `line` point into
the minified `app.js`, so match the message to `web/src`. `play_start` over 2000 ms, or a
`stall`, is worth a trace: was it the server, Cloudflare, or the network.
Also check the container itself, since Loki cannot see a daemon that is not running: Also check the container itself, since Loki cannot see a daemon that is not running:
```sh ```sh

View File

@@ -1,10 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Ask production's Loki or Tempo a question, from anywhere that can run docker on Tower. """Ask production's Loki, Tempo or Prometheus a question, from anywhere that can run docker on Tower.
query.py logs '<LogQL log query>' [since] lines, newest first query.py logs '<LogQL log query>' [since] lines, newest first
query.py metric '<LogQL metric query>' [since] one value per series; $range is `since` query.py metric '<LogQL metric query>' [since] one value per series; $range is `since`
query.py traces '<TraceQL query>' [since] matching traces, slowest first query.py traces '<TraceQL query>' [since] matching traces, slowest first
query.py trace <trace id> one trace's spans, as a tree query.py trace <trace id> one trace's spans, as a tree
query.py prom '<PromQL query>' [since] one value per series, now; $range is `since`
`since` is 1h, 24h, 7d and the like (default 24h). Loki and Tempo publish no query port on the `since` is 1h, 24h, 7d and the like (default 24h). Loki and Tempo publish no query port on the
host, so each call runs a throwaway alpine container on the monitoring project's network. host, so each call runs a throwaway alpine container on the monitoring project's network.
@@ -53,8 +54,18 @@ def main():
traces = sorted(r.get("traces", []), key=lambda t: -t.get("durationMs", 0)) traces = sorted(r.get("traces", []), key=lambda t: -t.get("durationMs", 0))
for t in traces: for t in traces:
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(int(t["startTimeUnixNano"]) / 1e9)) when = time.strftime("%m-%d %H:%M:%S", time.gmtime(int(t["startTimeUnixNano"]) / 1e9))
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {t.get('rootTraceName', '')}") # No root name: its root span is a tap in the page, which the browser never sends.
name = t.get("rootTraceName") or "(from the page)"
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {name}")
print(f"-- {len(traces)} trace(s)", file=sys.stderr) print(f"-- {len(traces)} trace(s)", file=sys.stderr)
elif kind == "prom":
# ipx's /metrics (#194), scraped as job ipx; through the network like the others.
r = fetch(f"http://prometheus:9090/api/v1/query?query={enc(q.replace('$range', since))}&time={now}")
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
for s in rows:
labels = {k: v for k, v in s["metric"].items() if k not in ("job", "instance")}
print(f"{float(s['value'][1]):>14g} {json.dumps(labels) if labels else ''}")
print(f"-- {len(rows)} series", file=sys.stderr)
elif kind == "trace": elif kind == "trace":
r = fetch(f"http://tempo:3200/api/traces/{q}") r = fetch(f"http://tempo:3200/api/traces/{q}")
spans = [sp for b in r.get("batches", r.get("resourceSpans", [])) spans = [sp for b in r.get("batches", r.get("resourceSpans", []))

View File

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

View File

@@ -56,15 +56,15 @@ docker logs --tail 20 iPX
included. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has. included. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
**A build that fails with `429 Too Many Requests` on a base image** is Docker Hub rate-limiting **A build that fails with `429 Too Many Requests` on a base image** is Docker Hub rate-limiting
this host. There is no Docker Hub login here, and the build asks about `debian:bookworm-slim` and this host. There is no Docker Hub login here, and the build asks about `debian:trixie-slim` and
`rust:1-slim-bookworm` every time unless they are already stored locally. Pull them from Google's `rust:1-slim-trixie` every time unless they are already stored locally. Pull them from Google's
mirror and tag them; the build then uses the local copies without asking Docker Hub: mirror and tag them; the build then uses the local copies without asking Docker Hub:
```sh ```sh
docker pull mirror.gcr.io/library/debian:bookworm-slim docker pull mirror.gcr.io/library/debian:trixie-slim
docker tag mirror.gcr.io/library/debian:bookworm-slim debian:bookworm-slim docker tag mirror.gcr.io/library/debian:trixie-slim debian:trixie-slim
docker pull mirror.gcr.io/library/rust:1-slim-bookworm docker pull mirror.gcr.io/library/rust:1-slim-trixie
docker tag mirror.gcr.io/library/rust:1-slim-bookworm rust:1-slim-bookworm docker tag mirror.gcr.io/library/rust:1-slim-trixie rust:1-slim-trixie
``` ```
Run those again now and then, or the local copies go stale. Run those again now and then, or the local copies go stale.
@@ -228,8 +228,13 @@ say, such as what was wrong before or what it cost to find out, it goes in the c
body, where `git log` and `git blame` find it beside the change. (There was a long-form body, where `git log` and `git blame` find it beside the change. (There was a long-form
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.) `docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty Cutting a release: first read `[Unreleased]` against the last release, not line by line, and rewrite
`[Unreleased]` above it, set `version` in `Cargo.toml` to `X.Y.Z` (dropping `-dev`), tag the commit it as what changed between the two. Lines go in as the work happens, so they record the way there:
a feature added and then reworked in the same release is one line saying what it is now, under
Added; something added and taken out again before the release, or a fix for a bug no release ever
had, is not there at all; and "instead of" and "no longer" compare with the last release, not with
last week. Issue numbers stay, all of them, on the line that survives. Then rename `[Unreleased]` to
`## [X.Y.Z] - YYYY-MM-DD` and open a new empty `[Unreleased]` above it, set `version` in `Cargo.toml` to `X.Y.Z` (dropping `-dev`), tag the commit
`vX.Y.Z`, and update the compare links at the bottom of the changelog. Then, in the next commit, `vX.Y.Z`, and update the compare links at the bottom of the changelog. Then, in the next commit,
set `version` to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so set `version` to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
in the logo's tooltip instead of claiming to be the last release. The release that follows can in the logo's tooltip instead of claiming to be the last release. The release that follows can

2
Cargo.lock generated
View File

@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "0.10.1" version = "0.12.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx" name = "ipx"
version = "0.10.1" version = "0.12.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -26,7 +26,7 @@ sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlit
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151" serde_json = "1.0.151"
sha2 = "0.10.9" sha2 = "0.10.9"
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] } tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal", "process"] }
toml = "1.1.5" toml = "1.1.5"
tower = { version = "0.5.3", features = ["util"] } tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.7.1", features = ["fs"] } tower-http = { version = "0.7.1", features = ["fs"] }

View File

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

View File

@@ -19,6 +19,7 @@ services:
- ipx.env # relative: Arcane resolves it inside its own container - ipx.env # relative: Arcane resolves it inside its own container
ports: ports:
- "8099:8099" # web UI - "8099:8099" # web UI
- "192.168.1.130:9187:9187" # /metrics for Prometheus, LAN only (ipx#194)
- "6881:6881/tcp" # BitTorrent peers - "6881:6881/tcp" # BitTorrent peers
- "6881:6881/udp" # DHT - "6881:6881/udp" # DHT
volumes: volumes:

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 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 (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. 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 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. 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 6. Apply the merged policy (see [users.md](users.md)) and mark anything rejected as `skipped` with
@@ -90,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` **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`, **Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,
`download_error`, `torrent_deferred`, `reaped`, `reap_done`, `scan_done`, `status`, `error`. `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 `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 there. Commands run one at a time, in the order they arrive, except `status` and `reload`: the
straight away, so the Docker healthcheck is never left waiting behind a scan or a download, and 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. 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 Progress carries the enclosure id, without which a UI cannot tell one download from another and
@@ -128,7 +132,7 @@ else a `401`. A feed's items and files (its entries, `download-latest`, `/api/en
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning | | `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
| `POST /api/fetch` | | | `POST /api/fetch` | |
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML | | `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 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/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/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/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 | | `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |

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 list` | Subscriptions and their state |
| `ipx status` | Counts: feeds, pending, downloaded | | `ipx status` | Counts: feeds, pending, downloaded |
| `ipx fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL | | `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 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 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 reap [--dry-run]` | Run retention now |
| `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI | | `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI |
| `ipx daemon [--web ADDR]` | Scheduler, control socket and 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 ## Accounts
Passwords are read from **stdin**, so they miss the shell history and any `ps` listing. Passwords are read from **stdin**, so they miss the shell history and any `ps` listing.

View File

@@ -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 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`. 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]` ## `[general]`
```toml ```toml
@@ -48,7 +54,8 @@ database as described above; `download_dir`, `socket` and `organize` stay in con
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to * **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
be polled *less* often, and a per-feed `schedule` overrides both. A feed that keeps failing be polled *less* often, and a per-feed `schedule` overrides both. 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 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`. * **`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 * **`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 pad. Kept items are never deleted, and a file only counts as read once every subscriber has
@@ -96,6 +103,7 @@ access_aud = "" # the Access application's AUD tag
auto_create_users = true auto_create_users = true
sign_out_url = "" # e.g. "/cdn-cgi/access/logout" sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
session_days = 30 session_days = 30
metrics_bind = "" # e.g. "0.0.0.0:9187"; empty serves no metrics
``` ```
* **`token`** — the shared secret, which signs in as the **admin**. `?token=…` sets a cookie, so * **`token`** — the shared secret, which signs in as the **admin**. `?token=…` sets a cookie, so
@@ -112,6 +120,9 @@ session_days = 30
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where `/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
the proxy signs them straight back in. the proxy signs them straight back in.
* **`session_days`** — sign a session out after this long without a request. * **`session_days`** — sign a session out after this long without a request.
* **`metrics_bind`** — where to serve `/metrics` for Prometheus: database connections in use, the
worker's queue, the runtime's tasks, memory and open files. It has a thread and a port of its own
and no sign-in, so publish it to the LAN only. Empty, the default, serves none.
It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can
itself carry a credential. Put TLS in front of it if that matters. itself carry a credential. Put TLS in front of it if that matters.

View File

@@ -87,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 itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
they pay for. 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 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 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 subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or

View File

@@ -2,15 +2,17 @@
python3 grafana/dashboard.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/dashboards/ipx.json python3 grafana/dashboard.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/dashboards/ipx.json
Grafana reads that file on its own within a minute; edits made in Grafana are refused. The panels Grafana reads that file on its own within a minute; edits made in Grafana are refused. The
read the fields of ipx's JSON log (IPX_LOG_FORMAT=json): renaming a field in web.rs access_log or Daemon row reads ipx's /metrics from Prometheus (job ipx, #194); the rest read the fields of
ipc.rs log_event has to be matched here. `dashboard.py queries` prints each ipx's JSON log (IPX_LOG_FORMAT=json): renaming a field in web.rs access_log or ipc.rs log_event
has to be matched here. `dashboard.py queries` prints each
query, to try against Loki. query, to try against Loki.
""" """
import json, sys import json, sys
LOKI = {"type": "loki", "uid": "${loki}"} LOKI = {"type": "loki", "uid": "${loki}"}
TEMPO = {"type": "tempo", "uid": "${tempo}"} TEMPO = {"type": "tempo", "uid": "${tempo}"}
PROM = {"type": "prometheus", "uid": "${prometheus}"}
SEL = '{container="iPX"}' SEL = '{container="iPX"}'
# ipx logs one JSON object a line (IPX_LOG_FORMAT=json). Each scan and download event carries its # ipx logs one JSON object a line (IPX_LOG_FORMAT=json). Each scan and download event carries its
# fields (ev, feed, new, bytes, msg, error.type, ...); each request its method, path, route, status # fields (ev, feed, new, bytes, msg, error.type, ...); each request its method, path, route, status
@@ -50,6 +52,14 @@ def loki(expr, ref="A", legend=None, instant=False, kind=None):
return t return t
def prom(expr, ref="A", legend=None):
# ipx's /metrics (#194), scraped by Prometheus as job ipx.
t = {"refId": ref, "datasource": PROM, "expr": expr}
if legend:
t["legendFormat"] = legend
return t
def row(title): def row(title):
global y, pid global y, pid
pid += 1 pid += 1
@@ -135,6 +145,67 @@ table("Busiest routes", [loki(f'topk(15, sum by (method, route) (count_over_time
12, rename={"method": "Method", "route": "Route", "Value": "Requests"}, sort=[{"displayName": "Requests", "desc": True}]) 12, rename={"method": "Method", "route": "Route", "Value": "Requests"}, sort=[{"displayName": "Requests", "desc": True}])
y += 8 y += 8
# ---- Daemon, from Prometheus: what the daemon holds between requests (#194)
row("Daemon")
# Open connections stay open, idle, for ten minutes after a burst, so open at the max is not
# pressure; drawn as a line of its own it read as the pool maxing out (#199).
ts("Database connections", [prom('max(max_over_time(ipx_db_connections{job="ipx", state="in_use"}[$__interval]))', legend="in use"),
prom('sum(ipx_db_connections{job="ipx"})', ref="B", legend="open"),
prom('max(ipx_db_connections_max{job="ipx"})', ref="C", legend="most the pool opens")],
0, w=8, desc="In use reaching the most the pool opens means requests and scans are waiting for a connection. "
"Open connections stay open, idle, for ten minutes after a burst.")["fieldConfig"]["overrides"] = [
{"matcher": {"id": "byName", "options": "open"},
"properties": [{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [4, 4]}},
{"id": "custom.fillOpacity", "value": 0}, {"id": "color", "value": {"mode": "fixed", "fixedColor": "#8e8e8e"}}]}]
ts("Waiting", [prom('ipx_worker_queue{job="ipx"}', legend="commands for the worker"),
prom('ipx_runtime_queue{job="ipx"}', ref="B", legend="tasks for a thread"),
prom('ipx_downloads_pending{job="ipx"}', ref="C", legend="downloads")],
8, w=8, desc="Scans and downloads run one at a time; a queue that only grows means the worker is stuck.")
ts("Listeners and tasks", [prom('ipx_event_listeners{job="ipx"}', legend="live-update listeners"),
prom('ipx_runtime_tasks{job="ipx"}', ref="B", legend="runtime tasks"),
prom('ipx_deadair_playing{job="ipx"}', ref="C", legend="playing through DeadAir")],
16, w=8)
y += 8
ts("Memory", [prom('ipx_process_resident_bytes{job="ipx"}', legend="resident")], 0, w=12, unit="bytes")
ts("Open files and sockets", [prom('ipx_process_open_fds{job="ipx"}', legend="open")], 12, w=12)
y += 8
# Jobs and queues (#195)
ts("Worker", [prom('max by (kind) (ipx_worker_job_seconds{job="ipx"})', legend="on a {{kind}} for"),
prom('max(ipx_last_scan_age_seconds{job="ipx"})', ref="B", legend="since the last scan finished")],
0, w=8, unit="s", desc="A job's time that only climbs, or no scan for over 10 minutes, means the worker is stuck; the healthcheck still passes.")
ts("Jobs done", [prom('sum by (kind) (increase(ipx_jobs_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
8, w=8, bars=True, stack=True)
ts("Time on jobs", [prom('sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
16, w=8, unit="s", bars=True, stack=True, desc="Seconds the worker spent on each kind of job in each interval.")
y += 8
ts("In flight", [prom('max(ipx_http_requests_in_flight{job="ipx"})', legend="web requests"),
prom('max(ipx_scan_feeds_fetching{job="ipx"})', ref="B", legend="feeds fetching"),
prom('max(ipx_downloads_active{job="ipx"})', ref="C", legend="downloads"),
prom('2 - max(ipx_torrent_slots_free{job="ipx"})', ref="D", legend="torrents")],
0, w=8)
ts("Download speed", [prom('sum(rate(ipx_download_bytes_total{job="ipx"}[$__rate_interval]))', legend="downloading")],
8, w=8, unit="Bps")
ts("Backlogs", [prom('max(ipx_deadair_backlog{job="ipx"})', legend="DeadAir to look at"),
prom('max(ipx_feed_retries{job="ipx"})', ref="B", legend="feeds to retry a lookup"),
prom('max(ipx_event_queue{job="ipx"})', ref="C", legend="live updates unread"),
prom('sum(increase(ipx_events_missed_total{job="ipx"}[$__rate_interval]))', ref="D", legend="live updates missed")],
16, w=8)
y += 8
ts("DeadAir on a file", [prom('max(ipx_deadair_busy_seconds{job="ipx"})', legend="for")], 0, w=12, unit="s",
desc="How long DeadAir has been on the file it is analysing; a few seconds each.")
ts("Artwork", [prom('max(ipx_art_cache_bytes{job="ipx"}) / max(ipx_art_cache_limit_bytes{job="ipx"})', legend="full"),
prom('sum(increase(ipx_art_requests_total{job="ipx", result="hit"}[$__rate_interval])) / '
'sum(increase(ipx_art_requests_total{job="ipx"}[$__rate_interval]))', ref="B", legend="hit rate"),
prom('sum(increase(ipx_art_evicted_total{job="ipx"}[$__rate_interval]))', ref="C", legend="images evicted")],
12, w=12, unit="percentunit",
desc="Full sits near 100% once the cache has filled, since each sweep trims it to fit. Hit rate is the share served from disk: "
"the rest was fetched from the publisher. Evicted, on the right, is images each sweep threw out to stay under the "
"limit: many at every sweep, with a falling hit rate, means the limit is too small.")["fieldConfig"]["overrides"] = [
{"matcher": {"id": "byName", "options": "images evicted"},
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"},
{"id": "custom.drawStyle", "value": "bars"}, {"id": "custom.fillOpacity", "value": 60}]}]
y += 8
# ---- Traces # ---- Traces
row("Traces") row("Traces")
for x, title, q in [(0, "Recent traces", TRACES + "}"), for x, title, q in [(0, "Recent traces", TRACES + "}"),
@@ -162,6 +233,7 @@ dash = {
"templating": {"list": [ "templating": {"list": [
{"name": "loki", "label": "Logs", "type": "datasource", "query": "loki", "current": {}, "hide": 0}, {"name": "loki", "label": "Logs", "type": "datasource", "query": "loki", "current": {}, "hide": 0},
{"name": "tempo", "label": "Traces", "type": "datasource", "query": "tempo", "current": {}, "hide": 0}, {"name": "tempo", "label": "Traces", "type": "datasource", "query": "tempo", "current": {}, "hide": 0},
{"name": "prometheus", "label": "Metrics", "type": "datasource", "query": "prometheus", "current": {}, "hide": 0},
]}, ]},
"links": [{"title": "iPX", "type": "link", "url": "https://ipodderx.sdf1.net", "targetBlank": True}], "links": [{"title": "iPX", "type": "link", "url": "https://ipodderx.sdf1.net", "targetBlank": True}],
"panels": panels, "panels": panels,

9
package-lock.json generated
View File

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

View File

@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@swc/core": "^1.16.2", "@swc/core": "^1.16.2",
"@swc/html": "^1.16.2" "@swc/html": "^1.16.2",
"hls.js": "1.6.19"
} }
} }

View File

@@ -2,6 +2,9 @@
//! of each publisher's server for every visit, and still there when a publisher is not. //! of each publisher's server for every visit, and still there when a publisher is not.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
pub fn dir() -> PathBuf { pub fn dir() -> PathBuf {
crate::config::data_dir().join("art") crate::config::data_dir().join("art")
@@ -38,6 +41,36 @@ pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> {
std::fs::rename(&tmp, file) 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. /// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it.
/// Returns how many went. /// Returns how many went.
pub fn trim(dir: &Path, limit: u64) -> usize { pub fn trim(dir: &Path, limit: u64) -> usize {
@@ -64,6 +97,8 @@ pub fn trim(dir: &Path, limit: u64) -> usize {
gone += 1; gone += 1;
} }
} }
crate::metrics::STATS.art_cache_bytes.store(total, std::sync::atomic::Ordering::Relaxed);
crate::metrics::STATS.art_evicted.fetch_add(gone as u64, std::sync::atomic::Ordering::Relaxed);
gone gone
} }
@@ -89,4 +124,15 @@ mod tests {
assert_eq!(trim(&d, 0), 2); assert_eq!(trim(&d, 0), 2);
let _ = std::fs::remove_dir_all(&d); 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

@@ -97,6 +97,9 @@ pub struct Web {
pub sign_out_url: String, pub sign_out_url: String,
/// Sign a session out after this long without a request. /// Sign a session out after this long without a request.
pub session_days: i64, pub session_days: i64,
/// Where to serve /metrics for Prometheus (#194), on a thread of its own, without sign-in:
/// `0.0.0.0:9187`. Empty serves none.
pub metrics_bind: String,
} }
impl Default for Web { impl Default for Web {
@@ -112,6 +115,7 @@ impl Default for Web {
auto_create_users: true, auto_create_users: true,
sign_out_url: String::new(), sign_out_url: String::new(),
session_days: 30, session_days: 30,
metrics_bind: String::new(),
} }
} }
} }

501
src/db.rs
View File

@@ -2,7 +2,7 @@
//! per-feed .ipxd plists, history.dat and qmcache.dat. //! per-feed .ipxd plists, history.dat and qmcache.dat.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crate::entity::{api_tokens, blocklists, catalogue, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users}; use crate::entity::{api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users};
use sea_orm::sea_query::{Expr, Func}; use sea_orm::sea_query::{Expr, Func};
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
@@ -60,12 +60,23 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
schema.create_table_from_entity(blocklists::Entity), schema.create_table_from_entity(blocklists::Entity),
schema.create_table_from_entity(hidden::Entity), schema.create_table_from_entity(hidden::Entity),
schema.create_table_from_entity(api_tokens::Entity), schema.create_table_from_entity(api_tokens::Entity),
schema.create_table_from_entity(deadair_plans::Entity),
schema.create_table_from_entity(deadair_prefs::Entity),
schema.create_table_from_entity(deadair_saved::Entity),
] { ] {
orm.execute(table.if_not_exists()).await.context("creating the schema")?; orm.execute(table.if_not_exists()).await.context("creating the schema")?;
} }
for sql in [ for sql in [
"CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid)", "CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid)",
// The few rows with a file on disk, 15 of the server's many thousand: counting a person's
// files through enclosures_entry read every file row of their feeds, 58 ms a feed list
// (#165). Also what the healthcheck's count and the Downloaded filter look for.
"CREATE INDEX IF NOT EXISTS enclosures_on_disk ON enclosures (feed_id, guid) WHERE path IS NOT NULL",
"CREATE UNIQUE INDEX IF NOT EXISTS users_name_lower ON users (lower(name))", "CREATE UNIQUE INDEX IF NOT EXISTS users_name_lower ON users (lower(name))",
// Every key and date an item list sorts by, so `entries_where` pages from the index alone
// and visits the table only for the rows it shows. Sorting All Subscriptions from the
// table read all 13,700 of its rows, show notes and all, half a second to two cold (#188).
"CREATE INDEX IF NOT EXISTS entries_by_time ON entries (feed_id, guid, published, first_seen)",
] { ] {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
} }
@@ -84,6 +95,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
} }
} }
if orm.execute_unprepared("SELECT pause_ms FROM deadair_prefs LIMIT 0").await.is_err() {
let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
Ok(()) Ok(())
} }
@@ -99,6 +114,24 @@ pub fn location() -> String {
/// The Postgres connection option that keeps notices from being sent at all; see `connect`. /// The Postgres connection option that keeps notices from being sent at all; see `connect`.
const QUIET: &str = "options=-c%20client_min_messages%3Dwarning"; const QUIET: &str = "options=-c%20client_min_messages%3Dwarning";
impl Db {
/// The connection pool as it stands, for /metrics (#194): connections open, those of them
/// idle, and the most it will open.
pub fn pool(&self) -> (u32, usize, u32) {
use sea_orm::DbBackend;
match self.orm.get_database_backend() {
DbBackend::Postgres => {
let p = self.orm.get_postgres_connection_pool();
(p.size(), p.num_idle(), p.options().get_max_connections())
}
_ => {
let p = self.orm.get_sqlite_connection_pool();
(p.size(), p.num_idle(), p.options().get_max_connections())
}
}
}
}
/// A URL fit for a log or an error: the password taken out. /// A URL fit for a log or an error: the password taken out.
fn redact(url: &str) -> String { fn redact(url: &str) -> String {
match (url.find("://"), url.rfind('@')) { match (url.find("://"), url.rfind('@')) {
@@ -136,10 +169,9 @@ async fn connect(location: &str) -> Result<sea_orm::DatabaseConnection> {
// line for it at 50 requests a second (#136). In WAL readers run beside the one writer. Each // line for it at 50 requests a second (#136). In WAL readers run beside the one writer. Each
// transaction here writes first, so it takes the write lock or waits out the busy timeout for // 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 that read a snapshot a write has since moved // it; SQLite refuses at once only a transaction that read a snapshot a write has since moved
// past. Postgres keeps sqlx's ten. // past. Postgres gets twenty, where sqlx's ten filled in bursts: a page load asks for dozens
if !is_postgres(location) { // of covers at once, each with a lookup (#200). The server allows 100, shared with other apps.
opts.max_connections(8); opts.max_connections(if is_postgres(location) { 20 } else { 8 });
}
sea_orm::Database::connect(opts) sea_orm::Database::connect(opts)
.await .await
.with_context(|| format!("opening {}", redact(location))) .with_context(|| format!("opening {}", redact(location)))
@@ -194,6 +226,8 @@ impl From<enclosures::Model> for EncRow {
fn from(e: enclosures::Model) -> Self { fn from(e: enclosures::Model) -> Self {
EncRow { EncRow {
id: e.id, id: e.id,
deadair: None,
deadair_pending: false,
feed_id: e.feed_id, feed_id: e.feed_id,
guid: e.guid, guid: e.guid,
url: e.url, url: e.url,
@@ -295,6 +329,13 @@ impl Db {
/// A SQLite file of its own (two connections to one ":memory:" would be two databases); or, /// A SQLite file of its own (two connections to one ":memory:" would be two databases); or,
/// with IPX_TEST_DATABASE_URL set, a Postgres schema of its own on that database, so the /// with IPX_TEST_DATABASE_URL set, a Postgres schema of its own on that database, so the
/// same tests prove the SQL on both. /// same tests prove the SQL on both.
/// Runs a statement as it is, for a test outside this module setting up what it needs.
#[cfg(test)]
pub async fn sql(&self, sql: &str) -> Result<()> {
self.orm.execute_unprepared(sql).await?;
Ok(())
}
#[cfg(test)] #[cfg(test)]
pub async fn memory() -> Result<Self> { pub async fn memory() -> Result<Self> {
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
@@ -390,26 +431,35 @@ impl Db {
) -> Result<std::collections::HashMap<String, FeedListing>> { ) -> Result<std::collections::HashMap<String, FeedListing>> {
use std::collections::HashMap; use std::collections::HashMap;
let one = || sea_orm::Value::from(only.map(str::to_owned)); let one = || sea_orm::Value::from(only.map(str::to_owned));
let counts = |sql: &'static str, args: Vec<sea_orm::Value>| async move { // A step a query: the feed list's trace was 290 ms with nothing under it.
self.rows(sql, args) let counts = |of: &'static str, sql: &'static str, args: Vec<sea_orm::Value>| async move {
tracing::Instrument::instrument(self.rows(sql, args), step(of))
.await? .await?
.iter() .iter()
.map(|r| Ok((r.try_get::<String>("", "feed_id")?, r.try_get::<i64>("", "n")?))) .map(|r| Ok((r.try_get::<String>("", "feed_id")?, r.try_get::<i64>("", "n")?)))
.collect::<Result<HashMap<_, _>>>() .collect::<Result<HashMap<_, _>>>()
}; };
// Of this person's feeds only, as the unread count is: counting every item of every feed on
// the server, 1534 feeds for someone reading 146, was 184-316 ms, 70% of the feed list's
// time (#165). The Directory reads this for every feed's title and artwork, not a count.
let entries = counts( let entries = counts(
"SELECT feed_id, count(*) AS n FROM entries "entries",
WHERE (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id", "SELECT e.feed_id, count(*) AS n FROM entries e
vec![one()], JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id
WHERE (CAST($2 AS TEXT) IS NULL OR e.feed_id = $2) GROUP BY e.feed_id",
vec![user_id.into(), one()],
) )
.await?; .await?;
let downloaded = counts( let downloaded = counts(
"SELECT feed_id, count(*) AS n FROM enclosures "downloaded",
WHERE path IS NOT NULL AND (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id", "SELECT x.feed_id, count(*) AS n FROM enclosures x
vec![one()], JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = x.feed_id
WHERE x.path IS NOT NULL AND (CAST($2 AS TEXT) IS NULL OR x.feed_id = $2) GROUP BY x.feed_id",
vec![user_id.into(), one()],
) )
.await?; .await?;
let unread = counts( let unread = counts(
"unread",
"SELECT e.feed_id, count(*) AS n FROM entries e "SELECT e.feed_id, count(*) AS n FROM entries e
JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id
LEFT JOIN entry_state s LEFT JOIN entry_state s
@@ -422,9 +472,10 @@ impl Db {
vec![user_id.into(), one()], vec![user_id.into(), one()],
) )
.await?; .await?;
let mut blocked: HashMap<String, Vec<String>> = blocklists::Entity::find() let mut blocked: HashMap<String, Vec<String>> = tracing::Instrument::instrument(
.filter(blocklists::Column::UserId.eq(user_id)) blocklists::Entity::find().filter(blocklists::Column::UserId.eq(user_id)).all(&self.orm),
.all(&self.orm) step("blocked"),
)
.await? .await?
.into_iter() .into_iter()
.map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default())) .map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
@@ -433,8 +484,7 @@ impl Db {
if let Some(id) = only { if let Some(id) = only {
rows = rows.filter(feeds::Column::Id.eq(id)); rows = rows.filter(feeds::Column::Id.eq(id));
} }
Ok(rows Ok(tracing::Instrument::instrument(rows.all(&self.orm), step("feeds"))
.all(&self.orm)
.await? .await?
.into_iter() .into_iter()
.map(|f| { .map(|f| {
@@ -782,7 +832,9 @@ impl Db {
/// One file serves every subscriber, so both tests are about all of them: **anyone** /// One file serves every subscriber, so both tests are about all of them: **anyone**
/// starring it keeps it, and it only counts as read when **everyone** subscribed has /// starring it keeps it, and it only counts as read when **everyone** subscribed has
/// read it. A file whose feed nobody subscribes to has no one left to keep it, so it /// read it. A file whose feed nobody subscribes to has no one left to keep it, so it
/// sorts with the read ones. /// sorts with the read ones. Only the marks of people who subscribe count: unsubscribing
/// leaves yours behind, and counted, a reader who had left made an episode someone had not
/// played look finished, and a pin of theirs kept a file for good (#156).
/// ///
/// (The Python intended `read = 1 AND flagged = 0` but never achieved it -- a missing /// (The Python intended `read = 1 AND flagged = 0` but never achieved it -- a missing
/// plistlib import and an `EntreiesData` typo meant the filter always threw.) /// plistlib import and an `EntreiesData` typo meant the filter always threw.)
@@ -796,13 +848,15 @@ impl Db {
LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs
ON subs.feed_id = e.feed_id ON subs.feed_id = e.feed_id
-- Someone an item is hidden from is as done with it as someone who read it. -- Someone an item is hidden from is as done with it as someone who read it.
LEFT JOIN (SELECT feed_id, guid, count(*) AS n LEFT JOIN (SELECT done.feed_id, done.guid, count(*) AS n
FROM (SELECT user_id, feed_id, guid FROM entry_state WHERE read FROM (SELECT user_id, feed_id, guid FROM entry_state WHERE read
UNION SELECT user_id, feed_id, guid FROM hidden) done UNION SELECT user_id, feed_id, guid FROM hidden) done
GROUP BY feed_id, guid) readers JOIN subscriptions u ON u.user_id = done.user_id AND u.feed_id = done.feed_id
GROUP BY done.feed_id, done.guid) readers
ON readers.feed_id = e.feed_id AND readers.guid = e.guid ON readers.feed_id = e.feed_id AND readers.guid = e.guid
WHERE e.path IS NOT NULL WHERE e.path IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM entry_state s AND NOT EXISTS (SELECT 1 FROM entry_state s
JOIN subscriptions u ON u.user_id = s.user_id AND u.feed_id = s.feed_id
WHERE s.feed_id = e.feed_id AND s.guid = e.guid AND s.flagged) WHERE s.feed_id = e.feed_id AND s.guid = e.guid AND s.flagged)
ORDER BY 6 DESC, coalesce(e.downloaded_at, 0) ASC, e.id ASC", ORDER BY 6 DESC, coalesce(e.downloaded_at, 0) ASC, e.id ASC",
vec![], vec![],
@@ -941,9 +995,10 @@ impl Db {
SELECT 1 FROM enclosures e SELECT 1 FROM enclosures e
WHERE e.feed_id = entries.feed_id AND e.guid = entries.guid WHERE e.feed_id = entries.feed_id AND e.guid = entries.guid
AND e.path IS NOT NULL) AND e.path IS NOT NULL)
-- Starred by anyone keeps it, the same rule the reaper follows. -- Starred by anyone subscribed keeps it, the same rule the reaper follows.
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM entry_state s SELECT 1 FROM entry_state s
JOIN subscriptions u ON u.user_id = s.user_id AND u.feed_id = s.feed_id
WHERE s.feed_id = entries.feed_id AND s.guid = entries.guid WHERE s.feed_id = entries.feed_id AND s.guid = entries.guid
AND s.flagged)", AND s.flagged)",
vec![older_than.into()], vec![older_than.into()],
@@ -1032,12 +1087,22 @@ impl Args {
/// The FROM and WHERE that `entries_in` and `count_in` share: whose read state, which feeds /// The FROM and WHERE that `entries_in` and `count_in` share: whose read state, which feeds
/// (one, or every feed the person subscribes to), the filter, and a case-insensitive search of /// (one, or every feed the person subscribes to), the filter, and a case-insensitive search of
/// title and description. /// title and description.
fn entries_from(a: &mut Args, user_id: i64, feed_id: Option<&str>, filter: Filter, search: Option<&str>) -> String { fn entries_from(
a: &mut Args,
user_id: i64,
feed_id: Option<&str>,
filter: Filter,
search: Option<&str>,
guid: Option<&str>,
) -> String {
let user = a.p(user_id); let user = a.p(user_id);
let scope = match feed_id { let mut scope = match feed_id {
Some(f) => format!("e.feed_id = {}", a.p(f)), Some(f) => format!("e.feed_id = {}", a.p(f)),
None => format!("e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = {user})"), None => format!("e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = {user})"),
}; };
if let Some(g) = guid {
scope = format!("{scope} AND e.guid = {}", a.p(g));
}
let search = match search.map(|q| q.trim().to_lowercase()).filter(|q| !q.is_empty()) { let search = match search.map(|q| q.trim().to_lowercase()).filter(|q| !q.is_empty()) {
Some(q) => { Some(q) => {
let like = a.p(format!("%{q}%")); let like = a.p(format!("%{q}%"));
@@ -1141,6 +1206,14 @@ impl Filter {
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
pub struct EncRow { pub struct EncRow {
pub id: i64, pub id: i64,
/// Seconds DeadAir's copy of this file saves, when the person asking plays it from one
/// (`deadair::for_reader`). None from the database.
#[serde(skip_serializing_if = "Option::is_none")]
pub deadair: Option<i64>,
/// DeadAir is on for this person's show and the copy is still being made: the page says to
/// wait rather than leave them guessing (`deadair::for_reader`).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub deadair_pending: bool,
pub feed_id: String, pub feed_id: String,
pub guid: String, pub guid: String,
pub url: String, pub url: String,
@@ -1165,19 +1238,44 @@ impl Db {
offset: i64, offset: i64,
limit: i64, limit: i64,
order: &str, order: &str,
) -> Result<Vec<EntryRow>> {
self.entries_where(user_id, feed_id, filter, search, None, offset, limit, order).await
}
/// One entry as `entries_in` gives it: what the player asks for again when the clock it
/// plays in changes (DeadAir, #171).
pub async fn entry(&self, user_id: i64, feed_id: &str, guid: &str) -> Result<Option<EntryRow>> {
let order = order_sql("published", "desc");
Ok(self.entries_where(user_id, Some(feed_id), Filter::All, None, Some(guid), 0, 1, &order).await?.pop())
}
#[allow(clippy::too_many_arguments)]
async fn entries_where(
&self,
user_id: i64,
feed_id: Option<&str>,
filter: Filter,
search: Option<&str>,
guid: Option<&str>,
offset: i64,
limit: i64,
order: &str,
) -> Result<Vec<EntryRow>> { ) -> Result<Vec<EntryRow>> {
let mut a = Args::default(); let mut a = Args::default();
let from = entries_from(&mut a, user_id, feed_id, filter, search); let from = entries_from(&mut a, user_id, feed_id, filter, search, guid);
// The page's keys first, then its rows: the sort then needs only `entries_by_time`, not
// every item's row, and the show notes are read for the page alone (#188).
let (limit, offset) = (a.p(limit), a.p(offset));
let user = a.p(user_id);
let sql = format!( let sql = format!(
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description, "SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description,
coalesce(s.read, false) AS read, coalesce(s.flagged, false) AS flagged, e.image, coalesce(s.read, false) AS read, coalesce(s.flagged, false) AS flagged, e.image,
coalesce(s.duration, e.duration) AS duration, coalesce(s.duration, e.duration) AS duration,
e.episode, e.season, coalesce(s.position, 0) AS position e.episode, e.season, coalesce(s.position, 0) AS position
{from} FROM (SELECT e.feed_id, e.guid {from} ORDER BY {order} LIMIT {limit} OFFSET {offset}) p
ORDER BY {order} JOIN entries e ON e.feed_id = p.feed_id AND e.guid = p.guid
LIMIT {} OFFSET {}", LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid
a.p(limit), ORDER BY {order}"
a.p(offset)
); );
let mut rows = self let mut rows = self
.rows(&sql, a.0) .rows(&sql, a.0)
@@ -1230,7 +1328,7 @@ impl Db {
search: Option<&str>, search: Option<&str>,
) -> Result<i64> { ) -> Result<i64> {
let mut a = Args::default(); let mut a = Args::default();
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search)); let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search, None));
let r = self.rows(&sql, a.0).await?; let r = self.rows(&sql, a.0).await?;
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?) Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
} }
@@ -2059,6 +2157,64 @@ impl Db {
)) ))
} }
/// Of these items, the stored ones the feed now tells differently: a title, text, artwork,
/// length or number that is not what is stored, a field the feed leaves out counting as
/// unchanged, as record_entry writes them. A scan inserted only items it had not stored
/// (#96), and with that alone a publisher's correction never arrived: Ain't It Cool News's
/// items kept a broken copy, each with the text of the one before it, after the feed was
/// fixed (#141). Only what the feed lists is read back, so a scan reads as much as it was
/// sent, not the archive.
#[tracing::instrument(skip_all)]
pub async fn changed_items(
&self,
feed_id: &str,
entries: &[crate::feed::Entry],
) -> Result<std::collections::HashSet<String>> {
if entries.is_empty() {
return Ok(Default::default());
}
let mut a = Args::default();
let feed = a.p(feed_id);
let guids: Vec<String> = entries.iter().map(|e| a.p(e.guid.clone())).collect();
let sql = format!(
"SELECT guid, title, description, image, duration, episode, season FROM entries
WHERE feed_id = {feed} AND guid IN ({})",
guids.join(", ")
);
type Stored = (Option<String>, Option<String>, Option<String>, Option<i64>, Option<i64>, Option<i64>);
let mut stored = std::collections::HashMap::<String, Stored>::new();
for r in self.rows(&sql, a.0).await? {
stored.insert(
r.try_get("", "guid")?,
(
r.try_get("", "title")?,
r.try_get("", "description")?,
r.try_get("", "image")?,
r.try_get("", "duration")?,
r.try_get("", "episode")?,
r.try_get("", "season")?,
),
);
}
fn differs<T: PartialEq>(new: &Option<T>, old: &Option<T>) -> bool {
new.is_some() && new != old
}
Ok(entries
.iter()
.filter(|e| {
stored.get(&e.guid).is_some_and(|s| {
differs(&e.title, &s.0)
|| differs(&e.description, &s.1)
|| differs(&e.image, &s.2)
|| differs(&e.duration, &s.3)
|| differs(&e.episode, &s.4)
|| differs(&e.season, &s.5)
})
})
.map(|e| e.guid.clone())
.collect())
}
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub async fn skipped_by_filter(&self, feed_id: &str) -> Result<std::collections::HashMap<String, String>> { pub async fn skipped_by_filter(&self, feed_id: &str) -> Result<std::collections::HashMap<String, String>> {
self.rows( self.rows(
@@ -2110,6 +2266,200 @@ pub enum EntryFlag {
Flagged, Flagged,
} }
/// A span for one step of a request or a scan, and none outside one: the healthcheck and the page's
/// live updates run the same code, and every call there would be a trace of its own.
pub fn step(of: &'static str) -> tracing::Span {
if tracing::Span::current().is_none() { tracing::Span::none() } else { tracing::info_span!("step", of) }
}
/// A file on disk with no DeadAir copy made since it was downloaded (issue #171).
#[derive(Debug, Clone)]
pub struct DeadAirTodo {
pub id: i64,
pub feed_id: String,
pub guid: String,
pub path: String,
}
impl Db {
/// DeadAir settings as (person, feed, wanted, pause in ms), everyone's or one person's. An
/// empty feed is the switch beside the speed.
pub async fn deadair_prefs(&self, user_id: Option<i64>) -> Result<Vec<(i64, String, bool, Option<i64>)>> {
let mut a = Args::default();
let only = match user_id {
Some(u) => format!("WHERE user_id = {}", a.p(u)),
None => String::new(),
};
self.rows(&format!("SELECT user_id, feed_id, wanted, pause_ms FROM deadair_prefs {only}"), a.0)
.await?
.iter()
.map(|r| {
Ok((r.try_get("", "user_id")?, r.try_get("", "feed_id")?, r.try_get("", "wanted")?, r.try_get("", "pause_ms")?))
})
.collect()
}
/// Sets one DeadAir setting, or with None takes it away: for a feed, that is following the
/// switch (or the feed's group) again.
pub async fn set_deadair(&self, user_id: i64, feed_id: &str, wanted: Option<bool>) -> Result<()> {
match wanted {
None => {
deadair_prefs::Entity::delete_by_id((user_id, feed_id.to_owned())).exec(&self.orm).await?;
}
Some(w) => {
self.exec(
"INSERT INTO deadair_prefs (user_id, feed_id, wanted) VALUES ($1, $2, $3)
ON CONFLICT (user_id, feed_id) DO UPDATE SET wanted = excluded.wanted",
vec![user_id.into(), feed_id.into(), w.into()],
)
.await?;
}
}
Ok(())
}
/// Sets the pause DeadAir leaves in a feed for someone (#182). Only beside a setting of the
/// switch: inserting one here would turn a feed's group's DeadAir off for it.
pub async fn set_deadair_pause(&self, user_id: i64, feed_id: &str, pause_ms: i64) -> Result<()> {
self.exec(
"UPDATE deadair_prefs SET pause_ms = $1 WHERE user_id = $2 AND feed_id = $3",
vec![pause_ms.into(), user_id.into(), feed_id.into()],
)
.await?;
Ok(())
}
/// Files on disk DeadAir has not looked at since they were downloaded, oldest first.
pub async fn deadair_todo(&self) -> Result<Vec<DeadAirTodo>> {
self.rows(
"SELECT e.id, e.feed_id, e.guid, e.path FROM enclosures e
LEFT JOIN deadair_plans d ON d.enclosure_id = e.id
WHERE e.path IS NOT NULL AND e.state = 'done'
AND (d.enclosure_id IS NULL OR d.made_at < coalesce(e.downloaded_at, 0))
ORDER BY e.id",
vec![],
)
.await?
.iter()
.map(|r| {
Ok(DeadAirTodo {
id: r.try_get("", "id")?,
feed_id: r.try_get("", "feed_id")?,
guid: r.try_get("", "guid")?,
path: r.try_get("", "path")?,
})
})
.collect()
}
/// Whether any of these people subscribes to the feed.
pub async fn subscribed_any(&self, users: &[i64], feed_id: &str) -> Result<bool> {
if users.is_empty() {
return Ok(false);
}
let mut a = Args::default();
let feed = a.p(feed_id);
let ids: Vec<String> = users.iter().map(|&u| a.p(u)).collect();
let sql = format!(
"SELECT 1 AS one FROM subscriptions WHERE feed_id = {feed} AND user_id IN ({}) LIMIT 1",
ids.join(", ")
);
Ok(!self.rows(&sql, a.0).await?.is_empty())
}
/// Who has heard an episode through: their place at 90% of its length or past it, where the
/// page calls it played. Not the read mark, which opening an item sets too: going by it,
/// DeadAir passed over every episode opened before it was played (#173).
pub async fn finished(&self, feed_id: &str, guid: &str) -> Result<Vec<i64>> {
self.rows(
"SELECT s.user_id FROM entry_state s
JOIN entries e ON e.feed_id = s.feed_id AND e.guid = s.guid
WHERE s.feed_id = $1 AND s.guid = $2 AND coalesce(s.duration, e.duration, 0) > 0
AND s.position * 10 >= coalesce(s.duration, e.duration) * 9",
vec![feed_id.into(), guid.into()],
)
.await?
.iter()
.map(|r| Ok(r.try_get("", "user_id")?))
.collect()
}
/// Records what DeadAir found in an enclosure: its plans as JSON, `{}` for none.
pub async fn store_plans(&self, enclosure_id: i64, plans: &str) -> Result<()> {
self.exec(
"INSERT INTO deadair_plans (enclosure_id, plans, made_at) VALUES ($1, $2, $3)
ON CONFLICT (enclosure_id) DO UPDATE SET plans = excluded.plans, made_at = excluded.made_at",
vec![enclosure_id.into(), plans.into(), now().into()],
)
.await?;
Ok(())
}
/// DeadAir's plans for these enclosures, by pause, for each one it has looked at since it
/// was last downloaded and is still on disk. Empty for a file it does not apply to; missing
/// for one it has yet to look at.
pub async fn plans(
&self,
enclosure_ids: &[i64],
) -> Result<std::collections::HashMap<i64, std::collections::BTreeMap<i64, crate::deadair::Plan>>> {
if enclosure_ids.is_empty() {
return Ok(Default::default());
}
let mut a = Args::default();
let ids: Vec<String> = enclosure_ids.iter().map(|&i| a.p(i)).collect();
let sql = format!(
"SELECT d.enclosure_id, d.plans FROM deadair_plans d JOIN enclosures e ON e.id = d.enclosure_id
WHERE d.enclosure_id IN ({}) AND e.path IS NOT NULL AND d.made_at >= coalesce(e.downloaded_at, 0)",
ids.join(", ")
);
self.rows(&sql, a.0)
.await?
.iter()
.map(|r| {
let plans: String = r.try_get("", "plans")?;
Ok((r.try_get("", "enclosure_id")?, serde_json::from_str(&plans).context("reading DeadAir's plans")?))
})
.collect()
}
/// Counts an episode towards the time DeadAir has saved someone, once however often it is
/// played.
pub async fn count_deadair(&self, user_id: i64, feed_id: &str, guid: &str, saved_ms: i64) -> Result<()> {
self.exec(
"INSERT INTO deadair_saved (user_id, feed_id, guid, saved_ms, at) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, feed_id, guid) DO NOTHING",
vec![user_id.into(), feed_id.into(), guid.into(), saved_ms.into(), now().into()],
)
.await?;
Ok(())
}
/// An episode's length as someone's player measured it, else as its feed gives it.
pub async fn entry_duration(&self, user_id: i64, feed_id: &str, guid: &str) -> Result<Option<i64>> {
let rows = self
.rows(
"SELECT coalesce(s.duration, e.duration) AS duration FROM entries e
LEFT JOIN entry_state s ON s.user_id = $1 AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE e.feed_id = $2 AND e.guid = $3",
vec![user_id.into(), feed_id.into(), guid.into()],
)
.await?;
Ok(rows.first().map(|r| r.try_get::<Option<i64>>("", "duration")).transpose()?.flatten())
}
/// The time DeadAir has saved someone, in ms.
pub async fn deadair_total(&self, user_id: i64) -> Result<i64> {
let rows = self
.rows(
"SELECT CAST(coalesce(sum(saved_ms), 0) AS BIGINT) AS total FROM deadair_saved WHERE user_id = $1",
vec![user_id.into()],
)
.await?;
// Cast: Postgres sums bigints as NUMERIC, which does not read as an i64.
Ok(rows.first().map(|r| r.try_get::<i64>("", "total")).transpose()?.unwrap_or(0))
}
}
/// Unix seconds. Everything time-shaped in the DB is stored this way. /// Unix seconds. Everything time-shaped in the DB is stored this way.
pub fn now() -> i64 { pub fn now() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()
@@ -2122,6 +2472,58 @@ pub fn now() -> i64 {
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn deadair_looks_at_a_file_once_per_download_and_counts_an_episode_once() {
let db = Db::memory().await.unwrap();
let u = db.create_user("dee", None, false).await.unwrap();
db.set_deadair(u, "f", Some(true)).await.unwrap();
db.set_deadair(u, "g", Some(false)).await.unwrap();
db.set_deadair(u, "g", None).await.unwrap();
assert_eq!(db.deadair_prefs(Some(u)).await.unwrap(), vec![(u, "f".to_string(), true, None)]);
db.set_deadair_pause(u, "f", 500).await.unwrap();
// Only beside a setting of the switch: a row of its own would turn DeadAir off for "g".
db.set_deadair_pause(u, "g", 400).await.unwrap();
assert_eq!(db.deadair_prefs(Some(u)).await.unwrap(), vec![(u, "f".to_string(), true, Some(500))]);
for sql in [
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
VALUES (1, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES (2, 'f', 'h', 'http://x/b.mp3', NULL, 'reaped')",
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
] {
db.orm.execute_unprepared(sql).await.unwrap();
}
let todo = db.deadair_todo().await.unwrap();
assert_eq!(todo.iter().map(|t| t.id).collect::<Vec<_>>(), [1], "only a file on disk");
db.store_plans(1, r#"{"300": {"cuts": [[1000,3000]], "pieces": [[0,10,1.0]]}}"#).await.unwrap();
assert!(db.deadair_todo().await.unwrap().is_empty());
let p = db.plans(&[1, 2]).await.unwrap();
assert_eq!((p.len(), p[&1][&300].cuts.as_slice(), p[&1][&300].saved_ms()), (1, &[crate::deadair::Cut(1000, 3000)][..], 2000));
// Downloaded again since: looked at again before it plays shortened.
db.orm.execute_unprepared(&format!("UPDATE enclosures SET downloaded_at = {} WHERE id = 1", now() + 60)).await.unwrap();
assert_eq!(db.deadair_todo().await.unwrap().len(), 1);
assert!(db.plans(&[1]).await.unwrap().is_empty());
assert!(!db.subscribed_any(&[u], "f").await.unwrap());
db.subscribe(u, "f").await.unwrap();
assert!(db.subscribed_any(&[u], "f").await.unwrap());
// Opened, which marks it read, and not played: not finished (#173).
db.set_entry_flag(u, "f", "g", EntryFlag::Read, true).await.unwrap();
assert!(db.finished("f", "g").await.unwrap().is_empty());
assert_eq!(db.entry_duration(u, "f", "g").await.unwrap(), Some(600));
db.set_position(u, "f", "g", 10, Some(590)).await.unwrap();
assert_eq!(db.entry_duration(u, "f", "g").await.unwrap(), Some(590), "what the player measured");
assert!(db.finished("f", "g").await.unwrap().is_empty());
db.set_position(u, "f", "g", 540, Some(590)).await.unwrap();
assert_eq!(db.finished("f", "g").await.unwrap(), [u], "past 90%");
assert_eq!(db.deadair_total(u).await.unwrap(), 0);
db.count_deadair(u, "f", "g", 2000).await.unwrap();
db.count_deadair(u, "f", "g", 2000).await.unwrap();
assert_eq!(db.deadair_total(u).await.unwrap(), 2000, "a replay counts once");
}
#[tokio::test] #[tokio::test]
async fn a_read_does_not_wait_for_a_write_in_progress() { async fn a_read_does_not_wait_for_a_write_in_progress() {
use sea_orm::TransactionTrait; use sea_orm::TransactionTrait;
@@ -2214,6 +2616,10 @@ mod tests {
assert_eq!(order("type", "asc").await.first().map(String::as_str), Some("n")); assert_eq!(order("type", "asc").await.first().map(String::as_str), Some("n"));
db.exec_for_test("DELETE FROM entries WHERE guid = 'n';").await.unwrap(); db.exec_for_test("DELETE FROM entries WHERE guid = 'n';").await.unwrap();
assert_eq!(order("published", "desc").await, ["c", "b", "a"]); assert_eq!(order("published", "desc").await, ["c", "b", "a"]);
// A page from the middle: the keys are paged before the rows are read (#188).
let page: Vec<String> = db.entries_in(1, None, Filter::All, None, 1, 1, &order_sql("published", "desc")).await
.unwrap().into_iter().map(|e| e.guid).collect();
assert_eq!(page, ["b"]);
db.set_entry_flag(1, "f", "a", EntryFlag::Flagged, true).await.unwrap(); db.set_entry_flag(1, "f", "a", EntryFlag::Flagged, true).await.unwrap();
assert_eq!(order("kept", "desc").await[0], "a"); assert_eq!(order("kept", "desc").await[0], "a");
// A pin does not lift an item above the others in any other sort (#36). // A pin does not lift an item above the others in any other sort (#36).
@@ -2228,9 +2634,9 @@ mod tests {
let db = Db::memory().await.unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false); "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(1,'g'),(2,'f'); INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(1,'g'),(2,'f'),(2,'h');
INSERT INTO feeds (id, url, title, ttl_mins) VALUES ('f','u','F',30),('g','v','G',null); INSERT INTO feeds (id, url, title, ttl_mins) VALUES ('f','u','F',30),('g','v','G',null),('h','w','H',null);
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','a',0),('f','b',0),('f','c',0),('g','d',0); INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','a',0),('f','b',0),('f','c',0),('g','d',0),('h','e',0);
INSERT INTO entry_state (user_id, feed_id, guid, read) VALUES (1,'f','a',true),(2,'f','b',true); INSERT INTO entry_state (user_id, feed_id, guid, read) VALUES (1,'f','a',true),(2,'f','b',true);
INSERT INTO hidden (user_id, feed_id, guid) VALUES (1,'f','c'); INSERT INTO hidden (user_id, feed_id, guid) VALUES (1,'f','c');
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
@@ -2251,6 +2657,8 @@ mod tests {
assert_eq!(one.ttl_mins, db.http_state(id).await.unwrap().ttl_mins, "{id}"); assert_eq!(one.ttl_mins, db.http_state(id).await.unwrap().ttl_mins, "{id}");
} }
assert_eq!((list["f"].unread, list["g"].unread), (1, 1)); // a read, c hidden; sam's read is sam's assert_eq!((list["f"].unread, list["g"].unread), (1, 1)); // a read, c hidden; sam's read is sam's
// Only sam reads h: still listed, for its title, but its items are not counted for ray (#165).
assert_eq!((list["h"].summary.title.as_deref(), list["h"].summary.entries), (Some("H"), 0));
} }
#[tokio::test] #[tokio::test]
@@ -2396,6 +2804,31 @@ mod tests {
assert_eq!(files, ["u1"].map(String::from).into()); // u2 is g's: left to the insert to find assert_eq!(files, ["u1"].map(String::from).into()); // u2 is g's: left to the insert to find
} }
#[tokio::test]
async fn a_scan_finds_the_stored_items_the_feed_now_tells_differently() {
let db = Db::memory().await.unwrap();
let item = |guid: &str, title: &str, text: Option<&str>| crate::feed::Entry {
guid: guid.into(),
title: Some(title.into()),
description: text.map(str::to_owned),
..Default::default()
};
db.record_entry("f", &item("a", "A", Some("the review"))).await.unwrap();
db.record_entry("f", &item("b", "B", Some("the next review"))).await.unwrap();
db.record_entry("f", &item("c", "C", Some("as it was"))).await.unwrap();
let changed = db
.changed_items("f", &[
item("a", "A", Some("the review, fixed")), // the text corrected
item("b", "B renamed", Some("the next review")), // retitled
item("c", "C", None), // says nothing of its text: what is stored stands
item("d", "D", Some("new")), // not stored: the insert's, not this
])
.await
.unwrap();
assert_eq!(changed, ["a", "b"].map(String::from).into());
assert!(db.changed_items("f", &[]).await.unwrap().is_empty());
}
#[tokio::test] #[tokio::test]
async fn only_artwork_a_feed_names_is_fetched_for_the_page() { async fn only_artwork_a_feed_names_is_fetched_for_the_page() {
let db = Db::memory().await.unwrap(); let db = Db::memory().await.unwrap();

755
src/deadair.rs Normal file
View File

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

View File

@@ -172,10 +172,12 @@ pub async fn download(
.with_context(|| format!("creating {}", tmp.display()))?; .with_context(|| format!("creating {}", tmp.display()))?;
let mut done: u64 = 0; let mut done: u64 = 0;
let mut stream = resp.bytes_stream(); let mut stream = resp.bytes_stream();
let _active = crate::metrics::busy(&crate::metrics::STATS.downloads_active);
while let Some(chunk) = stream.next().await { while let Some(chunk) = stream.next().await {
let chunk = chunk.context("reading body")?; let chunk = chunk.context("reading body")?;
file.write_all(&chunk).await?; file.write_all(&chunk).await?;
done += chunk.len() as u64; done += chunk.len() as u64;
crate::metrics::STATS.download_bytes.fetch_add(chunk.len() as u64, std::sync::atomic::Ordering::Relaxed);
on_progress(done, total); on_progress(done, total);
} }
file.flush().await?; file.flush().await?;
@@ -207,6 +209,36 @@ pub async fn place(d: &Downloaded, dir: &Path) -> Result<PathBuf> {
Ok(dest) Ok(dest)
} }
/// Deletes what an enclosure's path names: a file, or the folder a torrent of several files came
/// down as. `remove_file` refused the folder, so the reaper warned on every pass and freed nothing,
/// and Delete failed (#158).
pub fn remove(path: &Path) -> std::io::Result<()> {
if path.is_dir() {
return std::fs::remove_dir_all(path);
}
std::fs::remove_file(path)
}
/// The file to play for an enclosure's path: the path itself, or for a torrent of several files,
/// the largest file in its folder, which is the episode (#158).
pub fn playable(path: &Path) -> PathBuf {
fn largest(dir: &Path, best: &mut Option<(u64, PathBuf)>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for e in entries.flatten() {
match e.metadata() {
Ok(m) if m.is_dir() => largest(&e.path(), best),
Ok(m) if best.as_ref().is_none_or(|(n, _)| m.len() > *n) => *best = Some((m.len(), e.path())),
_ => {}
}
}
}
let mut best = None;
if path.is_dir() {
largest(path, &mut best);
}
best.map_or_else(|| path.to_owned(), |(_, p)| p)
}
/// Advertised as a torrent, before spending any bandwidth on it. /// Advertised as a torrent, before spending any bandwidth on it.
pub fn looks_like_torrent(url: &str, mime: Option<&str>) -> bool { pub fn looks_like_torrent(url: &str, mime: Option<&str>) -> bool {
mime.is_some_and(|m| m.to_ascii_lowercase().contains("torrent")) mime.is_some_and(|m| m.to_ascii_lowercase().contains("torrent"))
@@ -415,6 +447,20 @@ mod tests {
assert_eq!(folder_for(&cfg, "id", &f, None), "etc/Show"); 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] #[test]
fn keyword_matching_is_or_across_keywords_and_and_within_one() { fn keyword_matching_is_or_across_keywords_and_and_within_one() {
let kws = vec!["deep dive".to_string(), "interview".to_string()]; let kws = vec!["deep dive".to_string(), "interview".to_string()];

View File

@@ -354,3 +354,67 @@ pub mod settings {
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}
} }
/// What DeadAir found in an enclosure (#181): `plans` is a `deadair::Plan` for each pause length,
/// as JSON keyed by the pause in ms, and `{}` for a file it does not apply to (video, not MP3 or
/// AAC, nothing to cut), so that one is looked at once and not every pass.
pub mod deadair_plans {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "deadair_plans")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub enclosure_id: i64,
#[sea_orm(column_type = "Text")]
pub plans: String,
/// Against the enclosure's `downloaded_at`: a file downloaded again is looked at again.
pub made_at: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
/// Who has DeadAir on, for which feeds: it is off for every feed until someone turns it on for
/// that one or its group, and `wanted` false keeps one feed of a group out of the group's.
pub mod deadair_prefs {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "deadair_prefs")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
pub wanted: bool,
/// The pause DeadAir leaves where it cuts a silence, in ms (#182); NULL for the usual.
pub pause_ms: Option<i64>,
}
owned_by_user!();
}
/// The time DeadAir has saved someone: one row per episode they got 75% through on a copy, so
/// replaying it never counts twice. Their total is the sum.
pub mod deadair_saved {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "deadair_saved")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
pub saved_ms: i64,
pub at: i64,
}
owned_by_user!();
}

View File

@@ -196,15 +196,25 @@ pub fn explain_failure(msg: &str) -> Option<Failure> {
if low.contains("http 402") { if low.contains("http 402") {
return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None }); return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None });
} }
if low.contains("dns error") // Only a name that does not exist. A lookup that failed for the moment says nothing about the
|| low.contains("failed to lookup address") // site, and NPR's and Megaphone's feeds were labelled gone after a DNS outage (#143).
|| low.contains("no address associated") 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 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. /// 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 /// The original matched on the URL ending in ".opml" (iPXClass.py:34), which misses an
@@ -1317,8 +1327,13 @@ mod tests {
explain_failure("the feed's address answered with nothing at all").unwrap().reason, explain_failure("the feed's address answered with nothing at all").unwrap().reason,
"This address answers with nothing; the site may be gone." "This address answers with nothing; the site may be gone."
); );
let dns = explain_failure("connecting: dns error: failed to lookup address information").unwrap(); let dns = explain_failure("connecting: dns error: failed to lookup address information: Name or service not known").unwrap();
assert_eq!(dns.reason, "This address no longer resolves; the site is gone."); 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(); 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_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()); assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());

View File

@@ -32,6 +32,8 @@ pub enum Event {
DownloadDone { feed: String, enclosure: i64, url: String, path: String, bytes: u64 }, DownloadDone { feed: String, enclosure: i64, url: String, path: String, bytes: u64 },
DownloadError { feed: String, enclosure: i64, url: String, msg: String }, DownloadError { feed: String, enclosure: i64, url: String, msg: String },
TorrentDeferred { feed: String, url: 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 }, Reaped { path: String, bytes: u64 },
/// Terminal: a client that asked for work stops reading here. /// Terminal: a client that asked for work stops reading here.
ScanDone { feeds: usize }, ScanDone { feeds: usize },
@@ -79,6 +81,7 @@ impl Event {
// Noise in a terminal; a UI still gets them on the socket. // Noise in a terminal; a UI still gets them on the socket.
Event::FeedStart { feed } => format!("{feed}: checking"), Event::FeedStart { feed } => format!("{feed}: checking"),
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"), Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"), Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"),
}) })
} }
@@ -141,7 +144,12 @@ impl Emitter {
// line per feed per tick for "not due yet" would push everything worth reading // line per feed per tick for "not due yet" would push everything worth reading
// out of the buffer within a few minutes. // out of the buffer within a few minutes.
log_event(&e, self.tx.is_some()); log_event(&e, self.tx.is_some());
if let Some(tx) = &self.tx { // A feed not due changes nothing a listener shows, and a scan has one for nearly every
// feed: 1,500 at once overflowed the channel's 1,024 for a page slow to read (#197).
let routine = matches!(&e, Event::FeedSkip { reason, .. } if reason.starts_with("not due"));
if let Some(tx) = &self.tx
&& !routine
{
// An error here only means nobody is listening yet. // An error here only means nobody is listening yet.
let _ = tx.send(e.clone()); let _ = tx.send(e.clone());
} }
@@ -359,6 +367,17 @@ pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_feed_not_due_stays_off_the_live_updates() {
let (tx, mut rx) = broadcast::channel(4);
let out = Emitter::socket(tx, false);
for i in 0..10 {
out.emit(Event::FeedSkip { feed: format!("f{i}"), reason: "not due for 5m".into() });
}
out.emit(Event::FeedSkip { feed: "g".into(), reason: "not modified".into() });
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
}
#[test] #[test]
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() { 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 e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };

View File

@@ -3,11 +3,13 @@ mod art;
mod auth; mod auth;
mod config; mod config;
mod db; mod db;
mod deadair;
mod entity; mod entity;
mod download; mod download;
mod feed; mod feed;
mod ipc; mod ipc;
mod logbuf; mod logbuf;
mod metrics;
mod retention; mod retention;
mod torrent; mod torrent;
mod web; mod web;
@@ -141,19 +143,86 @@ pub struct Ctx {
/// stops every feed scan, every HTTP download and every status command behind it. /// stops every feed scan, every HTTP download and every status command behind it.
/// A one-shot CLI run keeps them inline, or the process would exit mid-download. /// A one-shot CLI run keeps them inline, or the process would exit mid-download.
pub detach_torrents: bool, pub detach_torrents: bool,
/// Feeds whose host could not be looked up for the moment, and when to try them again: a
/// failure that says nothing about the feed, kept off it and out of the database (#143).
/// Forgotten on a restart, which only makes the retry sooner.
pub retry: std::sync::Mutex<std::collections::HashMap<String, i64>>,
/// Held from taking a copy of the config to storing it (`edit_cfg`). Each change stores the
/// whole catalogue from its copy, and two at once stored over each other: a feed added while
/// another add spent a minute fetching was gone once that one stored (#153).
// ponytail: this process only. `ipx add`, `rm` and `import` write from a process of their
// own, and a change in flight here can still store over theirs; take the copy from the
// database rather than memory if that ever happens.
pub cfg_write: tokio::sync::Mutex<()>,
/// Wakes DeadAir's maker when a show is turned on, so its copies start at once instead of
/// at the next pass (#171).
pub deadair: tokio::sync::Notify,
/// When the Directory was last cleared of dead feeds (`clean_directory`), 0 for not since
/// starting.
pub directory_cleaned: std::sync::atomic::AtomicI64,
} }
/// A copy of the config to change, holding off every other change until `store_cfg` keeps it.
pub struct CfgEdit<'a> {
cfg: config::Config,
_held: tokio::sync::MutexGuard<'a, ()>,
}
impl std::ops::Deref for CfgEdit<'_> {
type Target = config::Config;
fn deref(&self) -> &config::Config {
&self.cfg
}
}
impl std::ops::DerefMut for CfgEdit<'_> {
fn deref_mut(&mut self) -> &mut config::Config {
&mut self.cfg
}
}
/// How soon a feed whose host could not be looked up is tried again (#143).
const RETRY_LOOKUP_SECS: i64 = 600;
impl Ctx { impl Ctx {
/// When a feed is next due, taking a lookup retry into account: not before it.
fn due(&self, cfg: &config::Config, feed: &config::Feed, id: &str, state: &db::HttpState, subscribed: bool) -> Option<i64> {
let at = due_at(cfg, feed, state, subscribed);
match self.retry.lock().unwrap().get(id) {
Some(&retry) => Some(at.unwrap_or(0).max(retry)),
None => at,
}
}
/// A scan's failure, recorded on the feed, unless it was only a moment's failure to look its
/// host up: on 2026-10-05 a DNS outage on Tower failed 204 feeds in a minute with
/// "Temporary failure in name resolution", and each, read once a day and backed off as a
/// failing feed, sat a day failing, unread and labelled gone (#143). Tried again in minutes
/// instead, with nothing on the feed to say it failed.
async fn feed_failed(&self, id: &str, url: &str, msg: &str) -> Result<()> {
if feed::is_temporary_lookup_failure(msg) {
self.retry.lock().unwrap().insert(id.to_owned(), db::now() + RETRY_LOOKUP_SECS);
return Ok(());
}
self.db.set_feed_error(id, url, msg).await
}
pub fn cfg(&self) -> std::sync::Arc<config::Config> { pub fn cfg(&self) -> std::sync::Arc<config::Config> {
self.cfg.read().unwrap().clone() self.cfg.read().unwrap().clone()
} }
/// Re-reads config.toml into the live snapshot. /// The config as it stands, to change and then keep with `store_cfg`; any other change waits
/// until then.
pub async fn edit_cfg(&self) -> CfgEdit<'_> {
let held = self.cfg_write.lock().await;
CfgEdit { cfg: (*self.cfg()).clone(), _held: held }
}
/// Keeps a changed catalogue or server settings: in the database, and for everything running /// Keeps a changed catalogue or server settings: in the database, and for everything running
/// here from now on. What config.toml's save and reload did, before the database held them. /// here from now on. What config.toml's save and reload did, before the database held them.
pub async fn store_cfg(&self, cfg: config::Config) -> Result<()> { pub async fn store_cfg(&self, edit: CfgEdit<'_>) -> Result<()> {
self.db.store_config(&cfg).await?; self.db.store_config(&edit.cfg).await?;
self.set_cfg(cfg); self.set_cfg(edit.cfg);
Ok(()) Ok(())
} }
@@ -255,7 +324,10 @@ async fn main() -> Result<()> {
let is_daemon = matches!(cli.command, Command::Daemon { .. }); let is_daemon = matches!(cli.command, Command::Daemon { .. });
// These write the catalogue from a process of their own, which a running daemon, holding its // These write the catalogue from a process of their own, which a running daemon, holding its
// own copy, would write over at its next change (#120). // own copy, would write over at its next change (#120).
let changes_catalogue = matches!(cli.command, Command::Add { .. } | Command::Rm { .. } | Command::Import { .. }); let changes_catalogue = matches!(
cli.command,
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
);
let (events, _) = broadcast::channel(1024); let (events, _) = broadcast::channel(1024);
let ctx = Arc::new(Ctx { let ctx = Arc::new(Ctx {
cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)), cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)),
@@ -264,6 +336,9 @@ async fn main() -> Result<()> {
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION"))) .user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
// Connecting only, so it bounds a download's start, not a long download (#108). // Connecting only, so it bounds a download's start, not a long download (#108).
.connect_timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(10))
// Two minutes without a byte is a stalled download, however long it is: unbounded, one
// held the worker, and every scan behind it, for good, while the daemon read healthy (#152).
.read_timeout(std::time::Duration::from_secs(120))
.build()?, .build()?,
feed_client: reqwest::Client::builder() feed_client: reqwest::Client::builder()
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION"))) .user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
@@ -275,6 +350,10 @@ async fn main() -> Result<()> {
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)), torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
config_path: config_path.clone(), config_path: config_path.clone(),
detach_torrents: is_daemon, detach_torrents: is_daemon,
retry: Default::default(),
cfg_write: Default::default(),
directory_cleaned: Default::default(),
deadair: Default::default(),
}); });
let result = match cli.command { let result = match cli.command {
@@ -441,7 +520,7 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
.db .db
.user_by_name(&name).await? .user_by_name(&name).await?
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?; .ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
ctx.db.delete_user(user.id).await?; remove_user(ctx, user.id).await?;
println!("removed {name}"); println!("removed {name}");
Ok(()) Ok(())
} }
@@ -449,11 +528,21 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
} }
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> { async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
// What the worker is on, for /metrics: the healthcheck answers while it is stuck (#195).
let _job = metrics::STATS.job(match &cmd {
Cmd::Fetch { .. } => "scan",
Cmd::Reap { .. } => "reap",
Cmd::Download { .. } => "download",
Cmd::Status => "status",
Cmd::Reload => "reload",
});
match cmd { match cmd {
Cmd::Fetch { feed, force, feeds } => { Cmd::Fetch { feed, force, feeds } => {
// Make room before pulling more down, as the original did per download. // Make room before pulling more down, as the original did per download.
reap(ctx, false, false).await?; reap(ctx, false, false).await?;
fetch(ctx, feed.as_deref(), force, &feeds).await fetch(ctx, feed.as_deref(), force, &feeds).await?;
metrics::STATS.last_scan_done.store(db::now(), std::sync::atomic::Ordering::Relaxed);
Ok(())
} }
Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await, Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await,
Cmd::Download { enclosure } => download_one(ctx, enclosure).await, Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
@@ -469,6 +558,7 @@ async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
/// memory, for a daemon told another ipx changed them (#120); config.toml, read again, for where /// memory, for a daemon told another ipx changed them (#120); config.toml, read again, for where
/// things are and who gets in. /// things are and who gets in.
async fn reload(ctx: &Ctx) -> Result<()> { async fn reload(ctx: &Ctx) -> Result<()> {
let _held = ctx.cfg_write.lock().await;
let cfg = assemble_config(&ctx.db, config::Config::load(&ctx.config_path)?, &ctx.config_path).await?; let cfg = assemble_config(&ctx.db, config::Config::load(&ctx.config_path)?, &ctx.config_path).await?;
ctx.set_cfg(cfg); ctx.set_cfg(cfg);
Ok(()) Ok(())
@@ -480,6 +570,12 @@ pub(crate) async fn status(ctx: &Ctx) -> Event {
match ctx.db.counts().await { match ctx.db.counts().await {
Ok((pending, downloaded)) => { Ok((pending, downloaded)) => {
let feeds = subscriptions(ctx).await.map(|s| s.len()).unwrap_or(0); let feeds = subscriptions(ctx).await.map(|s| s.len()).unwrap_or(0);
// Kept for /metrics, which asks the database nothing (#195): the healthcheck asks this every 30 s.
use std::sync::atomic::Ordering::Relaxed;
let st = &metrics::STATS;
st.feeds.store(feeds as i64, Relaxed);
st.pending.store(pending as i64, Relaxed);
st.downloaded.store(downloaded as i64, Relaxed);
Event::Status { feeds, pending, downloaded } Event::Status { feeds, pending, downloaded }
} }
Err(e) => Event::Error { msg: format!("{e:#}") }, Err(e) => Event::Error { msg: format!("{e:#}") },
@@ -527,7 +623,10 @@ async fn daemon(
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not retire feeds whose OPML is no longer in config"), Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not retire feeds whose OPML is no longer in config"),
} }
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64); // Room for any one request's commands: a web request waits inside itself for a free place,
// and Download latest sends up to 100. With 64 the rest waited on downloads an hour long, and
// every other request that queues work waited behind them (#154).
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(1024);
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?; let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
// status and reload are answered by the socket itself; everything else waits its turn in the // status and reload are answered by the socket itself; everything else waits its turn in the
@@ -562,6 +661,9 @@ async fn daemon(
shutdown().await; shutdown().await;
let _ = tx_stop.send(true); let _ = tx_stop.send(true);
}); });
// Off the worker: the first pass over a library is hours of ffmpeg, and scans and downloads
// must not wait behind it.
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
// Runs one job, abandoning it if a signal arrives. Returns false to end the loop. // Runs one job, abandoning it if a signal arrives. Returns false to end the loop.
async fn until_stopped( async fn until_stopped(
@@ -687,11 +789,25 @@ async fn start_web(
let (access, ctx, team) = (access.clone(), ctx.clone(), team.to_owned()); let (access, ctx, team) = (access.clone(), ctx.clone(), team.to_owned());
tokio::spawn(async move { access.prefetch(&ctx.client, &team).await }); tokio::spawn(async move { access.prefetch(&ctx.client, &team).await });
} }
let playing = deadair::Playing::default();
if !cfg.web.metrics_bind.is_empty() {
let sources = metrics::Sources {
ctx: ctx.clone(),
cmds: cmds.clone(),
events: events.clone(),
playing: playing.clone(),
runtime: tokio::runtime::Handle::current(),
started: std::time::Instant::now(),
};
let at = metrics::spawn(&cfg.web.metrics_bind, sources)?;
tracing::info!(%at, "serving metrics for Prometheus at /metrics");
}
let state = web::WebState { let state = web::WebState {
ctx: ctx.clone(), ctx: ctx.clone(),
cmds: cmds.clone(), cmds: cmds.clone(),
events: events.clone(), events: events.clone(),
access, access,
playing,
}; };
Ok(Some(tokio::spawn(async move { Ok(Some(tokio::spawn(async move {
if let Err(e) = web::serve(state, &bind).await { if let Err(e) = web::serve(state, &bind).await {
@@ -721,38 +837,54 @@ async fn add(
category: Option<String>, category: Option<String>,
list: bool, list: bool,
) -> Result<()> { ) -> Result<()> {
let mut cfg = (*ctx.cfg()).clone(); let url = feed::find_feed(&ctx.client, &feed::expand_input(url)).await?;
let url = &feed::find_feed(&ctx.client, &feed::expand_input(url)).await?; let (id, existed) = add_to_catalogue(ctx, &url, folder, keywords).await?;
// Includes feeds derived from an OPML, or the same show could be added twice. // Listing a feed the catalogue already has, or giving it a category, is the point of asking
if let Some(existing) = subscriptions(ctx).await?.iter().find(|s| feed::same_feed(&s.cfg.url, url)) { // again: it changes those, and nothing else. A feed an OPML lists has no entry to change.
// Listing a feed the catalogue already has, or giving it a category, is the point of if list || category.is_some() {
// asking again: it changes those, and nothing else. let mut cfg = ctx.edit_cfg().await;
if let Some(f) = cfg.feeds.get_mut(&existing.id) if let Some(f) = cfg.feeds.get_mut(&id) {
&& (list || category.is_some())
{
f.listed |= list; f.listed |= list;
if category.is_some() { if category.is_some() {
f.category = category; f.category = category;
} }
ctx.store_cfg(cfg).await?; ctx.store_cfg(cfg).await?;
println!("{} is already in the catalogue; updated its Directory listing", existing.id); if existed {
println!("{id} is already in the catalogue; updated its Directory listing");
return Ok(()); return Ok(());
} }
anyhow::bail!("already subscribed as {:?}", existing.id);
} }
let id = add_one(ctx, &mut cfg, url, folder, keywords).await?;
if let Some(f) = cfg.feeds.get_mut(&id) {
f.category = category;
f.listed = list;
} }
ctx.store_cfg(cfg).await?; if existed {
anyhow::bail!("already subscribed as {id:?}");
}
println!("added {id}{}", if list { ", listed in the Directory" } else { "" }); println!("added {id}{}", if list { ", listed in the Directory" } else { "" });
Ok(()) Ok(())
} }
/// Adds the feed at `url` to the catalogue unless it is there already, a feed an OPML lists
/// included, so one show is never added twice: its id, and whether it was there. For `ipx add`
/// and the page's Add feed alike, which were the same steps written twice (#161); each finds the
/// feed first, to say its own way why an address is not one.
pub async fn add_to_catalogue(
ctx: &Ctx,
url: &str,
folder: Option<String>,
keywords: Vec<String>,
) -> Result<(String, bool)> {
// Taken before the duplicate check, so two people adding the same feed at once add it once.
let mut cfg = ctx.edit_cfg().await;
if let Some(existing) = subscriptions(ctx).await?.into_iter().find(|s| feed::same_feed(&s.cfg.url, url)) {
return Ok((existing.id, true));
}
let id = add_one(ctx, &mut cfg, url, folder, keywords).await?;
ctx.store_cfg(cfg).await?;
Ok((id, false))
}
/// Returns the new feed id. Adding checks the address is a feed first (`feed::find_feed`); this /// Returns the new feed id. Adding checks the address is a feed first (`feed::find_feed`); this
/// still names one it cannot read from its URL rather than failing. /// still names one it cannot read from its URL rather than failing.
pub async fn add_one( async fn add_one(
ctx: &Ctx, ctx: &Ctx,
cfg: &mut config::Config, cfg: &mut config::Config,
url: &str, url: &str,
@@ -819,7 +951,7 @@ fn url_stem(url: &str) -> String {
} }
async fn rm(ctx: &Ctx, feed: &str) -> Result<()> { async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
let mut cfg = (*ctx.cfg()).clone(); let mut cfg = ctx.edit_cfg().await;
if cfg.feeds.remove(feed).is_none() { if cfg.feeds.remove(feed).is_none() {
// Derived from an OPML: drop it here, though the subscription will list it again // Derived from an OPML: drop it here, though the subscription will list it again
// on the next read unless the OPML itself goes. // on the next read unless the OPML itself goes.
@@ -869,8 +1001,8 @@ pub async fn subscribe_opml(
let mut found = vec![]; let mut found = vec![];
collect_outlines(&doc.body.outlines, &mut found); collect_outlines(&doc.body.outlines, &mut found);
let mut cfg = ctx.edit_cfg().await;
let known = subscriptions(ctx).await?; let known = subscriptions(ctx).await?;
let mut cfg = (*ctx.cfg()).clone();
let mut ids = vec![]; let mut ids = vec![];
let mut grew = false; let mut grew = false;
for (title, url) in found { for (title, url) in found {
@@ -1020,11 +1152,18 @@ async fn clean_directory(ctx: &Ctx) -> Result<()> {
const DEAD: i64 = 30 * 86_400; const DEAD: i64 = 30 * 86_400;
const QUIET: i64 = 365 * 86_400; const QUIET: i64 = 365 * 86_400;
let now = db::now(); let now = db::now();
// Once a day, for feeds dead a month or quiet a year: before every scan, its query took 1 to
// 12 s every few minutes, and the scans and downloads waited behind it (#193).
use std::sync::atomic::Ordering::Relaxed;
if now - ctx.directory_cleaned.load(Relaxed) < 86_400 {
return Ok(());
}
ctx.directory_cleaned.store(now, Relaxed);
let stale = ctx.db.stale_unsubscribed(now - DEAD, now - QUIET).await?; let stale = ctx.db.stale_unsubscribed(now - DEAD, now - QUIET).await?;
if stale.is_empty() { if stale.is_empty() {
return Ok(()); return Ok(());
} }
let mut cfg = (*ctx.cfg()).clone(); let mut cfg = ctx.edit_cfg().await;
if stale.iter().fold(false, |any, id| cfg.feeds.remove(id).is_some() || any) { if stale.iter().fold(false, |any, id| cfg.feeds.remove(id).is_some() || any) {
ctx.store_cfg(cfg).await?; ctx.store_cfg(cfg).await?;
} }
@@ -1039,12 +1178,16 @@ async fn clean_directory(ctx: &Ctx) -> Result<()> {
/// `standalone` false means this is the sweep that runs before a scan: it reports what it /// `standalone` false means this is the sweep that runs before a scan: it reports what it
/// deleted, but must not emit the terminal ReapDone, or a client waiting on its `fetch` /// deleted, but must not emit the terminal ReapDone, or a client waiting on its `fetch`
/// would stop reading before the scan had even started. /// would stop reading before the scan had even started.
#[tracing::instrument(name = "reap", skip_all, fields(dry_run = dry_run))] #[tracing::instrument(name = "reap", skip_all, fields(dry_run = dry_run, files = tracing::field::Empty, bytes = tracing::field::Empty, entries = tracing::field::Empty))]
async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> { async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
let r = retention::run(&ctx.cfg(), &ctx.db, dry_run).await?; let r = retention::run(&ctx.cfg(), &ctx.db, dry_run).await?;
let span = tracing::Span::current();
span.record("files", r.aged_out.len() + r.over_quota.len());
span.record("bytes", r.bytes_freed);
span.record("entries", r.entries_pruned);
if !dry_run { if !dry_run {
clean_directory(ctx).await?; tracing::Instrument::instrument(clean_directory(ctx), db::step("clean_directory")).await?;
let gone = art::trim(&art::dir(), ctx.cfg().general.art_cache_mb * 1_048_576); let gone = db::step("trim_artwork").in_scope(|| art::trim(&art::dir(), ctx.cfg().general.art_cache_mb * 1_048_576));
if gone > 0 { if gone > 0 {
tracing::debug!(gone, "trimmed the artwork kept on disk"); tracing::debug!(gone, "trimmed the artwork kept on disk");
} }
@@ -1093,7 +1236,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
state.last_modified = None; state.last_modified = None;
} }
if !force && let Some(at) = due_at(&cfg, &sub.cfg, &state, subscribed.contains_key(id)) { if !force && let Some(at) = ctx.due(&cfg, &sub.cfg, id, &state, subscribed.contains_key(id)) {
if at > db::now() { if at > db::now() {
ctx.out.emit(Event::FeedSkip { ctx.out.emit(Event::FeedSkip {
feed: id.clone(), feed: id.clone(),
@@ -1120,6 +1263,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
if !early { if !early {
return None; return None;
} }
let _fetching = metrics::busy(&metrics::STATS.feeds_fetching);
Some(feed::fetch(&client, &feed_cfg, etag.as_deref(), modified.as_deref()).await) Some(feed::fetch(&client, &feed_cfg, etag.as_deref(), modified.as_deref()).await)
}, },
tracing::Span::current(), tracing::Span::current(),
@@ -1165,7 +1309,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
// One bad feed must not end the scan. // One bad feed must not end the scan.
let msg = format!("{e:#}"); let msg = format!("{e:#}");
ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() }); ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() });
ctx.db.set_feed_error(id, &feed_cfg.url, &msg).await?; ctx.feed_failed(id, &feed_cfg.url, &msg).await?;
} }
} }
} }
@@ -1191,7 +1335,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
Err(e) => { Err(e) => {
let msg = format!("{e:#}"); let msg = format!("{e:#}");
ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() }); ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() });
ctx.db.set_feed_error(id, &feed_cfg.url, &msg).await?; ctx.feed_failed(id, &feed_cfg.url, &msg).await?;
} }
} }
} }
@@ -1221,6 +1365,9 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
.map(|(id, f)| Sub { id: id.clone(), cfg: f.clone(), managed: false }) .map(|(id, f)| Sub { id: id.clone(), cfg: f.clone(), managed: false })
.collect(); .collect();
// A group's title, once a group: asked for each feed in it, three queries a feed, it was about
// 96 for The Glass Cannon Network's 32 shows on every feed list, live update and scan (#159).
let mut group_titles = std::collections::HashMap::<String, String>::new();
for m in ctx.db.managed_feeds().await? { for m in ctx.db.managed_feeds().await? {
if cfg.feeds.contains_key(&m.id) { if cfg.feeds.contains_key(&m.id) {
continue; // promoted to config at some point; that entry wins continue; // promoted to config at some point; that entry wins
@@ -1234,7 +1381,13 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
} }
let base = match parent.and_then(|p| p.folder.clone()) { let base = match parent.and_then(|p| p.folder.clone()) {
Some(folder) => folder, Some(folder) => folder,
None => ctx.db.feed_summary(&m.group_id).await.ok().and_then(|s| s.title).unwrap_or_else(|| m.group_id.clone()), None => {
if !group_titles.contains_key(&m.group_id) {
let title = ctx.db.feed_summary(&m.group_id).await.ok().and_then(|s| s.title);
group_titles.insert(m.group_id.clone(), title.unwrap_or_else(|| m.group_id.clone()));
}
group_titles[&m.group_id].clone()
}
}; };
let title = m.title.clone().unwrap_or_else(|| m.id.clone()); let title = m.title.clone().unwrap_or_else(|| m.id.clone());
out.push(Sub { out.push(Sub {
@@ -1262,6 +1415,50 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
Ok(out) Ok(out)
} }
/// Takes a feed off one person's list, and the feeds in it if it is a group. Everyone else's
/// lists are left alone.
pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> {
ctx.db.unsubscribe(user_id, id).await?;
for child in subscriptions(ctx).await?.iter().filter(|s| s.cfg.group.as_deref() == Some(id)) {
ctx.db.unsubscribe(user_id, &child.id).await?;
}
if ctx.db.subscriber_counts().await?.contains_key(id) {
return Ok(());
}
// 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. A feed an
// admin listed stays in the Directory, for the next person.
let mut cfg = ctx.edit_cfg().await;
if cfg.feeds.get(id).is_some_and(|f| f.listed) {
return Ok(());
}
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.
return ctx.db.drop_managed(id).await;
}
ctx.store_cfg(cfg).await?;
retire_group(ctx, id).await
}
/// Removes an account, leaving each of its feeds first as unsubscribing does. The foreign keys
/// took the subscriptions with the account, but not the feed: one only they read stayed in the
/// catalogue with nobody subscribed, downloading every new episode for no one (#157).
pub async fn remove_user(ctx: &Ctx, user_id: i64) -> Result<()> {
let catalogue = subscriptions(ctx).await?;
let mine: std::collections::HashSet<String> =
ctx.db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
for id in &mine {
// A feed in a group of theirs goes with the group.
let in_theirs = catalogue.iter().any(|s| &s.id == id && s.cfg.group.as_ref().is_some_and(|g| mine.contains(g)));
if !in_theirs {
leave(ctx, user_id, id).await?;
}
}
ctx.db.delete_user(user_id).await
}
/// Retires every feed derived from `parent_id`, now that nothing subscribes to the OPML or /// Retires every feed derived from `parent_id`, now that nothing subscribes to the OPML or
/// Patreon feed that listed them: the same rule `sync_group` applies to one the list drops -- /// Patreon feed that listed them: the same rule `sync_group` applies to one the list drops --
/// removed if nothing was downloaded, orphaned and kept otherwise. Called once the parent /// removed if nothing was downloaded, orphaned and kept otherwise. Called once the parent
@@ -1329,7 +1526,9 @@ async fn until_next_scan(ctx: &Ctx) -> std::time::Duration {
.iter() .iter()
// A feed with no row yet has never been checked: due now. // A feed with no row yet has never been checked: due now.
.map(|s| { .map(|s| {
states.get(&s.id).and_then(|st| due_at(&cfg, &s.cfg, st, subscribed.contains_key(&s.id))).unwrap_or(0) let never = db::HttpState::default();
let state = states.get(&s.id).unwrap_or(&never);
ctx.due(&cfg, &s.cfg, &s.id, state, subscribed.contains_key(&s.id)).unwrap_or(0)
}) })
.min(), .min(),
) )
@@ -1406,7 +1605,7 @@ async fn follow_move(ctx: &Ctx, id: &str, to: &str) -> Result<()> {
tracing::info!(feed = id, to, "the feed moved to an address another feed already has; leaving it"); tracing::info!(feed = id, to, "the feed moved to an address another feed already has; leaving it");
return Ok(()); return Ok(());
} }
let mut cfg = (*ctx.cfg()).clone(); let mut cfg = ctx.edit_cfg().await;
let Some(f) = cfg.feeds.get_mut(id) else { return Ok(()) }; let Some(f) = cfg.feeds.get_mut(id) else { return Ok(()) };
let from = std::mem::replace(&mut f.url, to.to_owned()); let from = std::mem::replace(&mut f.url, to.to_owned());
ctx.store_cfg(cfg).await?; ctx.store_cfg(cfg).await?;
@@ -1573,7 +1772,7 @@ async fn scan_one(
{ {
match feed::classify(&ctx.client, &key, &parsed).await { match feed::classify(&ctx.client, &key, &parsed).await {
Ok(category) => { Ok(category) => {
let mut cfg = (*ctx.cfg()).clone(); let mut cfg = ctx.edit_cfg().await;
if let Some(f) = cfg.feeds.get_mut(id).filter(|f| f.category.is_none()) { if let Some(f) = cfg.feeds.get_mut(id).filter(|f| f.category.is_none()) {
tracing::info!(feed = id, category = %category, "categorised"); tracing::info!(feed = id, category = %category, "categorised");
f.category = Some(category); f.category = Some(category);
@@ -1598,13 +1797,17 @@ async fn scan_one(
// changed nothing however often the feed was scanned. // changed nothing however often the feed was scanned.
let skipped = ctx.db.skipped_by_filter(id).await?; let skipped = ctx.db.skipped_by_filter(id).await?;
let (known_items, known_files) = ctx.db.stored_items(id).await?; let (known_items, known_files) = ctx.db.stored_items(id).await?;
let changed = ctx.db.changed_items(id, &parsed.entries).await?;
let mut scan = Scan::default(); let mut scan = Scan::default();
// Its own span: the time a feed spends after its fetch was untraced (#96). // Its own span: the time a feed spends after its fetch was untraced (#96).
let store = tracing::info_span!("store", items = parsed.entries.len()); let store = tracing::info_span!("store", items = parsed.entries.len());
tracing::Instrument::instrument(async { tracing::Instrument::instrument(async {
for entry in &parsed.entries { for entry in &parsed.entries {
// Only what is not stored yet is inserted; the insert would find the rest and do nothing. // What is not stored yet is inserted, and what the feed has changed since is written
if !known_items.contains(&entry.guid) && ctx.db.record_entry(id, entry).await? { // again (#141); the rest is left alone. A correction keeps the item's read state.
if (!known_items.contains(&entry.guid) || changed.contains(&entry.guid))
&& ctx.db.record_entry(id, entry).await?
{
scan.new_entries += 1; scan.new_entries += 1;
} }
for enc in &entry.enclosures { for enc in &entry.enclosures {
@@ -1685,7 +1888,8 @@ async fn scan_one(
continue; continue;
} }
match fetch_one(ctx, id, item.id, feed_cfg, &item.url, &dest_dir).await { match fetch_one(ctx, id, item.id, feed_cfg, &item.url, &dest_dir).await {
Ok((path, bytes)) => { Ok(None) => scan.torrents += 1,
Ok(Some((path, bytes))) => {
ctx.out.emit(Event::DownloadDone { ctx.out.emit(Event::DownloadDone {
feed: id.to_string(), feed: id.to_string(),
enclosure: item.id, enclosure: item.id,
@@ -1883,7 +2087,12 @@ async fn policy_for(ctx: &Ctx, id: &str, feed_cfg: &config::Feed) -> Result<Poli
} }
fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Policy { fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Policy {
let cap = |n: Option<usize>| n.unwrap_or(if global == 0 { usize::MAX } else { global }); // 0 is every item, the whole back catalogue, wherever it is set: the server's default, a
// feed's or one person's. Only the server's was; a feed set to 0 downloaded nothing (#166).
let cap = |n: Option<usize>| match n.unwrap_or(global) {
0 => usize::MAX,
n => n,
};
if subs.is_empty() { if subs.is_empty() {
return Policy { return Policy {
@@ -1920,6 +2129,8 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
policy policy
} }
/// Downloads one file and records it. None when it turned out to be a torrent, handed off to run
/// on its own, which records it when it finishes.
#[tracing::instrument(name = "download", skip_all, fields(feed = feed_id, enclosure = enclosure, url = url))] #[tracing::instrument(name = "download", skip_all, fields(feed = feed_id, enclosure = enclosure, url = url))]
async fn fetch_one( async fn fetch_one(
ctx: &Arc<Ctx>, ctx: &Arc<Ctx>,
@@ -1928,7 +2139,7 @@ async fn fetch_one(
feed_cfg: &config::Feed, feed_cfg: &config::Feed,
url: &str, url: &str,
dest_dir: &std::path::Path, dest_dir: &std::path::Path,
) -> Result<(PathBuf, u64)> { ) -> Result<Option<(PathBuf, u64)>> {
// Throttled to whole percents, as the original's lastDLStepSize guard did. // Throttled to whole percents, as the original's lastDLStepSize guard did.
let mut last_pct = -1i64; let mut last_pct = -1i64;
let name = download::filename_for(url, None); let name = download::filename_for(url, None);
@@ -1959,12 +2170,22 @@ async fn fetch_one(
ctx.db.mark_enclosure(url, "skipped", Some("torrents disabled")).await?; ctx.db.mark_enclosure(url, "skipped", Some("torrents disabled")).await?;
anyhow::bail!("body is a torrent and torrents are disabled"); anyhow::bail!("body is a torrent and torrents are disabled");
} }
return torrent_one(ctx, feed_id, enclosure, url, dest_dir).await; // As one advertised as a torrent goes: off the worker where it can be, and recorded either
// way. Run here and never recorded, it stayed pending, and every scan of its feed ran it
// again, on the worker, for up to the stall limit and the seeding time (#155).
if ctx.detach_torrents {
ctx.db.mark_enclosure(url, "downloading", None).await?;
spawn_torrent(ctx, feed_id.to_owned(), enclosure, url.to_owned(), dest_dir.to_owned());
return Ok(None);
}
let (path, bytes) = torrent_one(ctx, feed_id, enclosure, url, dest_dir).await?;
ctx.db.mark_downloaded(url, &path, bytes).await?;
return Ok(Some((path, bytes)));
} }
let path = download::place(&got, dest_dir).await?; let path = download::place(&got, dest_dir).await?;
ctx.db.mark_downloaded(url, &path, got.bytes).await?; ctx.db.mark_downloaded(url, &path, got.bytes).await?;
Ok((path, got.bytes)) Ok(Some((path, got.bytes)))
} }
/// Runs a torrent off the command worker, so feed scans and HTTP downloads keep moving /// Runs a torrent off the command worker, so feed scans and HTTP downloads keep moving
@@ -2038,14 +2259,15 @@ async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
if !cfg.torrent.enabled { if !cfg.torrent.enabled {
Err(anyhow::anyhow!("torrents are disabled")) Err(anyhow::anyhow!("torrents are disabled"))
} else { } else {
torrent_one(ctx, &enc.feed_id, enc.id, &enc.url, &dest_dir).await torrent_one(ctx, &enc.feed_id, enc.id, &enc.url, &dest_dir).await.map(Some)
} }
} else { } else {
fetch_one(ctx, &enc.feed_id, enc.id, feed_cfg, &enc.url, &dest_dir).await fetch_one(ctx, &enc.feed_id, enc.id, feed_cfg, &enc.url, &dest_dir).await
}; };
match result { match result {
Ok((path, bytes)) => { Ok(None) => {} // A torrent after all, running on its own.
Ok(Some((path, bytes))) => {
ctx.db.mark_downloaded(&enc.url, &path, bytes).await?; ctx.db.mark_downloaded(&enc.url, &path, bytes).await?;
ctx.out.emit(Event::DownloadDone { ctx.out.emit(Event::DownloadDone {
feed: enc.feed_id.clone(), feed: enc.feed_id.clone(),
@@ -2247,6 +2469,11 @@ mod tests {
assert!(!p.wanted(&["python"], &[])); assert!(!p.wanted(&["python"], &[]));
assert_eq!(p.budget, 9); assert_eq!(p.budget, 9);
// 0 is the whole back catalogue, set for the server, for the feed or by one person (#166).
assert_eq!(merge_policy(&[], &feed(), 0).budget, usize::MAX);
assert_eq!(merge_policy(&[], &config::Feed { max_new_per_check: Some(0), ..feed() }, 3).budget, usize::MAX);
assert_eq!(merge_policy(&[sub(None, None, Some(0)), sub(None, None, Some(2))], &feed(), 3).budget, usize::MAX);
// One person taking everything removes the filter for the shared copy. // One person taking everything removes the filter for the shared copy.
let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3); let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3);
assert!(p.wanted(&["python"], &[])); assert!(p.wanted(&["python"], &[]));
@@ -2279,9 +2506,187 @@ mod tests {
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)), torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
config_path: PathBuf::new(), config_path: PathBuf::new(),
detach_torrents: false, detach_torrents: false,
retry: Default::default(),
cfg_write: Default::default(),
deadair: Default::default(),
directory_cleaned: Default::default(),
} }
} }
#[tokio::test]
async fn metrics_answer_from_a_thread_of_their_own() {
let ctx = Arc::new(test_ctx(config::Config::default()).await);
let (cmds, _rx) = mpsc::channel(8);
let (events, _) = broadcast::channel(8);
let sources = metrics::Sources {
ctx: ctx.clone(),
cmds,
events,
playing: Default::default(),
runtime: tokio::runtime::Handle::current(),
started: std::time::Instant::now(),
};
let at = metrics::spawn("127.0.0.1:0", sources).unwrap();
let text = ctx.client.get(format!("http://{at}/metrics")).send().await.unwrap().text().await.unwrap();
// Every sample a name, optional labels and a number, as Prometheus parses them (#194).
for line in text.lines().filter(|l| !l.starts_with('#')) {
let (name, value) = line.rsplit_once(' ').unwrap();
assert!(name.starts_with("ipx_") && value.parse::<f64>().is_ok(), "{line}");
}
for name in ["ipx_db_connections{state=\"in_use\"}", "ipx_db_connections_max", "ipx_worker_queue", "ipx_runtime_tasks"] {
assert!(text.contains(name), "{name}");
}
assert!(metrics::spawn(&at.to_string(), metrics::Sources {
ctx: ctx.clone(), cmds: mpsc::channel(1).0, events: broadcast::channel(1).0, playing: Default::default(),
runtime: tokio::runtime::Handle::current(), started: std::time::Instant::now(),
}).is_err(), "a port already taken is an error at start");
}
#[tokio::test]
async fn dead_feeds_leave_the_directory_once_a_day_not_before_every_scan() {
let ctx = test_ctx(config::Config::default()).await;
let dead = |id: &str| format!("INSERT INTO feeds (id, url, error_since, managed) VALUES ('{id}', 'u-{id}', 100, false)");
let listed = async || ctx.db.feed_urls().await.unwrap().into_iter().map(|(id, _)| id).collect::<Vec<_>>();
ctx.db.sql(&dead("a")).await.unwrap();
clean_directory(&ctx).await.unwrap();
assert!(listed().await.is_empty(), "the first pass since starting looks");
ctx.db.sql(&dead("b")).await.unwrap();
clean_directory(&ctx).await.unwrap();
assert_eq!(listed().await, ["b"], "not again the same day (#193)");
ctx.directory_cleaned.store(db::now() - 86_401, std::sync::atomic::Ordering::Relaxed);
clean_directory(&ctx).await.unwrap();
assert!(listed().await.is_empty(), "a day on, it looks again");
}
#[tokio::test]
async fn a_removed_account_takes_the_feeds_only_it_read_with_it() {
let ctx = test_ctx(config::Config::default()).await;
let mut cfg = ctx.edit_cfg().await;
for id in ["only-theirs", "shared", "listed"] {
cfg.feeds.insert(id.into(), config::Feed { listed: id == "listed", ..feed() });
}
ctx.store_cfg(cfg).await.unwrap();
let gone = ctx.db.create_user("gone", None, false).await.unwrap();
let stays = ctx.db.create_user("stays", None, true).await.unwrap();
for id in ["only-theirs", "shared", "listed"] {
ctx.db.subscribe(gone, id).await.unwrap();
}
ctx.db.subscribe(stays, "shared").await.unwrap();
remove_user(&ctx, gone).await.unwrap();
assert!(ctx.db.user_by_id(gone).await.unwrap().is_none());
// Nobody reads it now: out of the catalogue, not downloading for no one (#157).
assert_eq!(ctx.cfg().feeds.keys().collect::<Vec<_>>(), ["listed", "shared"]);
assert!(ctx.db.subscription(stays, "shared").await.unwrap().is_some());
}
#[tokio::test]
async fn deadair_hands_out_each_persons_clock_and_saves_in_the_originals() {
let ctx = test_ctx(config::Config::default()).await;
let db = &ctx.db;
let u = db.create_user("dee", None, false).await.unwrap();
db.subscribe(u, "f").await.unwrap();
for sql in [
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
VALUES (7, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
] {
db.sql(sql).await.unwrap();
}
// Two minutes cut at the usual pause, both before the place saved; 40 s at the longest.
let plans = r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
"500": {"cuts": [[60000,100000]], "pieces": []}}"#;
db.store_plans(7, plans).await.unwrap();
db.set_position(u, "f", "g", 300, None).await.unwrap();
let order = db::order_sql("published", "desc");
let page = || {
let (order, ctx) = (order.clone(), &ctx);
async move {
let mut rows = ctx.db.entries_in(u, Some("f"), db::Filter::All, None, 0, 10, &order).await.unwrap();
deadair::for_reader(ctx, u, &mut rows).await.unwrap();
rows.remove(0)
}
};
let off = page().await;
assert_eq!((off.position, off.enclosures[0].deadair), (300, None), "off until turned on for the feed");
assert!(!off.enclosures[0].deadair_pending);
db.set_deadair(u, "f", Some(true)).await.unwrap();
db.sql("DELETE FROM deadair_plans").await.unwrap();
let waiting = page().await;
assert!(waiting.enclosures[0].deadair_pending, "on, and its silences not found yet");
db.store_plans(7, plans).await.unwrap();
let on = page().await;
assert!(!on.enclosures[0].deadair_pending);
assert_eq!((on.position, on.duration, on.enclosures[0].deadair), (180, Some(480), Some(120)));
// A longer pause of their own: their clock, and the playlist, are that plan's (#182).
db.set_deadair_pause(u, "f", 500).await.unwrap();
let long = page().await;
assert_eq!((long.position, long.duration, long.enclosures[0].deadair), (260, Some(560), Some(40)));
assert_eq!(deadair::plan_for(&ctx, u, "f", 7).await.unwrap().unwrap().saved_ms(), 40_000);
db.set_deadair_pause(u, "f", 300).await.unwrap();
// Saved from the playlist at 3:20 of its 8:00: 5:20 of the original's 10:00, short of 75%.
let playing = deadair::Playing::default();
let plan = deadair::plan_for(&ctx, u, "f", 7).await.unwrap().unwrap();
playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(plan));
let saved = deadair::on_save(&ctx, &playing, u, "f", "g", 200, Some(480)).await.unwrap();
assert_eq!(saved, (320, Some(600)));
assert_eq!(db.deadair_total(u).await.unwrap(), 0);
// Past 75% (6:00 of the playlist is 8:00 of the original), counted once.
for _ in 0..2 {
deadair::on_save(&ctx, &playing, u, "f", "g", 360, None).await.unwrap();
}
assert_eq!(db.deadair_total(u).await.unwrap(), 120_000);
// Playing the original again: saved as it is.
playing.lock().unwrap().clear();
assert_eq!(deadair::on_save(&ctx, &playing, u, "f", "g", 200, Some(600)).await.unwrap(), (200, Some(600)));
// Turned off again: the original's clock, and no playlist.
db.set_deadair(u, "f", Some(false)).await.unwrap();
assert_eq!(page().await.enclosures[0].deadair, None);
assert!(deadair::plan_for(&ctx, u, "f", 7).await.unwrap().is_none());
}
#[tokio::test]
async fn two_changes_to_the_catalogue_at_once_both_keep() {
let ctx = Arc::new(test_ctx(config::Config::default()).await);
// A slow change, as adding a feed is while it fetches, and a quick one made meanwhile.
let slow = {
let ctx = ctx.clone();
tokio::spawn(async move {
let mut cfg = ctx.edit_cfg().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
cfg.feeds.insert("a".into(), feed());
ctx.store_cfg(cfg).await.unwrap();
})
};
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let mut cfg = ctx.edit_cfg().await;
cfg.feeds.insert("b".into(), feed());
ctx.store_cfg(cfg).await.unwrap();
slow.await.unwrap();
assert_eq!(ctx.cfg().feeds.keys().collect::<Vec<_>>(), ["a", "b"], "neither stored over the other (#153)");
let (_, stored) = ctx.db.stored_config().await.unwrap().unwrap();
assert_eq!(stored.keys().collect::<Vec<_>>(), ["a", "b"]);
}
#[tokio::test]
async fn a_moments_lookup_failure_is_tried_again_soon_and_not_held_against_the_feed() {
let ctx = test_ctx(config::Config::default()).await;
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
ctx.feed_failed("npr", "https://feeds.npr.org/x", blip).await.unwrap();
// Nothing on the feed: no error, no run of failures to back off from or call it gone by.
let s = ctx.db.feed_summary("npr").await.unwrap();
assert!(s.last_error.is_none() && s.error_since.is_none(), "{:?}", s.last_error);
// Due again in minutes, though a feed nobody subscribes to is otherwise read once a day.
let state = db::HttpState { last_checked: Some(db::now() - 2 * 86_400), ..Default::default() };
let due = ctx.due(&config::Config::default(), &feed(), "npr", &state, false).unwrap();
assert!((due - db::now() - RETRY_LOOKUP_SECS).abs() <= 2, "{due}");
// A real failure is the feed's.
ctx.feed_failed("gone", "https://x/feed", "HTTP 404 Not Found").await.unwrap();
assert_eq!(ctx.db.feed_summary("gone").await.unwrap().last_error.as_deref(), Some("HTTP 404 Not Found"));
}
#[tokio::test] #[tokio::test]
async fn a_reload_takes_the_catalogue_another_ipx_wrote() { async fn a_reload_takes_the_catalogue_another_ipx_wrote() {
let with = |id: &str| { let with = |id: &str| {
@@ -2299,7 +2704,8 @@ mod tests {
assert_eq!(urls, vec!["http://x/added-by-the-cli.xml".to_string()]); assert_eq!(urls, vec!["http://x/added-by-the-cli.xml".to_string()]);
} }
/// Answers /empty with a 200 and nothing, anything else with a feed of one item. /// Answers /empty with a 200 and nothing, /fixed with a feed of one item as it should read,
/// anything else with the same item's text broken.
async fn empty_or_feed_server() -> String { async fn empty_or_feed_server() -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -2310,9 +2716,13 @@ mod tests {
tokio::spawn(async move { tokio::spawn(async move {
let mut buf = [0u8; 2048]; let mut buf = [0u8; 2048];
let n = sock.read(&mut buf).await.unwrap_or(0); let n = sock.read(&mut buf).await.unwrap_or(0);
let empty = String::from_utf8_lossy(&buf[..n]).starts_with("GET /empty"); let req = String::from_utf8_lossy(&buf[..n]).into_owned();
let body = if empty { "\n" } else { let body = if req.starts_with("GET /empty") {
"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><item><title>One</title><guid>g1</guid></item></channel></rss>" "\n"
} else if req.starts_with("GET /fixed") {
"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><item><title>One</title><guid>g1</guid><description>As it should read.</description></item></channel></rss>"
} else {
"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><item><title>One</title><guid>g1</guid><description>Somebody else's text.</description></item></channel></rss>"
}; };
let resp = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()); let resp = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len());
let _ = sock.write_all(resp.as_bytes()).await; let _ = sock.write_all(resp.as_bytes()).await;
@@ -2322,6 +2732,20 @@ mod tests {
format!("http://{addr}") format!("http://{addr}")
} }
#[tokio::test]
async fn a_publishers_correction_reaches_an_item_already_stored() {
let base = empty_or_feed_server().await;
let ctx = Arc::new(test_ctx(config::Config::default()).await);
let at = |path: &str| config::Feed { url: format!("{base}{path}"), ..feed() };
let none = db::HttpState::default();
let text = || ctx.db.strings_for_test("SELECT description FROM entries WHERE feed_id = 'aicn'");
scan_one(&ctx, "aicn", &at("/feed"), &none, false, None).await.unwrap();
assert_eq!(text().await, ["Somebody else's text."]);
// The feed fixed, the item already stored: it was left as it was (#141).
scan_one(&ctx, "aicn", &at("/fixed"), &none, false, None).await.unwrap();
assert_eq!(text().await, ["As it should read."]);
}
#[tokio::test] #[tokio::test]
async fn nothing_from_a_feed_that_never_posted_is_an_error_and_from_one_that_has_is_not() { async fn nothing_from_a_feed_that_never_posted_is_an_error_and_from_one_that_has_is_not() {
let base = empty_or_feed_server().await; let base = empty_or_feed_server().await;

305
src/metrics.rs Normal file
View File

@@ -0,0 +1,305 @@
//! Prometheus metrics for what logs and traces cannot show: the daemon's own state between
//! requests, such as how many database connections are in use (#194). Served on a port and a
//! thread of their own, with a runtime of their own, and made only of what is already in memory:
//! a scrape neither waits for the web server's threads or the database, whose pool running dry
//! is one of the things it is there to show, nor holds anything up itself. No sign-in, as
//! Prometheus exporters have none: checking a token is a database lookup. Written out by hand:
//! the text format is a few lines, and a client library would be a dependency for them.
use std::fmt::Write as _;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering::Relaxed};
/// What the daemon counts as it works, for /metrics to read without asking anything (#195).
/// Global: the web layer, the worker, downloads, artwork and DeadAir each add to it, and a handle
/// threaded through all of them was more code than the counts.
pub static STATS: Stats = Stats::new();
pub struct Stats {
pub http_in_flight: AtomicI64,
/// Live updates dropped for a page or socket client that fell behind the broadcast.
pub events_missed: AtomicU64,
pub downloads_active: AtomicI64,
pub download_bytes: AtomicU64,
/// Feeds a scan is fetching at this moment, up to its AHEAD.
pub feeds_fetching: AtomicI64,
/// When a scan last finished, Unix seconds; 0 for not since starting.
pub last_scan_done: AtomicI64,
/// Files someone wants shortened that DeadAir has not looked at, as of its last pass.
pub deadair_backlog: AtomicI64,
/// When DeadAir started on the file it is on, Unix seconds; 0 when it is on none.
pub deadair_busy_since: AtomicI64,
pub art_cache_bytes: AtomicU64,
/// Images the sweep threw out to keep under the limit: kept bytes sit at the limit by design,
/// so this is what says whether the limit is too small.
pub art_evicted: AtomicU64,
/// Artwork served from disk, and fetched from the publisher because it was not there.
pub art_hits: AtomicU64,
pub art_misses: AtomicU64,
/// The library as the healthcheck last counted it, every 30 s; -1 until it has.
pub feeds: AtomicI64,
pub pending: AtomicI64,
pub downloaded: AtomicI64,
/// The worker's job now, and since when.
job: std::sync::Mutex<Option<(&'static str, std::time::Instant)>>,
/// Jobs finished, by kind: how many, and their seconds all told.
jobs: std::sync::Mutex<std::collections::BTreeMap<&'static str, (u64, f64)>>,
}
impl Stats {
pub const fn new() -> Self {
Self {
http_in_flight: AtomicI64::new(0),
events_missed: AtomicU64::new(0),
downloads_active: AtomicI64::new(0),
download_bytes: AtomicU64::new(0),
feeds_fetching: AtomicI64::new(0),
last_scan_done: AtomicI64::new(0),
deadair_backlog: AtomicI64::new(0),
deadair_busy_since: AtomicI64::new(0),
art_cache_bytes: AtomicU64::new(0),
art_evicted: AtomicU64::new(0),
art_hits: AtomicU64::new(0),
art_misses: AtomicU64::new(0),
feeds: AtomicI64::new(-1),
pending: AtomicI64::new(-1),
downloaded: AtomicI64::new(-1),
job: std::sync::Mutex::new(None),
jobs: std::sync::Mutex::new(std::collections::BTreeMap::new()),
}
}
/// Marks the worker as on a job of `kind` until what it returns is dropped, however the job
/// ends: finished, failed or stopped.
pub fn job(&'static self, kind: &'static str) -> Job {
*self.job.lock().unwrap() = Some((kind, std::time::Instant::now()));
Job(self)
}
}
pub struct Job(&'static Stats);
impl Drop for Job {
fn drop(&mut self) {
if let Some((kind, since)) = self.0.job.lock().unwrap().take() {
let mut jobs = self.0.jobs.lock().unwrap();
let e = jobs.entry(kind).or_default();
e.0 += 1;
e.1 += since.elapsed().as_secs_f64();
}
}
}
/// Counts one more of something in progress until dropped, however it ends.
pub struct Busy(&'static AtomicI64);
pub fn busy(gauge: &'static AtomicI64) -> Busy {
gauge.fetch_add(1, Relaxed);
Busy(gauge)
}
impl Drop for Busy {
fn drop(&mut self) {
self.0.fetch_sub(1, Relaxed);
}
}
/// What the metrics are read from.
pub struct Sources {
pub ctx: Arc<crate::Ctx>,
pub cmds: tokio::sync::mpsc::Sender<crate::ipc::Command>,
pub events: tokio::sync::broadcast::Sender<crate::ipc::Event>,
pub playing: crate::deadair::Playing,
/// The daemon's runtime, not the metrics thread's own.
pub runtime: tokio::runtime::Handle,
pub started: std::time::Instant,
}
/// Starts serving /metrics on `bind`, on a thread of its own, and says where.
pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
use anyhow::Context as _;
// Bound here, so a port already taken is the daemon's error at start, not a thread's later.
let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding metrics to {bind}"))?;
listener.set_nonblocking(true)?;
let addr = listener.local_addr()?;
let s = Arc::new(s);
std::thread::Builder::new().name("metrics".into()).spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Ok(rt) => rt,
Err(e) => return tracing::error!(error = %e, "metrics could not start"),
};
rt.block_on(async move {
let app = axum::Router::new().route(
"/metrics",
axum::routing::get(move || {
let s = s.clone();
async move { ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(&s)) }
}),
);
match tokio::net::TcpListener::from_std(listener) {
Ok(l) => {
if let Err(e) = axum::serve(l, app).await {
tracing::error!(error = %e, "metrics stopped");
}
}
Err(e) => tracing::error!(error = %e, "metrics could not listen"),
}
});
})?;
Ok(addr)
}
/// Every metric, from memory alone.
pub fn render(s: &Sources) -> String {
let mut out = String::new();
let (open, idle, max) = s.ctx.db.pool();
gauge(&mut out, "ipx_db_connections", "Database connections open, in use or idle.",
&[("state=\"in_use\"", open as f64 - idle as f64), ("state=\"idle\"", idle as f64)]);
gauge(&mut out, "ipx_db_connections_max", "The most database connections the pool opens.", &[("", max as f64)]);
gauge(&mut out, "ipx_worker_queue", "Commands waiting for the worker: scans, downloads and the like.",
&[("", (s.cmds.max_capacity() - s.cmds.capacity()) as f64)]);
gauge(&mut out, "ipx_event_listeners", "Open pages and socket clients listening for live updates.",
&[("", s.events.receiver_count() as f64)]);
gauge(&mut out, "ipx_torrent_slots_free", "Torrents that could start now.",
&[("", s.ctx.torrent_slots.available_permits() as f64)]);
gauge(&mut out, "ipx_deadair_playing", "Episodes someone is playing through DeadAir's playlist.",
&[("", s.playing.lock().map_or(0, |p| p.len()) as f64)]);
work(&mut out, &STATS, s.events.len(), s.ctx.retry.lock().map_or(0, |r| r.len()), s.ctx.cfg().general.art_cache_mb * 1_048_576);
let rt = s.runtime.metrics();
gauge(&mut out, "ipx_runtime_workers", "The async runtime's worker threads.", &[("", rt.num_workers() as f64)]);
gauge(&mut out, "ipx_runtime_tasks", "Tasks alive in the async runtime.", &[("", rt.num_alive_tasks() as f64)]);
gauge(&mut out, "ipx_runtime_queue", "Tasks waiting in the runtime's global queue.", &[("", rt.global_queue_depth() as f64)]);
let (rss, fds) = process();
if let Some(v) = rss {
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
}
if let Some(v) = fds {
gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]);
}
gauge(&mut out, "ipx_uptime_seconds", "Seconds since the daemon started.", &[("", s.started.elapsed().as_secs() as f64)]);
let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION"));
gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]);
out
}
/// The work in progress and done, from `STATS`: jobs, queues and what is in flight (#195).
fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_limit: u64) {
let now = crate::db::now();
let job = *st.job.lock().unwrap();
gauge(out, "ipx_worker_busy", "1 while the worker is on a job.", &[("", job.is_some() as u8 as f64)]);
if let Some((kind, since)) = job {
let label = format!("kind=\"{kind}\"");
gauge(out, "ipx_worker_job_seconds", "How long the worker has been on its job, by the job's kind.",
&[(&label, since.elapsed().as_secs_f64())]);
}
let jobs = st.jobs.lock().unwrap().clone();
let labels: Vec<String> = jobs.keys().map(|k| format!("kind=\"{k}\"")).collect();
let pairs = |f: fn(&(u64, f64)) -> f64| labels.iter().zip(jobs.values()).map(|(l, v)| (l.as_str(), f(v))).collect::<Vec<_>>();
counter(out, "ipx_jobs_total", "Jobs the worker has finished, by kind.", &pairs(|v| v.0 as f64));
counter(out, "ipx_job_seconds_total", "Seconds the worker has spent on jobs, by kind.", &pairs(|v| v.1));
let last = st.last_scan_done.load(Relaxed);
if last > 0 {
gauge(out, "ipx_last_scan_age_seconds", "Seconds since a scan last finished.", &[("", (now - last) as f64)]);
}
gauge(out, "ipx_http_requests_in_flight", "Web requests being answered.", &[("", st.http_in_flight.load(Relaxed) as f64)]);
gauge(out, "ipx_scan_feeds_fetching", "Feeds a scan is fetching at once.", &[("", st.feeds_fetching.load(Relaxed) as f64)]);
gauge(out, "ipx_downloads_active", "Files downloading now.", &[("", st.downloads_active.load(Relaxed) as f64)]);
counter(out, "ipx_download_bytes_total", "Bytes downloaded since the daemon started.", &[("", st.download_bytes.load(Relaxed) as f64)]);
gauge(out, "ipx_event_queue", "Live updates sent and not yet read by every listener.", &[("", event_queue as f64)]);
counter(out, "ipx_events_missed_total", "Live updates dropped for a listener that fell behind.", &[("", st.events_missed.load(Relaxed) as f64)]);
gauge(out, "ipx_deadair_backlog", "Files of shows someone has DeadAir on that it has not looked at, those already heard through included.", &[("", st.deadair_backlog.load(Relaxed) as f64)]);
let since = st.deadair_busy_since.load(Relaxed);
gauge(out, "ipx_deadair_busy_seconds", "How long DeadAir has been on its file; 0 when on none.",
&[("", if since > 0 { (now - since) as f64 } else { 0.0 })]);
gauge(out, "ipx_feed_retries", "Feeds waiting to try a failed name lookup again.", &[("", retries as f64)]);
gauge(out, "ipx_art_cache_bytes", "Artwork kept on disk, as of the last sweep.", &[("", st.art_cache_bytes.load(Relaxed) as f64)]);
gauge(out, "ipx_art_cache_limit_bytes", "The most artwork kept.", &[("", art_limit as f64)]);
counter(out, "ipx_art_evicted_total", "Images thrown out to keep the artwork under its limit.", &[("", st.art_evicted.load(Relaxed) as f64)]);
counter(out, "ipx_art_requests_total", "Artwork asked for: served from disk, or fetched from the publisher.",
&[("result=\"hit\"", st.art_hits.load(Relaxed) as f64), ("result=\"miss\"", st.art_misses.load(Relaxed) as f64)]);
for (name, help, v) in [
("ipx_feeds", "Feeds in the catalogue, as the healthcheck last counted.", &st.feeds),
("ipx_downloads_pending", "Files a scan will download on its own, as the healthcheck last counted.", &st.pending),
("ipx_files_downloaded", "Files on disk, as the healthcheck last counted.", &st.downloaded),
] {
let v = v.load(Relaxed);
if v >= 0 {
gauge(out, name, help, &[("", v as f64)]);
}
}
}
/// A counter, which only goes up while the daemon runs, in Prometheus's text format.
pub fn counter(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
write(out, "counter", name, help, samples);
}
/// One gauge in Prometheus's text format: its help, its type, and a line for each set of labels
/// (`state="idle"`, or "" for none).
pub fn gauge(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
write(out, "gauge", name, help, samples);
}
fn write(out: &mut String, kind: &str, name: &str, help: &str, samples: &[(&str, f64)]) {
let _ = writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}");
for (labels, value) in samples {
let labels = if labels.is_empty() { String::new() } else { format!("{{{labels}}}") };
let _ = writeln!(out, "{name}{labels} {value}");
}
}
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
pub fn process() -> (Option<f64>, Option<f64>) {
let rss = std::fs::read_to_string("/proc/self/status").ok().and_then(|s| {
let kb = s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
Some(kb * 1024.0)
});
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
(rss, fds)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gauges_are_written_as_prometheus_reads_them() {
let mut out = String::new();
gauge(&mut out, "ipx_db_connections", "Connections.", &[("state=\"idle\"", 3.0), ("state=\"in_use\"", 1.0)]);
gauge(&mut out, "ipx_feeds", "Feeds.", &[("", 1535.0)]);
assert_eq!(
out,
"# HELP ipx_db_connections Connections.\n# TYPE ipx_db_connections gauge\n\
ipx_db_connections{state=\"idle\"} 3\nipx_db_connections{state=\"in_use\"} 1\n\
# HELP ipx_feeds Feeds.\n# TYPE ipx_feeds gauge\nipx_feeds 1535\n"
);
}
#[test]
fn jobs_and_work_in_progress_are_counted_however_they_end() {
static T: Stats = Stats::new();
{
let _j = T.job("scan");
let _b = busy(&T.downloads_active);
assert_eq!(T.downloads_active.load(Relaxed), 1);
let mut out = String::new();
work(&mut out, &T, 0, 0, 0);
assert!(out.contains("ipx_worker_busy 1\n") && out.contains("ipx_worker_job_seconds{kind=\"scan\"}"), "{out}");
}
// Dropped, as an error or a stop drops them: nothing left in progress, one scan done.
assert_eq!(T.downloads_active.load(Relaxed), 0);
let mut out = String::new();
work(&mut out, &T, 3, 1, 10);
assert!(out.contains("ipx_worker_busy 0\n") && !out.contains("ipx_worker_job_seconds"), "{out}");
assert!(out.contains("ipx_jobs_total{kind=\"scan\"} 1\n") && out.contains("# TYPE ipx_jobs_total counter"), "{out}");
assert!(out.contains("ipx_event_queue 3\n") && out.contains("ipx_feed_retries 1\n"));
// The library's counts are left out until the healthcheck has counted it.
assert!(!out.contains("ipx_feeds "));
}
#[test]
fn the_process_reads_its_memory_and_files_on_linux() {
let (rss, fds) = process();
assert!(rss.unwrap() > 0.0 && fds.unwrap() > 0.0);
}
}

View File

@@ -1,9 +1,10 @@
//! Disk quota and age sweeps. Replaces iPXQuotaManager.py ("SmartSpace"). //! Disk quota and age sweeps. Replaces iPXQuotaManager.py ("SmartSpace").
use anyhow::Result; use anyhow::Result;
use tracing::Instrument;
use crate::config::Config; use crate::config::Config;
use crate::db::{Candidate, Db, now}; use crate::db::{Candidate, Db, now, step};
/// Headroom kept below the quota, as getToQuota()'s `1048576 * 50` did. /// Headroom kept below the quota, as getToQuota()'s `1048576 * 50` did.
pub const PAD_BYTES: u64 = 50 * 1024 * 1024; pub const PAD_BYTES: u64 = 50 * 1024 * 1024;
@@ -49,7 +50,8 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
let mut report = Report::default(); let mut report = Report::default();
// Someone may have deleted a file by hand; the row must stop claiming it exists. // Someone may have deleted a file by hand; the row must stop claiming it exists.
for (id, path) in db.missing_files().await? { // Each step is a span of the reap's trace, so a slow one says which part was slow.
for (id, path) in db.missing_files().instrument(step("missing_files")).await? {
if !dry_run { if !dry_run {
db.mark_reaped(id).await?; db.mark_reaped(id).await?;
} }
@@ -57,20 +59,20 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
report.reconciled += 1; report.reconciled += 1;
} }
for id in db.prune_abandoned_failures().await? { for id in db.prune_abandoned_failures().instrument(step("prune_abandoned_failures")).await? {
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to"); tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
} }
let candidates = db.reap_candidates().await?; let candidates = db.reap_candidates().instrument(step("reap_candidates")).await?;
if cfg.general.max_age_days > 0 { if cfg.general.max_age_days > 0 {
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64; let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
report.aged_out = aged(&candidates, cutoff); report.aged_out = aged(&candidates, cutoff);
for c in &report.aged_out { for c in &report.aged_out {
report.bytes_freed += remove(db, c, dry_run).await?; report.bytes_freed += remove(db, c, dry_run).instrument(step("remove_aged")).await?;
} }
if !dry_run { if !dry_run {
report.entries_pruned = db.prune_entries(cutoff).await?; report.entries_pruned = db.prune_entries(cutoff).instrument(step("prune_entries")).await?;
} }
} }
@@ -85,7 +87,7 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum(); let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
report.over_quota = pick(&remaining, total, limit); report.over_quota = pick(&remaining, total, limit);
for c in &report.over_quota { for c in &report.over_quota {
report.bytes_freed += remove(db, c, dry_run).await?; report.bytes_freed += remove(db, c, dry_run).instrument(step("remove_over_quota")).await?;
} }
} }
@@ -96,9 +98,13 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
if dry_run { if dry_run {
return Ok(c.bytes.max(0) as u64); return Ok(c.bytes.max(0) as u64);
} }
let size = std::fs::metadata(&c.path).map(|m| m.len()).unwrap_or(c.bytes.max(0) as u64); // A torrent's folder measures as a directory entry, not what is in it: the recorded size then.
let size = std::fs::metadata(&c.path)
.ok()
.filter(|m| m.is_file())
.map_or(c.bytes.max(0) as u64, |m| m.len());
// Missing is fine: the point is that it is gone. // 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 && e.kind() != std::io::ErrorKind::NotFound
{ {
tracing::warn!(path = c.path, error = %e, "could not delete"); tracing::warn!(path = c.path, error = %e, "could not delete");
@@ -189,11 +195,36 @@ mod tests {
); );
} }
#[tokio::test]
async fn the_marks_of_someone_who_left_count_for_nothing() {
// ray and sam subscribe; left read one episode and pinned the others before unsubscribing.
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false),(3,'left',false);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'heard', 0), ('f', 'pinned', 0), ('f', 'old', 0);
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
(3, 'f', 'heard', true, false),
(1, 'f', 'heard', true, false),
(3, 'f', 'pinned', false, true),
(3, 'f', 'old', false, true);
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'heard', 'u1', '/tmp/heard', 10, 'done', 10),
(2, 'f', 'pinned', 'u2', '/tmp/pinned', 10, 'done', 20);",
).await
.unwrap();
let got: Vec<(i64, bool)> = db.reap_candidates().await.unwrap().iter().map(|c| (c.id, c.read)).collect();
assert_eq!(got, vec![(1, false), (2, false)], "sam has not heard it, and nobody here pinned it (#156)");
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "the old item goes, a leaver's pin or not");
}
#[tokio::test] #[tokio::test]
async fn prune_keeps_entries_that_still_have_a_file() { async fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().await.unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true); "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 entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
INSERT INTO entries (feed_id, guid, first_seen) VALUES INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'has-file', 100), ('f', 'has-file', 100),

View File

@@ -21,6 +21,8 @@ use std::sync::Arc;
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
use crate::Ctx; use crate::Ctx;
use crate::db::step;
use tracing::Instrument;
use crate::ipc::{Command, Event}; use crate::ipc::{Command, Event};
const COOKIE: &str = "ipx_token"; const COOKIE: &str = "ipx_token";
@@ -32,6 +34,7 @@ pub struct WebState {
pub events: broadcast::Sender<Event>, pub events: broadcast::Sender<Event>,
/// Cloudflare Access's signing keys, fetched once and kept. /// Cloudflare Access's signing keys, fetched once and kept.
pub access: Arc<crate::access::Keys>, pub access: Arc<crate::access::Keys>,
pub playing: crate::deadair::Playing,
} }
pub fn router(state: WebState) -> Router { pub fn router(state: WebState) -> Router {
@@ -45,6 +48,7 @@ pub fn router(state: WebState) -> Router {
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed)) .route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
.route("/api/feeds/{id}/entries", get(entries)) .route("/api/feeds/{id}/entries", get(entries))
.route("/api/entries", get(all_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}/flags", post(set_flags))
.route("/api/entries/{feed_id}/{guid}/position", post(set_position)) .route("/api/entries/{feed_id}/{guid}/position", post(set_position))
.route("/api/feeds/{id}/read-all", post(read_all)) .route("/api/feeds/{id}/read-all", post(read_all))
@@ -63,10 +67,14 @@ pub fn router(state: WebState) -> Router {
.route("/api/users", get(list_users).post(add_user)) .route("/api/users", get(list_users).post(add_user))
.route("/api/users/{id}", patch(patch_user).delete(remove_user)) .route("/api/users/{id}", patch(patch_user).delete(remove_user))
.route("/api/logs", get(logs)) .route("/api/logs", get(logs))
.route("/api/client", post(client_event))
.route("/api/events", get(events)) .route("/api/events", get(events))
.route("/admin", get(admin_page)) .route("/admin", get(admin_page))
.route("/admin.js", get(admin_js)) .route("/admin.js", get(admin_js))
.route("/media/{id}", get(media)) .route("/media/{id}", get(media))
.route("/media/{id}/deadair.m3u8", get(media_deadair))
.route("/media/{id}/deadair.mp3", get(media_piece))
.route("/media/{id}/deadair", get(media_save))
.route("/api/art", get(art)) .route("/api/art", get(art))
.layer(middleware::from_fn_with_state(state.clone(), auth)) .layer(middleware::from_fn_with_state(state.clone(), auth))
// Signing in cannot require being signed in, so these sit outside the auth layer. // Signing in cannot require being signed in, so these sit outside the auth layer.
@@ -84,6 +92,7 @@ pub fn router(state: WebState) -> Router {
.route("/app.js", get(app_js)) .route("/app.js", get(app_js))
.route("/app.css", get(app_css)) .route("/app.css", get(app_css))
.route("/login.js", get(login_js)) .route("/login.js", get(login_js))
.route("/hls.js", get(hls_js))
.route("/inter.woff2", get(inter)) .route("/inter.woff2", get(inter))
.route_layer(middleware::from_fn(name_span)) .route_layer(middleware::from_fn(name_span))
.layer(middleware::from_fn(access_log)) .layer(middleware::from_fn(access_log))
@@ -91,9 +100,17 @@ pub fn router(state: WebState) -> Router {
} }
pub async fn serve(state: WebState, bind: &str) -> Result<()> { 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) let listener = tokio::net::TcpListener::bind(bind)
.await .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"); tracing::info!(bind, "web ui listening");
axum::serve( axum::serve(
listener, listener,
@@ -365,8 +382,10 @@ async fn me(
let url = state.ctx.cfg().web.sign_out_url.clone(); let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url); let sign_out = (by_proxy && !url.is_empty()).then_some(url);
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default(); 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!({ Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked, "name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
"deadair_saved": saved,
})) }))
} }
@@ -558,7 +577,7 @@ async fn remove_user(
target.name 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"); tracing::info!(by = %user.name, user = %target.name, "account removed");
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -629,6 +648,12 @@ async fn login_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/login.js"))) script(include_str!(concat!(env!("OUT_DIR"), "/login.js")))
} }
/// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181): the page loads
/// it only there, by the name build.mjs gives it.
async fn hls_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/hls.js")))
}
fn script(js: &'static str) -> impl IntoResponse { fn script(js: &'static str) -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js) ([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
} }
@@ -760,6 +785,10 @@ struct FeedRow {
keywords: Vec<String>, keywords: Vec<String>,
/// Your words that hide an item of this feed, beside your list for every feed. /// Your words that hide an item of this feed, beside your list for every feed.
blocked: Vec<String>, blocked: Vec<String>,
/// Played shortened by DeadAir: on for this feed or its group, off until you turn it on.
deadair: bool,
/// The pause DeadAir leaves where it cuts, in ms: this feed's, its group's, or the usual.
deadair_pause: i64,
allow_explicit: bool, allow_explicit: bool,
auto_download: bool, auto_download: bool,
max_new_per_check: Option<usize>, max_new_per_check: Option<usize>,
@@ -810,17 +839,23 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
let cfg = state.ctx.cfg(); let cfg = state.ctx.cfg();
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue. // Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
// What comes back is only the part of it this person subscribes to. // 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 let mine: std::collections::HashMap<String, crate::db::Sub> = state
.ctx .ctx
.db .db
.subscriptions_for(user.id).await? .subscriptions_for(user.id)
.instrument(step("subscriptions"))
.await?
.into_iter() .into_iter()
.map(|s| (s.feed_id.clone(), s)) .map(|s| (s.feed_id.clone(), s))
.collect(); .collect();
let counts = state.ctx.db.subscriber_counts().await?; let counts = state.ctx.db.subscriber_counts().instrument(step("subscriber_counts")).await?;
let pinned = state.ctx.db.pinned_feeds(user.id).await?; let pinned = state.ctx.db.pinned_feeds(user.id).instrument(step("pinned")).await?;
let mut listed = state.ctx.db.feed_list(user.id, only).await?; let prefs = state.ctx.db.deadair_prefs(Some(user.id)).await?;
let deadair: std::collections::HashMap<&str, bool> = prefs.iter().map(|(_, f, w, _)| (f.as_str(), *w)).collect();
let pauses: std::collections::HashMap<&str, i64> =
prefs.iter().filter_map(|(_, f, _, p)| Some((f.as_str(), (*p)?))).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()); let mut out = Vec::with_capacity(mine.len());
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) { for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
let (id, feed) = (&sub.id, &sub.cfg); let (id, feed) = (&sub.id, &sub.cfg);
@@ -844,6 +879,16 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
.or_else(|| up.and_then(|u| u.keywords.clone())) .or_else(|| up.and_then(|u| u.keywords.clone()))
.unwrap_or_else(|| feed.keywords.clone()), .unwrap_or_else(|| feed.keywords.clone()),
blocked, blocked,
deadair: deadair
.get(id.as_str())
.or_else(|| feed.group.as_deref().and_then(|g| deadair.get(g)))
.copied()
.unwrap_or(false),
deadair_pause: pauses
.get(id.as_str())
.or_else(|| feed.group.as_deref().and_then(|g| pauses.get(g)))
.copied()
.unwrap_or(crate::deadair::DEFAULT_PAUSE),
allow_explicit: mine allow_explicit: mine
.allow_explicit .allow_explicit
.or(up.and_then(|u| u.allow_explicit)) .or(up.and_then(|u| u.allow_explicit))
@@ -954,20 +999,11 @@ async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result<Vec
let counts = db.subscriber_counts().await?; let counts = db.subscriber_counts().await?;
let media = db.media_feeds().await?; let media = db.media_feeds().await?;
let catalogue = crate::subscriptions(&state.ctx).await?; let catalogue = crate::subscriptions(&state.ctx).await?;
let by_id: std::collections::HashMap<&str, &crate::config::Feed> = let ok = listable(&catalogue);
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 mut out = vec![]; let mut out = vec![];
for s in &catalogue { for s in &catalogue {
let n = counts.get(&s.id).copied().unwrap_or(0); let n = counts.get(&s.id).copied().unwrap_or(0);
// A feed inside an OPML that looks private is as private as the OPML. if (n == 0 && !everything) || !ok.contains(s.id.as_str()) {
let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g));
if (n == 0 && !everything)
|| is_folder.contains(s.id.as_str())
|| looks_private(&s.cfg)
|| folder.is_some_and(|f| looks_private(f))
{
continue; continue;
} }
let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default(); let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default();
@@ -989,6 +1025,26 @@ async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result<Vec
Ok(out) 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( async fn get_popular(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
@@ -1024,9 +1080,22 @@ struct ListedItem {
struct Listed { struct Listed {
/// What the feed says it is (#130), sanitized as its items are. /// What the feed says it is (#130), sanitized as its items are.
description: Option<String>, 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>, 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 /// 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. /// guessed id reaches nothing private, as `subscribe_popular` checks.
async fn get_listed( async fn get_listed(
@@ -1034,13 +1103,16 @@ async fn get_listed(
user: crate::db::User, user: crate::db::User,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Listed>, ApiError> { ) -> Result<Json<Listed>, ApiError> {
if !popular(&state, user.id, true).await?.iter().any(|p| p.id == id) { if !listable(&crate::subscriptions(&state.ctx).await?).contains(id.as_str()) {
return Err(ApiError::not_found(format!("{id:?} is not in the directory"))); return Err(ApiError::not_found(format!("{id:?} is not in the directory")));
} }
let order = crate::db::order_sql("published", "desc"); 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 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 mut sanitizer = feed_sanitizer();
let description = state.ctx.db.feed_description(&id).await?.map(|d| clean_description(&mut sanitizer, &d, None)); 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 let items = rows
.into_iter() .into_iter()
.map(|e| ListedItem { .map(|e| ListedItem {
@@ -1052,7 +1124,7 @@ async fn get_listed(
duration: e.duration, duration: e.duration,
}) })
.collect(); .collect();
Ok(Json(Listed { description, items })) Ok(Json(Listed { description, failing, items }))
} }
fn sort_name(p: &PopularRow) -> String { fn sort_name(p: &PopularRow) -> String {
@@ -1066,7 +1138,7 @@ async fn subscribe_popular(
user: crate::db::User, user: crate::db::User,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
if !popular(&state, user.id, true).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"))); return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
} }
state.ctx.db.subscribe(user.id, &id).await?; state.ctx.db.subscribe(user.id, &id).await?;
@@ -1146,6 +1218,18 @@ impl IntoResponse for ApiError {
mod tests { mod tests {
use super::*; 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] #[test]
fn a_relative_image_resolves_against_the_post() { fn a_relative_image_resolves_against_the_post() {
let mut b = feed_sanitizer(); let mut b = feed_sanitizer();
@@ -1220,6 +1304,60 @@ mod tests {
assert!(looks_private(&basic), "a feed with a login configured"); 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 a_requests_trace_is_the_pages_from_its_traceparent() {
use opentelemetry::trace::TraceContextExt;
let with = |v: &str| {
let mut h = HeaderMap::new();
h.insert("traceparent", v.parse().unwrap());
remote_parent(&h, None)
};
let cx = with("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").expect("parsed");
assert_eq!(cx.span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
assert!(cx.span().span_context().is_remote());
// Anything else from the browser starts a trace of its own.
for bad in ["", "00-xyz-00f067aa0ba902b7-01", "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"00-00000000000000000000000000000000-00f067aa0ba902b7-01", "00-4bf92f3577b34da6a3ce929d0e0e4736"] {
assert!(with(bad).is_none(), "{bad}");
}
assert!(remote_parent(&HeaderMap::new(), None).is_none());
// From the query, where a beacon or Safari's player cannot send a header (#192).
let q = remote_parent(&HeaderMap::new(), Some("x=1&traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"));
assert_eq!(q.unwrap().span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
assert!(remote_parent(&HeaderMap::new(), Some("traceparent=nonsense")).is_none());
}
#[tokio::test]
async fn the_page_reports_only_what_it_is_meant_to() {
let u = crate::db::User { id: 1, name: "dee".into(), pass_hash: None, is_admin: false, created: None, last_login: None };
let e = |ev: &str| ClientEvent {
ev: ev.into(), msg: Some("x".repeat(10_000)), src: None, line: None, feed: None, enclosure: None, deadair: None, ms: Some(5),
};
for ev in ["page_error", "rejection", "player_error", "play_start", "stall"] {
assert_eq!(client_event(u.clone(), Json(e(ev))).await, StatusCode::NO_CONTENT, "{ev}");
}
assert_eq!(client_event(u, Json(e("anything"))).await, StatusCode::BAD_REQUEST);
}
#[test] #[test]
fn only_the_last_admin_is_protected() { fn only_the_last_admin_is_protected() {
let u = |id, is_admin| crate::db::User { let u = |id, is_admin| crate::db::User {
@@ -1366,18 +1504,42 @@ async fn entry_page(
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty()); let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
let db = &state.ctx.db; let db = &state.ctx.db;
let order = crate::db::order_sql(page.sort.as_deref().unwrap_or("published"), page.dir.as_deref().unwrap_or("desc")); let order = crate::db::order_sql(page.sort.as_deref().unwrap_or("published"), page.dir.as_deref().unwrap_or("desc"));
let mut rows = let mut rows = db
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?; .entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)
.instrument(step("entries_in"))
.await?;
step("sanitize").in_scope(|| {
let mut sanitizer = feed_sanitizer(); let mut sanitizer = feed_sanitizer();
for row in &mut rows { for row in &mut rows {
if let Some(d) = &row.description { if let Some(d) = &row.description {
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref())); row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
} }
} }
let total = db.count_in(user_id, feed, filter, search).await?; });
crate::deadair::for_reader(&state.ctx, user_id, &mut rows).instrument(step("deadair")).await?;
let total = db.count_in(user_id, feed, filter, search).instrument(step("count_in")).await?;
Ok(Json(EntryPage { total, entries: rows })) 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. /// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
fn feed_sanitizer() -> ammonia::Builder<'static> { fn feed_sanitizer() -> ammonia::Builder<'static> {
let mut b = ammonia::Builder::new(); let mut b = ammonia::Builder::new();
@@ -1429,7 +1591,6 @@ async fn add_feed(
user: crate::db::User, user: crate::db::User,
Json(body): Json<NewFeed>, Json(body): Json<NewFeed>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> 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 // 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. // 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)) let url = crate::feed::find_feed(&state.ctx.client, &crate::feed::expand_input(&body.url))
@@ -1437,26 +1598,14 @@ async fn add_feed(
.map_err(|e| ApiError::bad_request(format!("{e:#}")))?; .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
// Someone else may already have it. Then adding costs nothing: no second fetch, no // 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. // second copy on disk, just another name against the same feed.
if let Some(existing) = crate::subscriptions(&state.ctx).await? let (id, existed) = crate::add_to_catalogue(&state.ctx, &url, body.folder, body.keywords).await?;
.into_iter() let already = existed && state.ctx.db.subscription(user.id, &id).await?.is_some();
.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?;
state.ctx.db.subscribe(user.id, &id).await?; state.ctx.db.subscribe(user.id, &id).await?;
if !already {
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?; explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
}
scan_soon(&state, Some(id.clone())).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. /// Queues a scan, so a feed just added shows its items without anyone pressing Scan now.
@@ -1490,6 +1639,12 @@ struct FeedPatch {
#[serde(default, deserialize_with = "double_option")] #[serde(default, deserialize_with = "double_option")]
max_new_per_check: Option<Option<usize>>, max_new_per_check: Option<Option<usize>>,
pinned: Option<bool>, pinned: Option<bool>,
/// True plays the feed shortened by DeadAir, 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>>,
/// The pause DeadAir leaves where it cuts, in ms: one of `deadair::PAUSES` (#182).
deadair_pause: Option<i64>,
} }
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is /// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
@@ -1527,14 +1682,21 @@ async fn patch_feed(
{ {
return Err(ApiError::not_found("you do not subscribe to that feed")); return Err(ApiError::not_found("you do not subscribe to that feed"));
} }
if let Some(p) = body.deadair_pause
&& !crate::deadair::PAUSES.contains(&p)
{
return Err(ApiError::bad_request("DeadAir's pause is 200, 300, 400 or 500 ms"));
}
if let Some(wanted) = body.deadair {
state.ctx.db.set_deadair(user.id, &id, wanted).await?;
state.ctx.deadair.notify_one();
}
if let Some(p) = body.deadair_pause {
state.ctx.db.set_deadair_pause(user.id, &id, p).await?;
}
// What one person wants -- which items, whether to fetch them, how many at a time -- // 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. // is theirs. It goes on their subscription and nobody else sees the change.
if state.ctx.db.subscription(user.id, &id).await?.is_some() { if let Some(mut mine) = state.ctx.db.subscription(user.id, &id).await? {
let mut mine = state
.ctx
.db
.subscription(user.id, &id).await?
.unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() });
let mut touched = false; let mut touched = false;
if let Some(v) = body.keywords.clone() { if let Some(v) = body.keywords.clone() {
mine.keywords = Some(v.into_iter().filter(|k| !k.trim().is_empty()).collect()); mine.keywords = Some(v.into_iter().filter(|k| !k.trim().is_empty()).collect());
@@ -1572,7 +1734,7 @@ async fn patch_feed(
"the feed's address, folder, schedule and category are the same for everyone, so only an admin changes them", "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 // 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. // entry: promote it, so the config holds your decisions and nothing else.
@@ -1633,34 +1795,7 @@ async fn remove_feed(
Path(id): Path<String>, Path(id): Path<String>,
user: crate::db::User, user: crate::db::User,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
// Unsubscribing is personal: it takes the feed off your list and leaves everyone crate::leave(&state.ctx, user.id, &id).await?;
// 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. A feed an
// admin listed stays in the Directory, for the next person.
let mut cfg = (*state.ctx.cfg()).clone();
if cfg.feeds.get(&id).is_some_and(|f| f.listed) {
return Ok(StatusCode::NO_CONTENT);
}
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?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -1700,7 +1835,7 @@ async fn download_now(
.ctx .ctx
.db .db
.enclosure(id).await? .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?; subscribed(&state, &user, &enc.feed_id).await?;
if enc.path.is_some() { if enc.path.is_some() {
return Ok(StatusCode::NO_CONTENT); // Already here. return Ok(StatusCode::NO_CONTENT); // Already here.
@@ -1730,7 +1865,7 @@ async fn delete_file(
.ctx .ctx
.db .db
.enclosure(id).await? .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?; subscribed(&state, &user, &enc.feed_id).await?;
// There is one copy of the file: deleting it deletes everyone's. Say so before doing // There is one copy of the file: deleting it deletes everyone's. Say so before doing
@@ -1756,7 +1891,7 @@ async fn delete_file(
} }
} }
if let Some(path) = &enc.path 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 && e.kind() != std::io::ErrorKind::NotFound
{ {
return Err(e.into()); return Err(e.into());
@@ -1824,7 +1959,10 @@ async fn events(
} }
return Some((futures_util::stream::iter(out), (rx, state, user))); return Some((futures_util::stream::iter(out), (rx, state, user)));
} }
Err(broadcast::error::RecvError::Lagged(_)) => {} // Counted for /metrics: a page that misses updates shows stale counts (#195).
Err(broadcast::error::RecvError::Lagged(n)) => {
crate::metrics::STATS.events_missed.fetch_add(n, std::sync::atomic::Ordering::Relaxed);
}
Err(broadcast::error::RecvError::Closed) => return None, Err(broadcast::error::RecvError::Closed) => return None,
} }
} }
@@ -1832,15 +1970,15 @@ async fn events(
Sse::new(futures_util::StreamExt::flatten(stream)).keep_alive(axum::response::sse::KeepAlive::default()) Sse::new(futures_util::StreamExt::flatten(stream)).keep_alive(axum::response::sse::KeepAlive::default())
} }
/// The feed an event changed what the list shows of: its counts, error or last check. Not the /// The feed an event changed what the list shows of: its counts, error or last check. The
/// routine skip of a feed not due, dozens a minute that change nothing. /// routine skip of a feed not due never reaches here (`Emitter::emit`).
fn changed_feed(ev: &Event) -> Option<&str> { fn changed_feed(ev: &Event) -> Option<&str> {
match ev { match ev {
Event::FeedDone { feed, .. } Event::FeedDone { feed, .. }
| Event::FeedError { feed, .. } | Event::FeedError { feed, .. }
| Event::FeedMoved { feed, .. } | Event::FeedMoved { feed, .. }
| Event::DownloadDone { feed, .. } => Some(feed), | Event::DownloadDone { feed, .. } => Some(feed),
Event::FeedSkip { feed, reason } if !reason.starts_with("not due") => Some(feed), Event::FeedSkip { feed, .. } => Some(feed),
_ => None, _ => None,
} }
} }
@@ -1852,6 +1990,38 @@ async fn media(
user: crate::db::User, user: crate::db::User,
req: Request, req: Request,
) -> Response { ) -> Response {
serve_media(state, id, user, req, false).await
}
/// DeadAir's playlist of a file (#181): the original's frames kept by this person's pause
/// (#182), as byte ranges of `deadair.mp3` beside it. At an address of its own, so a player that
/// had part of one version cached never stitches the two together.
async fn media_deadair(State(state): State<WebState>, Path(id): Path<i64>, user: crate::db::User) -> Response {
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
};
if let Err(e) = subscribed(&state, &user, &enc.feed_id).await {
return e.into_response();
}
let plan = match crate::deadair::plan_for(&state.ctx, user.id, &enc.feed_id, id).instrument(step("plan")).await {
Ok(Some(p)) => p,
Ok(None) => return (StatusCode::NOT_FOUND, "DeadAir has nothing to cut in this file for you").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response(),
};
let body = crate::deadair::playlist(&plan, "deadair.mp3");
// Which version this person is playing, so the positions their player saves are read in its
// clock (`deadair::on_save`).
state.playing.lock().unwrap().insert((user.id, enc.feed_id, enc.guid), Arc::new(plan));
// Never kept: it changes with the pause they choose.
([(header::CONTENT_TYPE, "application/vnd.apple.mpegurl"), (header::CACHE_CONTROL, "no-store")], body)
.into_response()
}
/// A file without its dead air, to keep (#186): what the save button fetches for someone who
/// plays it shortened.
// shortcut: no Range requests, so a save that breaks off starts again; fine for a button that
// saves, add them if a player is ever pointed here.
async fn media_save(State(state): State<WebState>, Path(id): Path<i64>, user: crate::db::User) -> Response {
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else { let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response(); return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
}; };
@@ -1861,8 +2031,65 @@ async fn media(
let Some(path) = enc.path else { let Some(path) = enc.path else {
return (StatusCode::NOT_FOUND, "not downloaded").into_response(); return (StatusCode::NOT_FOUND, "not downloaded").into_response();
}; };
match ServeFile::new(path).oneshot(req).await { let plan = match crate::deadair::plan_for(&state.ctx, user.id, &enc.feed_id, id).instrument(step("plan")).await {
Ok(r) => r.into_response(), Ok(Some(p)) => p,
Ok(None) => return (StatusCode::NOT_FOUND, "DeadAir has nothing to cut in this file for you").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response(),
};
let path = crate::download::playable(std::path::Path::new(&path));
let (len, body) = match crate::deadair::kept(&path, &plan).await {
Ok(k) => k,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let stem = path.file_stem().map_or("episode".into(), |s| s.to_string_lossy());
let ext = path.extension().map_or("mp3".into(), |s| s.to_string_lossy());
let name = format!("{stem} (DeadAir).{ext}");
let name = percent_encoding::utf8_percent_encode(&name, percent_encoding::NON_ALPHANUMERIC);
let kind = if ext.eq_ignore_ascii_case("aac") { "audio/aac" } else { "audio/mpeg" };
(
[
(header::CONTENT_TYPE, kind.to_owned()),
(header::CONTENT_LENGTH, len.to_string()),
(header::CONTENT_DISPOSITION, format!("attachment; filename*=UTF-8''{name}")),
(header::CACHE_CONTROL, "private".to_owned()),
],
axum::body::Body::from_stream(body),
)
.into_response()
}
/// The original's bytes, for the pieces of DeadAir's playlist.
async fn media_piece(
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, piece: bool) -> Response {
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
};
if let Err(e) = subscribed(&state, &user, &enc.feed_id).await {
return e.into_response();
}
let Some(path) = enc.path else {
return (StatusCode::NOT_FOUND, "not downloaded").into_response();
};
// The original itself: positions this person's player saves are in its clock again. A piece
// of the playlist leaves that as the playlist set it.
if !piece {
state.playing.lock().unwrap().remove(&(user.id, enc.feed_id.clone(), enc.guid.clone()));
}
match ServeFile::new(crate::download::playable(std::path::Path::new(&path))).oneshot(req).await {
Ok(mut r) => {
// Never in a shared cache: Cloudflare kept `deadair.mp3`, by its extension, and served
// its copy to anyone Access let in, without the subscriber check above (#189).
r.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private"));
r.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
} }
@@ -1876,10 +2103,11 @@ struct ArtQuery {
/// time it is fetched from the publisher and kept on disk (`art`, up to `art_cache_mb`), so /// 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 /// 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 /// server. Only an address some feed or item names as its artwork, and only an image up to
/// 5 MB, so the route cannot be pointed at anything else. It began as a way round http-only /// 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). /// artwork on the https page (#90).
async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Response { async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Response {
const MAX: usize = 5 << 20; // 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://"); 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) { if !web || !state.ctx.db.names_image(&q.u).await.unwrap_or(false) {
return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response(); return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response();
@@ -1887,8 +2115,13 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
let keep = state.ctx.cfg().general.art_cache_mb > 0; let keep = state.ctx.cfg().general.art_cache_mb > 0;
let file = crate::art::path(&q.u); let file = crate::art::path(&q.u);
if keep && let Some((kind, body)) = crate::art::read(&file) { if keep && let Some((kind, body)) = crate::art::read(&file) {
crate::metrics::STATS.art_hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return art_response(kind, body); return art_response(kind, body);
} }
if let Some(why) = crate::art::failed(&q.u) {
return (StatusCode::BAD_GATEWAY, why).into_response();
}
crate::metrics::STATS.art_misses.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let got = async { let got = async {
let mut r = state.ctx.client.get(&q.u).timeout(crate::feed::FEED_TIMEOUT).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(); let kind = r.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
@@ -1907,7 +2140,13 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
} }
art_response(kind, body) art_response(kind, body)
} }
Err(e) => (StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(), 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()
}
} }
} }
@@ -1939,7 +2178,9 @@ async fn set_position(
user: crate::db::User, user: crate::db::User,
Json(body): Json<Position>, Json(body): Json<Position>,
) -> Result<StatusCode, ApiError> { ) -> 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) Ok(StatusCode::NO_CONTENT)
} }
@@ -2128,7 +2369,7 @@ async fn patch_settings(
if !user.is_admin { if !user.is_admin {
return Err(ApiError::forbidden("only an admin changes these settings")); 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 { if let Some(sched) = body.schedule {
let sched = sched.trim().to_owned(); let sched = sched.trim().to_owned();
if crate::config::parse_interval(&sched).is_none() { if crate::config::parse_interval(&sched).is_none() {
@@ -2189,6 +2430,65 @@ async fn logs(user: crate::db::User, Query(q): Query<LogQuery>) -> Result<Json<L
Ok(Json(LogPage { lines, latest })) Ok(Json(LogPage { lines, latest }))
} }
/// What the page reports from the browser (#190): its errors and its player's, and how long play
/// took to start or stalled for. Logged as `ev` lines under `ipx::client`, inside the request's
/// span, so a line carries the trace of the action that led to it.
#[derive(Deserialize)]
struct ClientEvent {
ev: String,
msg: Option<String>,
src: Option<String>,
line: Option<u32>,
feed: Option<String>,
enclosure: Option<i64>,
deadair: Option<bool>,
ms: Option<u64>,
}
async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> StatusCode {
// From the browser: only the kinds the page sends, and nothing long enough to fill the log.
let cut = |s: &Option<String>| s.as_deref().map(|s| s.chars().take(500).collect::<String>());
let (msg, src, feed) = (cut(&e.msg), cut(&e.src), cut(&e.feed));
let (ev, line, enclosure, deadair, ms, user) = (e.ev.as_str(), e.line, e.enclosure, e.deadair, e.ms, user.name.as_str());
match ev {
"page_error" | "rejection" | "player_error" => tracing::warn!(
target: "ipx::client", ev, msg, src, line, feed, enclosure, deadair, user,
"{user}'s page: {ev}: {}", msg.as_deref().unwrap_or("")
),
"play_start" | "stall" => tracing::info!(
target: "ipx::client", ev, ms, feed, enclosure, deadair, user,
"{user}'s page: {ev} {} ms", ms.unwrap_or(0)
),
_ => return StatusCode::BAD_REQUEST,
}
StatusCode::NO_CONTENT
}
/// The trace a request from the page belongs to, from its `traceparent` (#190), so the requests
/// one action sets off are one trace. None for a missing or malformed one. In the query where no
/// header can be set: a position saved by sendBeacon, and the playlist Safari's player fetches
/// itself (#192).
fn remote_parent(headers: &HeaderMap, query: Option<&str>) -> Option<opentelemetry::Context> {
use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState};
let v = match headers.get("traceparent") {
Some(h) => h.to_str().ok()?,
None => query?.split('&').find_map(|kv| kv.strip_prefix("traceparent="))?,
};
let mut parts = v.split('-');
let (version, trace, span) = (parts.next()?, parts.next()?, parts.next()?);
if version != "00" || trace.len() != 32 || span.len() != 16 {
return None;
}
let sc = SpanContext::new(
TraceId::from_hex(trace).ok()?,
SpanId::from_hex(span).ok()?,
TraceFlags::SAMPLED,
true,
TraceState::default(),
);
sc.is_valid().then(|| opentelemetry::Context::new().with_remote_span_context(sc))
}
/// Names a request's trace by its route, `POST /api/entries/{feed_id}/{guid}/flags`, once routing /// Names a request's trace by its route, `POST /api/entries/{feed_id}/{guid}/flags`, once routing
/// has found it. Named by the path `access_log` sees, every item's GUID was a trace name of its /// has found it. Named by the path `access_log` sees, every item's GUID was a trace name of its
/// own, and nothing grouped. A route layer, because only one runs after routing. Renamed on the /// own, and nothing grouped. A route layer, because only one runs after routing. Renamed on the
@@ -2228,8 +2528,14 @@ async fn access_log(req: Request, next: Next) -> Response {
http.response.status_code = tracing::field::Empty, http.response.status_code = tracing::field::Empty,
) )
}; };
if let Some(parent) = remote_parent(req.headers(), req.uri().query()) {
use tracing_opentelemetry::OpenTelemetrySpanExt;
let _ = span.set_parent(parent);
}
let started = std::time::Instant::now(); let started = std::time::Instant::now();
let in_flight = crate::metrics::busy(&crate::metrics::STATS.http_in_flight);
let resp = tracing::Instrument::instrument(next.run(req), span.clone()).await; let resp = tracing::Instrument::instrument(next.run(req), span.clone()).await;
drop(in_flight);
span.record("http.response.status_code", resp.status().as_u16()); span.record("http.response.status_code", resp.status().as_u16());
if !quiet { if !quiet {
let duration_ms = started.elapsed().as_millis() as u64; let duration_ms = started.elapsed().as_millis() as u64;

View File

@@ -47,7 +47,7 @@ function makeContext(html, script, { media = false } = {}) {
}; };
const ctx = { const ctx = {
document, console, document, console, crypto: require("crypto").webcrypto, performance,
window: { isSecureContext: false, addEventListener(){} }, window: { isSecureContext: false, addEventListener(){} },
localStorage: { getItem: () => null, setItem(){}, removeItem(){} }, localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined }, navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
@@ -66,6 +66,7 @@ function makeContext(html, script, { media = false } = {}) {
EventSource: function () { this.close = () => {}; }, EventSource: function () { this.close = () => {}; },
MediaMetadata: function () {}, MediaMetadata: function () {},
Blob: function () {}, Blob: function () {},
getComputedStyle: () => ({ backgroundColor: '' }),
setTimeout, clearTimeout, setInterval, clearInterval, setTimeout, clearTimeout, setInterval, clearInterval,
confirm: () => false, prompt: () => null, alert(){}, confirm: () => false, prompt: () => null, alert(){},
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){}, Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},

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 page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('#list')).toBeVisible(); await expect(page.locator('#list')).toBeVisible();
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider 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 page.locator('.ep', { hasText: 'First Episode' }).click();
await expect(page.locator('#detail .dt')).toHaveText('First Episode'); await expect(page.locator('#detail .dt')).toHaveText('First Episode');
@@ -1429,15 +1429,17 @@ test('a failing feed is marked on its artwork and says why', async ({ page }) =>
test.describe('touch gestures on a phone', () => { test.describe('touch gestures on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol. // Playwright's touchscreen only taps; a drag goes through the DevTools protocol. Sent all at
// once, in order: awaited one by one, a busy machine stretched a drag past the 800 ms a swipe
// has, and it was taken for a slow drag and went nowhere (#184).
async function drag(page, from, to, lift = true) { async function drag(page, from, to, lift = true) {
const cdp = await page.context().newCDPSession(page); const cdp = await page.context().newCDPSession(page);
const steps = 8; const steps = 8, touch = (type, touchPoints) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] }); const sent = [touch('touchStart', [from])];
for (let i = 1; i <= steps; i++) for (let i = 1; i <= steps; i++)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ sent.push(touch('touchMove', [{ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }]));
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] }); if (lift) sent.push(touch('touchEnd', []));
if (lift) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await Promise.all(sent);
return cdp; return cdp;
} }
@@ -1656,3 +1658,183 @@ test('a refresh button turns while what it checks is being checked', async ({ pa
await page.evaluate(() => setScanning('multi-show', false)); await page.evaluate(() => setScanning('multi-show', false));
expect(await spin('#scanAll .i')).toBe('none'); expect(await spin('#scanAll .i')).toBe('none');
}); });
test.describe('on a narrow phone', () => {
test.use({ viewport: { width: 360, height: 760 } });
test("a feed's buttons stay on one row, and the bar round the island is the page's colour", async ({ page }) => {
// Seven 44px buttons did not fit 360px, and Unsubscribe went onto a row of its own (#146).
await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).first().click();
const tops = await page.locator('#content .acts .btn').evaluateAll(bs => bs.map(b => b.getBoundingClientRect().top));
expect(tops.length).toBe(7);
expect(new Set(tops).size).toBe(1);
// iOS 26 paints behind the status bar and the Dynamic Island in the colour of a sticky bar at
// the top edge, older ones in theme-color: both the top bar's, which is the page's (#147).
const bar = await page.locator('#topbar').evaluate(b => [getComputedStyle(b).position, b.getBoundingClientRect().top, getComputedStyle(b).backgroundColor]);
expect(bar.slice(0, 2)).toEqual(['sticky', 0]);
expect(bar[2]).toBe(await page.evaluate(() => getComputedStyle(document.body).backgroundColor));
expect(await page.evaluate(() => document.querySelector('meta[name=theme-color]').content)).toBe(bar[2]);
// Glass's bar is see-through on a desktop, which no flat strip can match: solid on a phone.
for (const mode of ['dark', 'light']) {
const [c, tc] = await page.evaluate(m => { setTheme('glass', m);
return [getComputedStyle(document.querySelector('#topbar')).backgroundColor, document.querySelector('meta[name=theme-color]').content]; }, mode);
expect(c).toMatch(/^rgb\(/);
expect(tc).toBe(c);
}
await page.evaluate(() => setTheme('modern', 'dark'));
});
});
test('a page brought back into view catches up with what was read elsewhere', async ({ page }) => {
// Reading an item tells no other page, and a phone's page misses everything while it is in the
// background: All Subscriptions went on counting an item read elsewhere as unread (#168).
const badge = page.locator('.place[data-id=":all"] .badge');
const [one] = (await (await page.request.get('/api/entries?limit=1')).json()).entries;
const flag = read => page.request.post(`/api/entries/${encodeURIComponent(one.feed_id)}/${encodeURIComponent(one.guid)}/flags`, { data: { read } });
await flag(false);
await page.reload();
const before = Number(await badge.textContent());
expect(before).toBeGreaterThan(0);
await flag(true); // read on another device
await page.evaluate(() => {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
document.dispatchEvent(new Event('visibilitychange'));
});
await expect(badge).toHaveText(String(before - 1));
});
test('a feed\'s settings say what downloads, take words as chips, and keep what is saved', async ({ page }) => {
// The redesigned dialog (#167): the download choice, word chips, the fold, and a save that holds.
const open = async () => {
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
await page.locator('#content .acts [data-a="settings"]').click();
await expect(page.locator('#modalCard.fset')).toBeVisible();
};
await open();
// The strip is the feed's own items, and Newest 5 lights every one of its two.
await expect(page.locator('#sepwin i')).toHaveCount(2);
await page.locator('.seg label', { hasText: 'Newest' }).click();
await page.locator('#smax').fill('5');
await expect(page.locator('#sepwin i.on')).toHaveCount(2);
await expect(page.locator('#swcap')).toContainText('Downloads the newest');
// Words become chips with Enter or a comma, and come off with their x.
await page.locator('#skwin').fill('first, second');
await page.locator('#skwin').press('Enter');
await expect(page.locator('#skw .chip')).toHaveCount(2);
await page.locator('#skw .chip', { hasText: 'second' }).locator('.chipx').click();
await expect(page.locator('#skw .chip')).toHaveCount(1);
await page.locator('#sblockin').fill('spoiler');
await page.locator('#sblockin').press('Enter');
// What only automatic downloads use folds away with them.
await page.locator('.switch', { hasText: 'Download automatically' }).click();
await expect(page.locator('#sdeps')).toHaveClass(/\boff\b/);
await page.locator('.switch', { hasText: 'Download automatically' }).click();
await expect(page.locator('#sdeps')).not.toHaveClass(/\boff\b/);
await page.locator('#ssave').click();
await expect(page.locator('#modal.on')).toBeHidden();
// Saved: open again and it is all there.
await open();
await expect(page.locator('[name=swin][value=newest]')).toBeChecked();
await expect(page.locator('#smax')).toHaveValue('5');
await expect(page.locator('#skw .chip')).toHaveText(['first']);
await expect(page.locator('#sblock .chip')).toHaveText(['spoiler']);
// Back as it was, for the tests after this one.
await page.locator('.seg label', { hasText: 'Default' }).click();
await page.locator('#skw .chipx').click();
await page.locator('#sblock .chipx').click();
await page.locator('#ssave').click();
await expect(page.locator('#modal.on')).toBeHidden();
await open();
await expect(page.locator('[name=swin][value=default]')).toBeChecked();
await expect(page.locator('#skw .chip, #sblock .chip')).toHaveCount(0);
await page.keyboard.press('Escape');
});
test('DeadAir is off until turned on for a show, then plays it shortened, with your pause, and switches back', async ({ page }) => {
// #171/#172. A show of its own, with real audio, added here and left at the end.
const { id } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } })).json();
const entry = async () => {
const r = await page.request.get(`/api/feeds/${encodeURIComponent(id)}/entries`);
return (await r.json()).entries?.[0];
};
await expect.poll(async () => (await entry())?.guid, { timeout: 15_000 }).toBe('quiet-1');
await page.request.post(`/api/feeds/${encodeURIComponent(id)}/download-latest`, { data: { count: 1 } });
await expect.poll(async () => !!(await entry())?.enclosures[0]?.path, { timeout: 15_000 }).toBe(true);
// Off for a show until someone turns it on: nothing is cut.
expect((await entry()).enclosures[0].deadair).toBeUndefined();
await page.reload();
// On, in the show's settings.
await page.locator('#feedlist .feed', { hasText: 'Quiet Show' }).first().click();
await page.locator('#content .acts [data-a="settings"]').click();
const sw = page.locator('#sdead');
await expect(sw).not.toBeChecked();
await sw.locator('xpath=..').click();
await page.locator('#ssave').click();
// Found at once, not at the next pass, and the row says so when it is done without a reload:
// "Cutting dead air…" until then, then about 1.7 s off each of four 2 s silences.
const row = page.locator('.ep', { hasText: 'Pauses Between' });
await expect(row).toContainText('s shorter', { timeout: 20_000 });
expect((await entry()).enclosures[0].deadair).toBeGreaterThan(5);
await expect(row).not.toContainText('Cutting dead air');
// A longer pause of your own (#182) saves less.
const usual = (await entry()).enclosures[0].deadair;
await page.locator('#content .acts [data-a="settings"]').click();
await expect(page.locator('#sdpause')).toHaveValue('300');
await page.locator('#sdpause').fill('500');
await page.locator('#ssave').click();
await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual);
// Saved without its dead air too (#186): smaller than the original, named to say so.
const saved = await page.evaluate(async id => {
const [d, o, p] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`), fetch(`/media/${id}/deadair.mp3`, { headers: { Range: 'bytes=0-99' } })]);
return { status: d.status, name: d.headers.get('content-disposition'), size: (await d.arrayBuffer()).byteLength, full: (await o.arrayBuffer()).byteLength,
// Kept out of shared caches, or Cloudflare serves its own copy to anyone (#189).
caching: [d, o, p].map(r => r.headers.get('cache-control')), piece: p.status };
}, (await entry()).enclosures[0].id);
expect(saved.status).toBe(200);
expect(saved.name).toContain('DeadAir');
expect(saved.size).toBeLessThan(saved.full);
expect(saved.caching).toEqual(['private', 'private', 'private']);
expect(saved.piece).toBe(206);
// Played through DeadAir's playlist (#181), by hls.js in Chromium, with the switch beside the
// speed pressed. The element's own src is then hls.js's blob: address.
// How long it took to start goes to the server's log, under the trace of the tap (#190).
const started = page.waitForRequest(r => r.url().endsWith('/api/client') && r.postDataJSON()?.ev === 'play_start');
const piece = page.waitForRequest(r => r.url().endsWith('/deadair.mp3'));
await row.locator('[data-a=play]').click();
await expect.poll(() => page.evaluate(() => player.src)).toMatch(/\/deadair\.m3u8$/);
const report = await started;
expect(report.postDataJSON()).toMatchObject({ deadair: true });
expect(report.postDataJSON().ms).toBeGreaterThan(0);
expect(report.headers().traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
// hls.js's own requests carry the same trace as the tap that played them (#192).
const traceOf = h => h.traceparent?.split('-')[1];
expect(traceOf((await piece).headers())).toBe(traceOf(report.headers()));
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').currentTime), { timeout: 15_000 }).toBeGreaterThan(0.5);
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'true');
// Off from the player: the original, from the same place.
await page.locator('#pdead').click();
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'false');
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').getAttribute('src'))).toMatch(/\/media\/\d+$/);
expect((await entry()).enclosures[0].deadair).toBeUndefined();
await page.locator('#pclose').click();
await page.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
});
test('a downloaded file\'s buttons sit on one row in the files pane', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
await expect(page.locator('.ep').first()).toBeVisible();
await page.evaluate(() => showDetail(S.entries.find(e => e.enclosures.some(x => x.path))));
const box = page.locator('#files .encbox').first();
await expect(box.locator('.btn')).toHaveCount(4);
const tops = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
expect(new Set(tops).size, 'play, save, share and delete wrapped onto two rows').toBe(1);
});

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

@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#0a1726">
<title>iPX admin</title> <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="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
@@ -13,7 +14,7 @@
<!-- The server sends this page, and its script, to admins only. --> <!-- The server sends this page, and its script, to admins only. -->
<header id="topbar"> <header id="topbar">
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a> <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> <h1>Admin</h1>
<span class="grow"></span> <span class="grow"></span>
<nav class="tabs" id="atabs"> <nav class="tabs" id="atabs">

View File

@@ -426,6 +426,7 @@
--glass-hi:rgba(255,255,255,.26); --glass-hi:rgba(255,255,255,.26);
--glass-lo:rgba(255,255,255,.10); --glass-lo:rgba(255,255,255,.10);
--glass-sheen:rgba(255,255,255,.07); --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); --wash1:rgba(64,120,255,.30);
--wash2:rgba(175,82,222,.24); --wash2:rgba(175,82,222,.24);
--wash3:rgba(48,176,199,.20); --wash3:rgba(48,176,199,.20);
@@ -450,6 +451,7 @@
--glass-hi:rgba(255,255,255,.9); --glass-hi:rgba(255,255,255,.9);
--glass-lo:rgba(255,255,255,.45); --glass-lo:rgba(255,255,255,.45);
--glass-sheen:rgba(255,255,255,.5); --glass-sheen:rgba(255,255,255,.5);
--glass-bar:#f6f7ff;
--wash1:rgba(64,120,255,.22); --wash1:rgba(64,120,255,.22);
--wash2:rgba(175,82,222,.16); --wash2:rgba(175,82,222,.16);
--wash3:rgba(48,176,199,.18); --wash3:rgba(48,176,199,.18);
@@ -458,6 +460,10 @@
/* A rule that sets display beats the UA's [hidden], and several below do. */ /* A rule that sets display beats the UA's [hidden], and several below do. */
[hidden]{display:none!important} [hidden]{display:none!important}
html,body{height:100%} 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{ body{
margin:0;background:var(--bg);color:var(--fg); margin:0;background:var(--bg);color:var(--fg);
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; 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 /* 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. */ Auto, data-mode arrives with the script, and until then the page is drawn dark. */
:root[data-mode="light"] .logo.ondark,:root:not([data-mode="light"]) .logo.onlight{display:none} :root[data-mode="light"] .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{ .iconbtn{
width:30px;height:30px;border-radius:8px;display:grid;place-items:center; width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
color:var(--dim);flex:none; color:var(--dim);flex:none;
@@ -522,6 +531,10 @@ a{color:var(--accent)}
padding-top:calc(7px + var(--safe-t));padding-bottom:7px; padding-top:calc(7px + var(--safe-t));padding-bottom:7px;
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r)); padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
background:var(--panel);border-bottom:1px solid var(--line); 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 .grow{flex:1}
#topbar #epSearch{width:260px;flex:none} #topbar #epSearch{width:260px;flex:none}
@@ -717,6 +730,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
.show .meta{min-width:0;flex:1;display:flex;flex-direction:column;align-items:flex-start;gap:5px} .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 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 .sub{margin:0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums}
.show .dfail{color:var(--bad)}
.show .acts{margin-top:8px} .show .acts{margin-top:8px}
.dlink{padding:0;font-size:13px;color:var(--accent);text-align:left} .dlink{padding:0;font-size:13px;color:var(--accent);text-align:left}
.dlink:hover{text-decoration:underline} .dlink:hover{text-decoration:underline}
@@ -792,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)} #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. */ /* Nothing selected, or an item with no files: the list takes the width. */
#split:has(>#files[hidden]){grid-template-columns:minmax(0,1fr) 0} #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} #files .empty{padding:24px 0}
.fhd{font-size:12px;color:var(--faint)} .fhd{font-size:12px;color:var(--faint)}
#grab{grid-area:grab;cursor:row-resize;background:var(--line)} #grab{grid-area:grab;cursor:row-resize;background:var(--line)}
@@ -801,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} .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{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} .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} #detail .encbox{margin:0 0 10px}
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)} .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} .dbody img{max-width:100%;height:auto;border-radius:6px}
@@ -817,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{color:var(--dim);font-size:13px;margin-bottom:8px}
.fhead .sub a{color:var(--dim);text-decoration:none} .fhead .sub a{color:var(--dim);text-decoration:none}
.fhead .sub a:hover{color:var(--accent)} .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{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 i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
.acts .btn:hover{background:var(--raise)} .acts .btn:hover{background:var(--raise)}
@@ -912,6 +933,11 @@ body.adminpage #log{max-width:none}
the row does not move when it changes. */ 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,.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} .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 /* 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. */ 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} .eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
@@ -1033,6 +1059,93 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.inline input[type=number]{flex:none;width:90px} .inline input[type=number]{flex:none;width:90px}
.check input{width:16px;height:16px;accent-color:var(--accent)} .check input{width:16px;height:16px;accent-color:var(--accent)}
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px} .cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
/* ---------- a feed's settings (#167) ---------- */
/* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been
three screens down. */
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(520px,100%)}
.fsetbody{overflow:auto;min-height:0;padding:22px 24px 24px}
.card.fset .fsetfoot{flex:none;margin:0;padding:12px 24px;border-top:1px solid var(--line)}
.fsethead{display:flex;gap:14px;align-items:center;margin-bottom:18px}
.fsethead .art{width:56px;height:56px;border-radius:12px;font-size:18px}
.fsethead h3{margin:0;font-size:19px;line-height:1.25;letter-spacing:-.01em;overflow-wrap:anywhere}
.fsethead p{margin:3px 0 0;font-size:12.5px;line-height:1.45;color:var(--dim)}
.faddr{margin-bottom:18px}
.moved{margin:2px 0 0;font-size:13px;color:var(--accent)}
/* On or off, drawn as iOS draws it, as the app is on a phone (#53). The box is the real control,
unseen over the track, so a click, a tap, the keyboard and a screen reader all reach it. */
.switch{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:38px;font-size:14px;font-weight:500;cursor:pointer}
/* The one switch the rest hang from. */
.switch.lead{font-weight:600}
.sw{position:relative;flex:none;width:42px;height:26px}
.sw input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
.sw i{position:absolute;inset:0;border-radius:999px;background:var(--raise);border:1px solid var(--line);pointer-events:none;transition:background-color .18s,border-color .18s}
/* White, as iOS has it, in every theme: in --ink, the dark themes' knob was a black hole in the track. */
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35);transition:transform .18s}
.sw input:checked+i{background:var(--accent);border-color:var(--accent)}
.sw input:checked+i::after{transform:translateX(16px)}
.sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
/* The switch and what only matters while it downloads on its own, in one box; the rest folds away
when it is off. In --line, not --edge, so the box shows on a phone, which draws no outlines. The
rows in a grid of one row, 1fr open and 0fr shut, so the fold has a height to move. */
.fdl{border:1px solid var(--line);border-radius:12px;padding:4px 14px}
/* The same box round a part of a dialog that stands apart: API tokens in Settings. */
.field.boxed{border:1px solid var(--line);border-radius:12px;padding:12px 14px}
.fdeps{display:grid;grid-template-rows:1fr;transition:grid-template-rows .22s ease}
.fdeps.off{grid-template-rows:0fr}
.fdeps>div{min-height:0;overflow:hidden}
.fdepsin{padding:6px 2px 10px}
/* The app's own segmented control, with radio buttons in it, for the arrow keys. */
.seg{margin-bottom:12px}
.seg label{flex:1;position:relative;text-align:center;white-space:nowrap;padding:5px 10px;border-radius:7px;font-size:13px;color:var(--dim);cursor:pointer}
.seg input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
.seg label:has(input:checked){background:var(--raise);color:var(--fg);font-weight:500}
.seg label:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:-2px}
/* The feed's newest items, newest first, each as tall as it is long; lit, those that download on
their own. Dragged along, it sets how many. Only drawing: the number in the sentence below is
what a keyboard and a screen reader use. */
.epwin{display:flex;align-items:flex-end;gap:2px;height:38px;margin-bottom:8px;cursor:ew-resize;touch-action:pan-y;user-select:none;-webkit-user-select:none}
.epwin:empty{display:none}
.epwin i{flex:1 1 0;min-width:2px;max-width:10px;height:var(--h);border-radius:2px;background:var(--line);transition:background-color .2s calc(var(--n) * 12ms)}
.epwin i.on{background:var(--accent)}
.epwin i.nofile{height:4px}
.epwin small{flex:none;align-self:center;margin-left:8px;font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums}
.epwin.all small{color:var(--accent)}
.wcap{margin:0 0 16px;font-size:13px;line-height:1.55;color:var(--dim)}
.wcap input[type=number]{display:inline-block;width:4.2em;margin:0 2px;padding:1px 4px;font-size:13px;text-align:center;font-variant-numeric:tabular-nums;color:var(--fg)}
.ffield{display:grid;gap:6px;margin-bottom:14px}
/* DeadAir's pause: four stops on one scale, a dot at each so it reads as four places to stop. */
.dpause{display:grid;gap:6px;margin-top:6px}
.dphead{display:flex;justify-content:space-between;align-items:baseline}
.dphead output{font-variant-numeric:tabular-nums;font-weight:600}
.dpslide{--t:22px;position:relative;height:var(--t)}
.dpslide::before,.dpslide::after{content:'';position:absolute;top:50%;pointer-events:none}
.dpslide::before{left:0;right:0;height:6px;margin-top:-3px;border-radius:3px;background:var(--raise)}
.dpslide::after{left:calc(var(--t)/2 - 3px);right:calc(var(--t)/2 - 3px);height:6px;margin-top:-3px;
background:radial-gradient(circle,var(--dim) 2px,transparent 2.5px) 0 0/6px 6px no-repeat,
radial-gradient(circle,var(--dim) 2px,transparent 2.5px) 33.333% 0/6px 6px no-repeat,
radial-gradient(circle,var(--dim) 2px,transparent 2.5px) 66.667% 0/6px 6px no-repeat,
radial-gradient(circle,var(--dim) 2px,transparent 2.5px) 100% 0/6px 6px no-repeat}
.dpslide input[type=range]{position:relative;z-index:1;width:100%;height:var(--t);margin:0;background:transparent}
.dpslide input[type=range]::-webkit-slider-thumb{width:var(--t);height:var(--t);box-shadow:0 0 0 3px var(--panel),0 1px 4px rgba(0,0,0,.3)}
.dpslide input[type=range]::-moz-range-thumb{width:var(--t);height:var(--t);box-shadow:0 0 0 3px var(--panel),0 1px 4px rgba(0,0,0,.3)}
.dpslide input[type=range]:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:var(--t)}
.dpends{display:flex;justify-content:space-between;font-size:12.5px;color:var(--dim)}
.dpause:has(input:disabled){opacity:.45}
.dpslide input[type=range]:disabled{cursor:default}
.fdl+.ffield{margin-top:18px}
/* DeadAir shares the box but not the fold: it applies whether or not this show downloads on its own. */
.fdead{border-top:1px solid var(--line);padding-top:10px;margin-top:4px}
.flabel{font-size:14px;font-weight:500}
.cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--dim)}
/* Words and phrases as chips: in one comma-separated box, one ran into the next. */
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:38px;padding:5px;border:1px solid var(--line);border-radius:9px;background:var(--bg);cursor:text}
.chips:focus-within{border-color:var(--accent)}
.chip{display:inline-flex;align-items:center;gap:2px;max-width:100%;padding:3px 3px 3px 10px;border-radius:999px;background:var(--panel2);color:var(--fg);font-size:13px;line-height:1.3;overflow-wrap:anywhere}
.chipx{display:grid;place-items:center;flex:none;width:20px;height:20px;padding:0;border-radius:50%;color:var(--dim)}
.chipx:hover{background:var(--raise);color:var(--fg)}
.chipx .i{width:9px;height:9px}
.chips .chipin{flex:1 0 8ch;width:auto;min-width:0;padding:4px 6px;border:0;background:none}
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60} #toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
.toast{ .toast{
background:var(--raise);border:1px solid var(--line);border-radius:9px; background:var(--raise);border:1px solid var(--line);border-radius:9px;
@@ -1072,8 +1185,10 @@ body:has(#player.on) #status{padding-bottom:3px}
@media (max-width:820px){ @media (max-width:820px){
#shell{grid-template-columns:1fr} #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)} /* The shadow only while open: off the screen, the drawer's fell across the page's left edge,
#sidebar.open{transform:none} 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} #scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
#player{ #player{
position:relative;z-index:39;gap:8px; position:relative;z-index:39;gap:8px;
@@ -1110,8 +1225,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>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.next>div{margin-left:16px}
#dpeek .encbox{margin:0 0 10px} #dpeek .encbox{margin:0 0 10px}
#dpeek.end{display:flex;align-items:center;padding-left:28px;color:var(--faint);font-size:13px} /* Past the last item, the accent and no words: the colour says it is the end. */
#dpeek.end p{max-width:4.5em;margin:0} #dpeek.end{background:var(--accent)}
/* Going back to the list from the first item: the list waits under a dimmer that lifts as the /* 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. */ reader, shadowed along its edge, is drawn off it. */
#dpeek.dim{width:100%;z-index:37;background:#000} #dpeek.dim{width:100%;z-index:37;background:#000}
@@ -1206,6 +1321,9 @@ body:has(#player.on) #status{padding-bottom:3px}
.fhead .sub{font-size:15px} .fhead .sub{font-size:15px}
.acts{gap:8px} .acts{gap:8px}
.acts .btn{min-width:44px;height:44px;padding:0 13px;justify-content:center;font-size:15px} .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 as a segmented control: equal segments on one track, the chosen one lifted. */
.tabs{flex:1 1 100%;border-radius:10px;padding:2px} .tabs{flex:1 1 100%;border-radius:10px;padding:2px}
@@ -1240,6 +1358,27 @@ body:has(#player.on) #status{padding-bottom:3px}
.card h3{font-size:22px} .card h3{font-size:22px}
.field label,.field .hint{font-size:13px} .field label,.field .hint{font-size:13px}
.check{font-size:17px} .check{font-size:17px}
/* A feed's settings, at a phone's sizes. */
.card.fset{padding:0}
.fsetbody{padding:20px 16px 24px}
.card.fset .fsetfoot{padding:12px 16px calc(12px + var(--safe-b))}
.fsethead .art{width:64px;height:64px;border-radius:14px}
.fsethead p,.wcap,.cap{font-size:14px}
.switch,.flabel{font-size:17px}
.switch{min-height:44px}
.sw{width:51px;height:31px}
.sw i::after{width:25px;height:25px}
.sw input:checked+i::after{transform:translateX(20px)}
.seg label{padding:7px 4px;border-radius:8px;font-size:14px}
.seg label:has(input:checked){box-shadow:0 1px 3px rgba(0,0,0,.22)}
.dpslide{--t:28px}
.dpends{font-size:14px}
/* 16px or more, or Safari zooms the page in on it. */
.wcap input[type=number]{font-size:16px;padding:3px 4px}
.chips{background:var(--panel2);border-color:var(--edge);border-radius:10px}
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
.chipx{width:26px;height:26px}
.fdl,.field.boxed{border-radius:14px}
.toast{border-radius:14px} .toast{border-radius:14px}
} }
@keyframes sheet{from{transform:translateY(100%)}} @keyframes sheet{from{transform:translateY(100%)}}
@@ -1253,6 +1392,8 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="classic"] #topbar, :root[data-theme="classic"] #topbar,
:root[data-theme="classic"] #status, :root[data-theme="classic"] #status,
:root[data-theme="classic"] #player{background:linear-gradient(#ececec,#c9c9c9);border-color:#8e8e8e;color:#1a1a1a} :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"] #status{text-align:center}
:root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3} :root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3}
:root[data-theme="classic"] #files{background:#f4f4f4} :root[data-theme="classic"] #files{background:#f4f4f4}
@@ -1273,6 +1414,11 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="classic"] .feed.sel .txt small, :root[data-theme="classic"] .feed.sel .txt small,
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff} :root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
:root[data-theme="classic"] #epSearch{border-radius:14px} :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{background:#c9302c;color:#fff}
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff} :root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
:root[data-theme="classic"] .ephead, :root[data-theme="classic"] .ephead,
@@ -1349,7 +1495,7 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)} :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. */ /* A narrow screen slides the feed list over the page, so it keeps its shadow. */
@media (max-width:820px){ @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. */ /* The card is the glass; a lighter scrim leaves something behind it to see through. */
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)} :root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)}
@@ -1368,12 +1514,17 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="glass"] .sidefoot button{border-radius:10px} :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"] .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 /* 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. */ light only catches the top edge there, and the bars at the top and bottom have none. */
@media (max-width:820px){ @media (max-width:820px){
:root[data-theme="glass"]{--glass-rim:inset 0 1px 0 var(--glass-lo)} :root[data-theme="glass"]{--glass-rim:inset 0 1px 0 var(--glass-lo)}
:root[data-theme="glass"] #topbar, :root[data-theme="glass"] #topbar,
:root[data-theme="glass"] #status{box-shadow:none} :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. */ /* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){ @media (prefers-reduced-transparency:reduce),(prefers-contrast:more){

View File

@@ -30,6 +30,11 @@ const STYLE = 'app.css';
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12); const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181). Served as it comes,
// by a name with its hash in it, which the page's script is given in place of __HLS_JS__.
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as /// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
/// one; the doctype only keeps it from complaining that a fragment has none. /// one; the doctype only keeps it from complaining that a fragment has none.
export function buildStyle({ minify = true } = {}) { export function buildStyle({ minify = true } = {}) {
@@ -51,7 +56,8 @@ export function buildStyle({ minify = true } = {}) {
/// The page and its script, built: { html, js, script }, where script is the file's name. /// The page and its script, built: { html, js, script }, where script is the file's name.
export function buildPage(name, { minify = true } = {}) { export function buildPage(name, { minify = true } = {}) {
const { script, src: files } = PAGES[name]; const { script, src: files } = PAGES[name];
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n'); const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
.replaceAll('__HLS_JS__', HLS_URL);
const js = swc.transformSync(src, { const js = swc.transformSync(src, {
filename: name + '.ts', filename: name + '.ts',
jsc: { jsc: {
@@ -88,6 +94,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
const out = process.argv[2] || path.join(here, 'dist'); const out = process.argv[2] || path.join(here, 'dist');
fs.mkdirSync(out, { recursive: true }); fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, STYLE), buildStyle()); fs.writeFileSync(path.join(out, STYLE), buildStyle());
fs.writeFileSync(path.join(out, 'hls.js'), HLS);
for (const name of Object.keys(PAGES)) { for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name); const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html); fs.writeFileSync(path.join(out, name), html);

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#0a1726">
<title>iPX</title> <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="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="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> <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. <!-- 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. --> 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"> <div class="tgroup">
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button> <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> <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> </div>
<div id="pright"> <div id="pright">
<button class="iconbtn" id="pdead" aria-pressed="false" aria-label="DeadAir" hidden></button>
<select id="rate" title="Speed"> <select id="rate" title="Speed">
<option value="0.8">0.8×</option><option value="1" selected>1×</option> <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> <option value="1.25">1.25×</option><option value="1.5">1.5×</option>

View File

@@ -1,7 +1,8 @@
/* ---------------- modals ---------------- */ /* ---------------- 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').innerHTML=html;
$('#modalCard').classList.toggle('wide',!!wide); $('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:'');
$('#modal').classList.add('on'); $('#modal').classList.add('on');
} }
function closeModal(){ function closeModal(){
@@ -137,7 +138,8 @@ function drawDirectory(box=$('#directory'), focus?: string){
<div class="show">${artHTML(show.image,NAME(show))}<div class="meta"><h2>${esc(NAME(show))}</h2>${ <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. // 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>`:''} show.category?`<button type="button" class="dlink" data-go="${esc(show.category)}">${esc(show.subcategory||show.category)}</button>`:''}
<p class="sub">${n?`${n} ${n===1?'person here subscribes':'people here subscribe'}`:'Nobody here subscribes yet'}</p> ${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="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>` <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>'); +sec(show.podcast?'Latest episodes':'Latest posts','<div class="latest" id="dshow"><p class="hint">Loading…</p></div>');
@@ -217,6 +219,8 @@ async function fillShow(box,p){
more.textContent=open?'Less':'More'; more.setAttribute('aria-expanded',String(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; const items=got.items;
list.innerHTML=items.length?items.map(e=>{ list.innerHTML=items.length?items.map(e=>{
const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500); const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500);
@@ -224,7 +228,8 @@ async function fillShow(box,p){
return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>` return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
+(text?`<p>${esc(text)}</p>`:'') +(text?`<p>${esc(text)}</p>`:'')
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>'; +(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. A feed nobody here subscribes to is checked once a day.</p>`; }).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 /// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
@@ -358,7 +363,15 @@ function due(ts){
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does /// 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 /// 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. /// 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(){ 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 g = await api('/api/settings');
const admin = !!(S.me&&S.me.admin); 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> openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
@@ -369,6 +382,8 @@ async function prefsModal(){
<select id="smode">${Object.entries(MODES).map(([k,t])=> <select id="smode">${Object.entries(MODES).map(([k,t])=>
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select> `<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> <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="field"><label>Subscriptions</label>
<div class="inline"> <div class="inline">
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. --> <!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
@@ -382,7 +397,7 @@ async function prefsModal(){
<span class="hint">Comma separated words or phrases, in every feed you read. An item with <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 one in its title or text is hidden from you and not downloaded for you. Each feed's
settings can add more.</span></div> settings can add more.</span></div>
<div class="field"><label for="tokname">API tokens</label> <div class="field boxed"><label for="tokname">API tokens</label>
<div id="tokens"></div> <div id="tokens"></div>
<div class="inline"><input type="text" id="tokname" placeholder="What it is for"> <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> <button class="btn" id="tokadd" title="Make an API token" aria-label="Make an API token">${ICON.plus} Make</button></div>
@@ -436,73 +451,186 @@ async function drawTokens(){
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean); 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){ function settingsModal(f, newUrl?: string){
const isGroup = S.feeds.some(c=>c.group===f.id); const kids=S.feeds.filter(c=>c.group===f.id).length;
openModal(`<h3>${esc(f.title||f.id)}</h3> const isGroup=kids>0;
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
settings apply to it and are inherited by every feed inside it.</p>`:''} const admin=!!(S.me&&S.me.admin);
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from const name=f.title||f.id;
${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 // Blank follows the server's default; 0 is every item (#166).
config.toml, and it stops following the subscription's settings.</p>`:''} let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest';
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed. let newest=f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
Everyone else keeps their own.</p> const whose=isGroup
<div class="field"><label>Keywords</label> ? `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.`
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}"> : parent
<span class="hint">Comma separated. Empty takes everything.</span></div> ? `It comes from ${esc(parent.title||parent.id)}, and follows your settings there until you save these.`
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label> : 'Your settings for this feed. Everyone else who reads it keeps their own.';
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}"> const sw=(input: string, text: string, cls='')=>`<label class="switch${cls}"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
<span class="hint">Comma separated words or phrases. An item with one in its title or text openModal(`<div class="fsetbody">
is hidden from you and not downloaded for you, as well as those your Settings hide <header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header>
everywhere.</span></div>`} <div class="field faddr"><label for="surl">Address</label>
<div class="field"><label>Newest episodes to download</label> <div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false"${admin?'':' readonly'}>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}"> <button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
<span class="hint">Blank follows the global default (${globalMax}). Older episodes stay ${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}</div>
listed to download by hand. <b>0 means every episode</b>, the whole back catalogue.</span></div> <div class="fdl">${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label> <div class="fdeps${f.auto_download?'':' off'}" id="sdeps"><div><div class="fdepsin">
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label> <div class="tabs seg" role="radiogroup" aria-label="How many to download">${
<div class="field"><label>Feed URL</label> [['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=>
<div class="inline"> `<label><input type="radio" name="swin" value="${v}"${mode===v?' checked':''}>${t}</label>`).join('')}</div>
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}> <div class="epwin" id="sepwin" aria-hidden="true"></div>
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button> <p class="wcap" id="swcap"></p>
<div class="ffield"><label class="flabel" for="skwin">Only items that mention</label>
<div class="chips" id="skw">${chipsHTML(f.keywords,'skwin')}</div>
<p class="cap">Any one of these will do, and a phrase needs all its words. Empty takes everything.</p></div>
${sw(`<input type="checkbox" role="switch" id="sexp"${f.allow_explicit?' checked':''}>`,'Include explicit items')}
</div></div></div>
<div class="ffield fdead">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
<p class="cap">Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so
episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses
are part of the telling.</p>
<div class="dpause">
<div class="dphead"><label class="flabel" for="sdpause">Breathing room</label><output id="sdpauseout" for="sdpause"></output></div>
<div class="dpslide"><input type="range" id="sdpause" min="200" max="500" step="100" value="${f.deadair_pause||300}"${f.deadair?'':' disabled'}></div>
<div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div>
<p class="cap">The silence kept where a long one is cut. More is easier on the ear; less saves more time.</p></div></div></div>
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
with what Settings hides everywhere.</p></div>`}
</div> </div>
<span class="hint">${S.me&&S.me.admin <div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
? `Shared with everyone reading this feed. Editing it keeps every item and download — <button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
handy when an auth token in the URL is rotated. The feed is re-checked from scratch
on the next scan.` // What only matters while it downloads on its own folds away, out of reach, when it does not.
: `The same for everyone reading this feed, so only an admin can change it.`}</span></div> const deps=$('#sdeps'), inner=$('.fdepsin',deps);
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label> inner.inert=!f.auto_download;
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}"> $('#sauto').onchange=e=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; };
<span class="hint">Where the files land. There is one copy however many people
subscribe, so this is the same for everyone.</span></div>`:''} // The download limit, said as what it does. A number in a box hid that it is a window on the
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label> // newest items, not a pace through the back catalogue (#97).
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}"> const each=isGroup?' of each show':'';
<datalist id="scats"></datalist> const older=' Older ones stay listed, to download by hand.';
<span class="hint">${f.feed_category const caption=()=>mode==='newest'
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.` ? `Downloads the newest <input type="number" id="smax" min="1" value="${newest}"
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</span></div>`:''} aria-label="How many of the newest to download">${each} on its own.${older}`
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button> : mode==='all'
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`); ? `Downloads every item${each}: the whole back catalogue${!isGroup&&f.entries?`, ${f.entries} so far`:''}, and each new one.`
: globalMax
? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}`
: `The server's default: downloads every item${each}, the whole back catalogue.`;
const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest;
let bars=[];
const draw=(words: boolean)=>{
// The newest items with a file, as hold_back counts them: one whose files a filter all
// skipped holds no place.
let left=limit();
// Everything: the older ones past the strip are lit too, in words.
$('#sepwin')?.classList.toggle('all',left===Infinity);
for(const b of bars){
const on=!b.classList.contains('nofile')&&left>0;
if(on) left--;
b.classList.toggle('on',on);
}
if(!words) return;
$('#swcap').innerHTML=caption();
const n=$('#smax');
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
};
for(const r of $$('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
// Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
$('#sdead').onchange=e=>{ $('#sdpause').disabled=!e.target.checked; };
const pause=$('#sdpause'), pauseText=()=>{ const s=`${pause.value/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
pause.oninput=pauseText; pauseText();
draw(true);
// The feed's own newest items, each as tall as it is long, so the strip reads as this show.
if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{
const items=page?.entries||[];
const strip=$('#sepwin'); if(!items.length||!strip) return;
const longest=Math.max(1,...items.map(e=>e.duration||0));
strip.innerHTML=items.map((e,i)=>{
const file=e.enclosures.some(x=>x.state!=='skipped');
const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60;
return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`;
}).join('')+(f.entries>items.length?`<small>+${f.entries-items.length} older</small>`:'');
bars=$$('i',strip);
draw(false);
// Drag along it to set how many: as far as the finger, the newest that far down.
const pick=(x: number)=>{
let n=0;
for(const b of bars){
if(!b.classList.contains('nofile')) n++;
if(x<=b.getBoundingClientRect().right) break;
}
newest=Math.max(1,n);
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); }
else { $('#smax').value=String(newest); draw(false); }
};
strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
}).catch(()=>{});
wireChips($('#skw'));
if($('#sblock')) wireChips($('#sblock'));
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); $('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
// Offer the categories the Directory already shows, so a blog about games joins Games rather // Opened to follow a feed that moved: the new address is what there is to look at.
// than starting a second chip beside it. if(newUrl){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); }
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
.flatMap(p=>[p.category,p.subcategory]).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
$('#ssave').onclick=async()=>{ $('#ssave').onclick=async()=>{
const max=$('#smax').value;
try{ try{
const patch: Record<string, unknown>={ const patch: Record<string, unknown>={
keywords:splitWords($('#skw').value), keywords:chipWords($('#skw')),
max_new_per_check:max===''?null:Number(max), max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked}; auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
if($('#sblock')) patch.blocked=splitWords($('#sblock').value); deadair_pause:Number($('#sdpause').value)};
// The shared half is an admin's to change, and the API refuses it from anyone else. if($('#sblock')) patch.blocked=chipWords($('#sblock'));
if(S.me&&S.me.admin){ // The address is the same for everyone: an admin's to change, for a feed that moved, and the
patch.url=$('#surl').value.trim(); // API refuses it from anyone else. The folder and category are not this dialog's at all.
patch.folder=$('#sfolder').value.trim()||null; if(admin) patch.url=$('#surl').value.trim();
patch.category=$('#scat').value.trim()||null;
}
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)}); 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(); await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); } }catch(e){ toast(e.message,true); }
}; };

View File

@@ -1,7 +1,19 @@
/* ---------------- live events ---------------- */ /* ---------------- live events ---------------- */
let sse; 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(){ function connect(){
sse=new EventSource('/api/events'); sse=new EventSource('/api/events');
// Back after a drop: whatever was sent meanwhile went to nobody.
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); }); const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh. // 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()); } if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
refreshEntries(); 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'){ else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live'); 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. // page reloads; reload the list here when a scan synced an OPML if that ever matters.
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); } else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); }
}; };
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); }; sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
} }
connect(); connect();
loadFeeds(); loadFeeds();

View File

@@ -95,7 +95,7 @@ document.addEventListener('touchmove', ev => {
}, {passive: false}); }, {passive: false});
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on /// 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 /// 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 /// (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. /// 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 || '')){ if(p.dataset.key !== kind + (e?.guid || '')){
p.dataset.key = kind + (e?.guid || ''); p.className = kind; 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; return p;
} }

View File

@@ -39,7 +39,8 @@ function renderEntries(){
const pane=$('#list'), top=pane?pane.scrollTop:0; const pane=$('#list'), top=pane?pane.scrollTop:0;
box.innerHTML=''; box.innerHTML='';
if(!S.entries.length){ 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; return;
} }
for(const e of S.entries) box.appendChild(epEl(e)); for(const e of S.entries) box.appendChild(epEl(e));
@@ -77,6 +78,8 @@ function epEl(e){
num&&`<span>${num}</span>`, num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`, left&&`<span>${left}</span>`,
others>0&&`<span>+${others} more file${others===1?'':'s'}</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>`, 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> ].filter(Boolean).join('<span class="dot"></span>')}</div>
</div> </div>
@@ -265,7 +268,7 @@ function showDetail(e){
syncTools(e); syncTools(e);
if(!e){ if(!e){
dropTurned(S.sel); 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>'; if(files) files.innerHTML='<p class="empty">No files</p>';
return; return;
} }
@@ -298,15 +301,16 @@ function detailHtml(e){
themselves would read as a mistake. */ e.title&&e.title.trim() ? `<h3 class="dt">${esc(e.title)}</h3>` : ''} themselves would read as a mistake. */ e.title&&e.title.trim() ? `<h3 class="dt">${esc(e.title)}</h3>` : ''}
<div class="dmeta"> <div class="dmeta">
${/* Joined, so a missing date or number leaves no stray dot behind. */ ${/* 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>')} .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> 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'}" <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> 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" ${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a> title="Open the original" aria-label="Open the original">${ICON.open}</a>
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''} <button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
</div> </div>
${narrow?encs:''} ${narrow?encs:''}
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`; <div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
@@ -323,7 +327,9 @@ function encBox(x){
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)` ? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
: 'Delete file'; : 'Delete file';
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`; const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`; // Played without its dead air, so saved without it too (#186).
const saveLabel=x.deadair>0?'Save to this computer, without its dead air':'Save to this computer';
const saveBtn=`<a class="btn ico" href="/media/${x.id}${x.deadair>0?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
// The publisher's address, not this server's copy, which only someone signed in here can open. // The publisher's address, not this server's copy, which only someone signed in here can open.
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`; const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
if(x.path && !isPlayable(x)){ if(x.path && !isPlayable(x)){

View File

@@ -1,8 +1,42 @@
/* ---------------- player ---------------- */ /* ---------------- player ---------------- */
const audio=$('#audio'); 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}; saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
/// Where a file plays from: DeadAir's playlist when the server says this person plays it
/// shortened (#171, #181), whose times the entry is already given in, else the file itself.
const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it
/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails
/// on the playlist's MP3 ranges with "stream parsing failed", and so plays nothing.
let hls: any=null;
function dropHls(){ if(hls){ hls.destroy(); hls=null; } }
/// Puts a file in the player: null when it is there at once, else a promise to wait on before
/// playing, while hls.js loads. Safari always takes the first way, where play() has to run within
/// the tap itself or it is refused.
function setSrc(url: string): Promise<void>|null{
dropHls();
const native=navigator.vendor.startsWith('Apple')&&!!audio.canPlayType('application/vnd.apple.mpegurl');
// Safari fetches the playlist itself, with no header to carry the tap's trace: in the address
// instead (#192). Only the playlist, which is never cached.
if(!url.endsWith('.m3u8')){ audio.src=url; return null; }
if(native){ audio.src=`${url}?traceparent=${traceparent()}`; return null; }
const w=window as any;
const ready=w.Hls?Promise.resolve():new Promise<void>((ok,fail)=>{
const s=document.createElement('script');
s.src='__HLS_JS__'; s.onload=()=>ok(); s.onerror=()=>fail(new Error('the DeadAir player did not load'));
document.head.append(s);
});
return ready.then(()=>{
// Its playlist and audio requests join the trace of the tap that played them (#192).
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
hls.loadSource(url); hls.attachMedia(audio);
});
}
// Marked read when an item has actually been listened to -- at the end, or past 90%. // 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 // 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. // you pressed play, which looks exactly like it went missing.
@@ -20,14 +54,16 @@ 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); toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
return; return;
} }
// The same file carries on where it was; another of the item's files starts from its top. // The same file carries on where it was; another of the item's files starts from its top. So
const resuming = player.guid===e.guid && player.enc===enc.id; // 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. // 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 && !audio.paused){ audio.pause(); return; }
let wait: Promise<void>|null=null;
if(!resuming){ if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false; 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'); document.body.classList.toggle('has-video', kindOf(enc)==='video');
audio.src=`/media/${enc.id}`; wait=setSrc(player.src=mediaSrc(enc));
audio.currentTime=0; audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true}); 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. // Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
@@ -38,8 +74,10 @@ function play(e,enc=e.enclosures.find(isPlayable)){
$('#player').classList.add('on'); $('#player').classList.add('on');
mediaSession(e,f); mediaSession(e,f);
player.marked=false; player.marked=false;
paintDead();
} }
audio.play().catch(err=>toast('Playback failed: '+err.message,true)); const go=()=>audio.play().catch(err=>toast('Playback failed: '+err.message,true));
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
renderEntries(); renderEntries();
} }
function mediaSession(e,f){ function mediaSession(e,f){
@@ -78,7 +116,8 @@ function savePos(){
// cannot tell a finished episode from a started one. NaN before metadata, Infinity on a stream. // cannot tell a finished episode from a started one. NaN before metadata, Infinity on a stream.
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null; const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
navigator.sendBeacon?.( navigator.sendBeacon?.(
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`, // A beacon sends no headers, so the trace goes in the address (#192).
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position?traceparent=${traceparent()}`,
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'})); new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
} }
audio.addEventListener('pause',savePos); audio.addEventListener('pause',savePos);
@@ -104,13 +143,69 @@ function syncPlayButtons(){
} }
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening); for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
window.addEventListener('beforeunload',savePos); window.addEventListener('beforeunload',savePos);
/// For the server's log (#190): how long play takes to start, how long it stalls for, and what
/// the browser says when it cannot play a file at all.
let asked=0, waited=0;
const playing=()=>({feed:player.feed,enclosure:player.enc,deadair:!!player.src?.endsWith('.m3u8')});
audio.addEventListener('play',()=>{ asked=performance.now(); });
audio.addEventListener('waiting',()=>{ if(!asked) waited=performance.now(); });
audio.addEventListener('playing',()=>{
const now=performance.now();
if(asked) report('play_start',{ms:Math.round(now-asked),...playing()});
else if(waited && now-waited>=500) report('stall',{ms:Math.round(now-waited),...playing()});
asked=0; waited=0;
});
audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); });
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause(); $('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15; $('#pback').onclick=()=>audio.currentTime-=15;
$('#pfwd').onclick=()=>audio.currentTime+=30; $('#pfwd').onclick=()=>audio.currentTime+=30;
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000}; $('#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)}; $('#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)}; $('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries(); /// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
/// icon's shape and aria-pressed, never a colour.
function paintDead(){
const b=$('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
b.hidden=!f||document.body.classList.contains('has-video');
if(b.hidden) return;
b.setAttribute('aria-pressed',String(!!f.deadair));
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
}
$('#pdead').onclick=async()=>{
const f=S.feeds.find(x=>x.id===player.feed); if(!f) return;
const on=!f.deadair;
try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); }
catch(err){ toast(err.message,true); return; }
f.deadair=on; paintDead();
const switched=await switchVersion();
toast(!on?'DeadAir is off for this show':switched?'DeadAir is cutting the dead air from this show'
:'DeadAir is on for this show. Episodes lose their dead air a few minutes after they download.');
renderEntries();
};
/// Carries on at the same place in the other version of what is playing, once DeadAir has been
/// turned on or off for its show. True if it changed version: a copy not made yet plays next time.
async function switchVersion(){
if(!player.guid) return false;
const [feed,guid]=[player.feed,player.guid].map(encodeURIComponent);
// Saved and waited for, not beaconed, so the entry asked for next has this place in it.
if(audio.readyState){
await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({
secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{});
player.moved=false;
}
const e=await api(`/api/entries/${feed}/${guid}`).catch(()=>null);
const enc=e&&e.enclosures.find(x=>x.id===player.enc);
if(!enc||mediaSrc(enc)===player.src) return false;
const was=!audio.paused;
player.entry=e; player.src=mediaSrc(enc); player.saveAt=e.position;
const wait=setSrc(player.src);
audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
return true;
}
$('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
// Called here, not left to the pause event: closing a player already paused fires none. // Called here, not left to the pause event: closing a player already paused fires none.
syncListening()}; syncListening()};
(function restore(){ (function restore(){

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; : theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
// The tab's icon in the same variant as the logo on the page. // The tab's icon in the same variant as the logo on the page.
const fav = $('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode]; const fav = $('#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 sel = $('#stheme'); if(sel) sel.value = theme.name;
const ms = $('#smode'); if(ms) ms.value = theme.mode; const ms = $('#smode'); if(ms) ms.value = theme.mode;
const mf = $('#smodefield'); if(mf) mf.hidden = !both; 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. // 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 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 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 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 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 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
@@ -56,8 +60,40 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
// The page's own buttons name their icon; this draws it in, ahead of any label they carry. // The page's own buttons name their icon; this draws it in, ahead of any label they carry.
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]); for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
/// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line
/// becomes how much shorter it is once the copy is ready (the dead_air event).
const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>';
/// What DeadAir took off an episode, as the list says it.
function shorter(secs){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
/// A total of time saved: "3 h 12 min", "12 min", "40 s".
function spanText(secs){
const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60);
return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`;
}
/// One trace for each thing you do (#190): the requests a tap or a key sets off share a trace id,
/// sent as traceparent, so Tempo shows them together. getRandomValues, not randomUUID, which a
/// LAN address over plain HTTP does not have.
let traceId='';
const hex=(n: number)=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join('');
for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ traceId=hex(16); },{capture:true,passive:true});
function traceparent(){ return `00-${traceId||=hex(16)}-${hex(8)}-01`; }
/// Something that went wrong, or how long something took, in the browser, for the server's log
/// (#190). The same thing at most once a minute: a failure in a loop would otherwise send a line
/// a frame.
const reported=new Map<string,number>();
function report(ev: string, o: Record<string,any>={}){
const key=ev+(o.msg||''), now=Date.now();
if(o.msg==='signed out' || now-(reported.get(key)||0)<60_000) return;
reported.set(key,now);
fetch('/api/client',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json',traceparent:traceparent()},
body:JSON.stringify({ev,...o})}).catch(()=>{});
}
window.addEventListener('error',e=>report('page_error',{msg:String(e.message||e.error||'error'),src:e.filename,line:e.lineno}));
window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e.reason?.message||e.reason)}));
async function api(url: string, opts?: RequestInit): Promise<any>{ async function api(url: string, opts?: RequestInit): Promise<any>{
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts}); const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
if(r.status===401){ location.href='/login'; throw new Error('signed out'); } if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
if(!r.ok) throw new Error(await r.text().catch(()=>'')||String(r.status)); if(!r.ok) throw new Error(await r.text().catch(()=>'')||String(r.status));
return r.status===204?null:r.json().catch(()=>null); return r.status===204?null:r.json().catch(()=>null);