Commit Graph

444 Commits

Author SHA1 Message Date
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>
v0.11.0
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>
v0.10.3
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